From 108d3f4ecad4c0ead14941c2067a21546750fedc Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Mon, 13 Apr 2026 04:40:38 +0200 Subject: [PATCH] feat: position animated independently per axis Made-with: Cursor --- .../hooks/use-keyframed-vector-property.ts | 190 ---- .../panels/properties/tabs/transform-tab.tsx | 892 +++++++++--------- apps/web/src/lib/animation/binding-values.ts | 12 + apps/web/src/lib/animation/graph-channels.ts | 24 +- apps/web/src/lib/animation/index.ts | 4 +- apps/web/src/lib/animation/keyframes.ts | 46 +- .../src/lib/animation/property-registry.ts | 31 +- apps/web/src/lib/animation/resolve.ts | 20 +- apps/web/src/lib/animation/types.ts | 6 +- .../src/services/storage/migrations/index.ts | 114 +-- .../migrations/transformers/v24-to-v25.ts | 140 +++ .../services/storage/migrations/v24-to-v25.ts | 16 + 12 files changed, 775 insertions(+), 720 deletions(-) delete mode 100644 apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts create mode 100644 apps/web/src/services/storage/migrations/transformers/v24-to-v25.ts create mode 100644 apps/web/src/services/storage/migrations/v24-to-v25.ts diff --git a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts b/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts deleted file mode 100644 index 7ee1f221..00000000 --- a/apps/web/src/components/editor/panels/properties/hooks/use-keyframed-vector-property.ts +++ /dev/null @@ -1,190 +0,0 @@ -import { useEditor } from "@/hooks/use-editor"; -import { - getKeyframeAtTime, - hasKeyframesForPath, - upsertElementKeyframe, -} from "@/lib/animation"; -import type { - AnimationPropertyPath, - ElementAnimations, - VectorValue, -} from "@/lib/animation/types"; -import type { TimelineElement } from "@/lib/timeline"; -import { snapToStep } from "@/utils/math"; -import { usePropertyDraft } from "./use-property-draft"; - -export function useKeyframedVectorProperty({ - trackId, - elementId, - animations, - propertyPath, - localTime, - isPlayheadWithinElementRange, - resolvedValue, - displayX, - displayY, - parseComponent, - step, - buildBaseUpdates, -}: { - trackId: string; - elementId: string; - animations: ElementAnimations | undefined; - propertyPath: AnimationPropertyPath; - localTime: number; - isPlayheadWithinElementRange: boolean; - resolvedValue: VectorValue; - displayX: string; - displayY: string; - parseComponent: (input: string) => number | null; - step?: number; - buildBaseUpdates: ({ - value, - }: { - value: VectorValue; - }) => Partial; -}) { - const editor = useEditor(); - const snapComponentValue = (value: number) => - step != null ? snapToStep({ value, step }) : value; - - const hasAnimatedKeyframes = hasKeyframesForPath({ - animations, - propertyPath, - }); - const keyframeAtTime = isPlayheadWithinElementRange - ? getKeyframeAtTime({ animations, propertyPath, time: localTime }) - : null; - const keyframeIdAtTime = keyframeAtTime?.id ?? null; - const isKeyframedAtTime = keyframeAtTime !== null; - const shouldUseAnimatedChannel = - hasAnimatedKeyframes && isPlayheadWithinElementRange; - - const previewVector = ({ value }: { value: VectorValue }) => { - const nextValue = { - x: snapComponentValue(value.x), - y: snapComponentValue(value.y), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.previewElements({ - updates: [ - { - trackId, - elementId, - updates: { - animations: upsertElementKeyframe({ - animations, - propertyPath, - time: localTime, - value: nextValue, - }), - }, - }, - ], - }); - return; - } - - editor.timeline.previewElements({ - updates: [{ trackId, elementId, updates: buildBaseUpdates({ value: nextValue }) }], - }); - }; - - const x = usePropertyDraft({ - displayValue: displayX, - parse: (input) => { - const parsedValue = parseComponent(input); - return parsedValue === null ? null : snapComponentValue(parsedValue); - }, - onPreview: (xVal) => - previewVector({ value: { x: xVal, y: resolvedValue.y } }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const y = usePropertyDraft({ - displayValue: displayY, - parse: (input) => { - const parsedValue = parseComponent(input); - return parsedValue === null ? null : snapComponentValue(parsedValue); - }, - onPreview: (yVal) => - previewVector({ value: { x: resolvedValue.x, y: yVal } }), - onCommit: () => editor.timeline.commitPreview(), - }); - - const toggleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - - if (keyframeIdAtTime) { - editor.timeline.removeKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, keyframeId: keyframeIdAtTime }, - ], - }); - return; - } - - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId, - propertyPath, - time: localTime, - value: resolvedValue, - }, - ], - }); - }; - - const commitX = ({ value }: { value: number }) => { - const vector: VectorValue = { - x: snapComponentValue(value), - y: snapComponentValue(resolvedValue.y), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: vector }, - ], - }); - return; - } - editor.timeline.updateElements({ - updates: [ - { trackId, elementId, patch: buildBaseUpdates({ value: vector }) }, - ], - }); - }; - - const commitY = ({ value }: { value: number }) => { - const vector: VectorValue = { - x: snapComponentValue(resolvedValue.x), - y: snapComponentValue(value), - }; - if (shouldUseAnimatedChannel) { - editor.timeline.upsertKeyframes({ - keyframes: [ - { trackId, elementId, propertyPath, time: localTime, value: vector }, - ], - }); - return; - } - editor.timeline.updateElements({ - updates: [ - { trackId, elementId, patch: buildBaseUpdates({ value: vector }) }, - ], - }); - }; - - return { - x, - y, - hasAnimatedKeyframes, - isKeyframedAtTime, - keyframeIdAtTime, - toggleKeyframe, - commitX, - commitY, - }; -} diff --git a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx index 40519c1d..c3cef2d6 100644 --- a/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx +++ b/apps/web/src/components/editor/panels/properties/tabs/transform-tab.tsx @@ -1,430 +1,462 @@ -import { NumberField } from "@/components/ui/number-field"; -import { useEditor } from "@/hooks/use-editor"; -import { clamp, isNearlyEqual } from "@/utils/math"; -import type { VisualElement } from "@/lib/timeline"; -import { - Section, - SectionContent, - SectionField, - SectionFields, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { Button } from "@/components/ui/button"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - ArrowExpandIcon, - Link05Icon, - RotateClockwiseIcon, -} from "@hugeicons/core-free-icons"; -import { - getGroupKeyframesAtTime, - hasGroupKeyframeAtTime, - resolveTransformAtTime, -} from "@/lib/animation"; -import { DEFAULTS } from "@/lib/timeline/defaults"; -import { useElementPlayhead } from "../hooks/use-element-playhead"; -import { KeyframeToggle } from "../components/keyframe-toggle"; -import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; -import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property"; -import { usePropertiesStore } from "../stores/properties-store"; - -export function parseNumericInput({ input }: { input: string }): number | null { - const parsed = parseFloat(input); - return Number.isNaN(parsed) ? null : parsed; -} - -export function isPropertyAtDefault({ - hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue, - staticValue, - defaultValue, -}: { - hasAnimatedKeyframes: boolean; - isPlayheadWithinElementRange: boolean; - resolvedValue: number; - staticValue: number; - defaultValue: number; -}): boolean { - if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { - return isNearlyEqual({ - leftValue: resolvedValue, - rightValue: defaultValue, - }); - } - - return staticValue === defaultValue; -} - -export function TransformTab({ - element, - trackId, -}: { - element: VisualElement; - trackId: string; -}) { - const editor = useEditor(); - const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); - const setTransformScaleLocked = usePropertiesStore( - (s) => s.setTransformScaleLocked, - ); - const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ - startTime: element.startTime, - duration: element.duration, - }); - const resolvedTransform = resolveTransformAtTime({ - baseTransform: element.transform, - animations: element.animations, - localTime, - }); - - const position = useKeyframedVectorProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.position", - localTime, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position, - displayX: Math.round(resolvedTransform.position.x).toString(), - displayY: Math.round(resolvedTransform.position.y).toString(), - parseComponent: (input) => parseNumericInput({ input }), - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { ...element.transform, position: value }, - }), - }); - - const parseScale = (input: string) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return parsed / 100; - }; - - const scaleX = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleX", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleX, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleX: value, - ...(isScaleLocked ? { scaleY: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleY", value }] - : undefined, - }); - - const scaleY = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.scaleY", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), - parse: parseScale, - valueAtPlayhead: resolvedTransform.scaleY, - step: 0.01, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - scaleY: value, - ...(isScaleLocked ? { scaleX: value } : {}), - }, - }), - buildAdditionalKeyframes: isScaleLocked - ? ({ value }) => [{ propertyPath: "transform.scaleX", value }] - : undefined, - }); - - const scaleFieldPropsX = { - value: scaleX.displayValue, - onFocus: scaleX.onFocus, - onChange: scaleX.onChange, - onBlur: scaleX.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleX.scrubTo, - onScrubEnd: scaleX.commitScrub, - onReset: () => - scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleX, - staticValue: element.transform.scaleX, - defaultValue: DEFAULTS.element.transform.scaleX, - }), - }; - - const scaleFieldPropsY = { - value: scaleY.displayValue, - onFocus: scaleY.onFocus, - onChange: scaleY.onChange, - onBlur: scaleY.onBlur, - dragSensitivity: "slow" as const, - onScrub: scaleY.scrubTo, - onScrubEnd: scaleY.commitScrub, - onReset: () => - scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), - isDefault: isPropertyAtDefault({ - hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.scaleY, - staticValue: element.transform.scaleY, - defaultValue: DEFAULTS.element.transform.scaleY, - }), - }; - - const rotation = useKeyframedNumberProperty({ - trackId, - elementId: element.id, - animations: element.animations, - propertyPath: "transform.rotate", - localTime, - isPlayheadWithinElementRange, - displayValue: Math.round(resolvedTransform.rotate).toString(), - parse: (input) => { - const parsed = parseNumericInput({ input }); - if (parsed === null) return null; - return clamp({ value: parsed, min: -360, max: 360 }); - }, - valueAtPlayhead: resolvedTransform.rotate, - step: 1, - buildBaseUpdates: ({ value }) => ({ - transform: { - ...element.transform, - rotate: value, - }, - }), - }); - - const hasScaleKeyframe = hasGroupKeyframeAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - - const toggleScaleKeyframe = () => { - if (!isPlayheadWithinElementRange) return; - const existing = getGroupKeyframesAtTime({ - animations: element.animations, - group: "transform.scale", - time: localTime, - }); - if (existing.length > 0) { - editor.timeline.removeKeyframes({ - keyframes: existing.map((ref) => ({ - trackId, - elementId: element.id, - ...ref, - })), - }); - return; - } - editor.timeline.upsertKeyframes({ - keyframes: [ - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleX", - time: localTime, - value: resolvedTransform.scaleX, - }, - { - trackId, - elementId: element.id, - propertyPath: "transform.scaleY", - time: localTime, - value: resolvedTransform.scaleY, - }, - ], - }); - }; - - const scaleLockButton = ( - - ); - - return ( -
- - Transform - - - -
- {isScaleLocked ? ( - <> - - } - > - } - {...scaleFieldPropsX} - /> - - {scaleLockButton} - - ) : ( - <> - - } - > - - - {scaleLockButton} - - } - > - - - - )} -
- - } - > -
- - position.commitX({ - value: DEFAULTS.element.transform.position.x, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: position.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.x, - staticValue: element.transform.position.x, - defaultValue: DEFAULTS.element.transform.position.x, - })} - /> - - position.commitY({ - value: DEFAULTS.element.transform.position.y, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: position.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.position.y, - staticValue: element.transform.position.y, - defaultValue: DEFAULTS.element.transform.position.y, - })} - /> -
-
- - - } - > -
- } - className="flex-none" - value={rotation.displayValue} - onFocus={rotation.onFocus} - onChange={rotation.onChange} - onBlur={rotation.onBlur} - dragSensitivity="slow" - onScrub={rotation.scrubTo} - onScrubEnd={rotation.commitScrub} - onReset={() => - rotation.commitValue({ - value: DEFAULTS.element.transform.rotate, - }) - } - isDefault={isPropertyAtDefault({ - hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, - isPlayheadWithinElementRange, - resolvedValue: resolvedTransform.rotate, - staticValue: element.transform.rotate, - defaultValue: DEFAULTS.element.transform.rotate, - })} - /> -
-
-
-
-
- ); -} +import { NumberField } from "@/components/ui/number-field"; +import { useEditor } from "@/hooks/use-editor"; +import { clamp, isNearlyEqual } from "@/utils/math"; +import type { VisualElement } from "@/lib/timeline"; +import { + Section, + SectionContent, + SectionField, + SectionFields, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { Button } from "@/components/ui/button"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + ArrowExpandIcon, + Link05Icon, + RotateClockwiseIcon, +} from "@hugeicons/core-free-icons"; +import { + getGroupKeyframesAtTime, + hasGroupKeyframeAtTime, + resolveTransformAtTime, +} from "@/lib/animation"; +import { DEFAULTS } from "@/lib/timeline/defaults"; +import { useElementPlayhead } from "../hooks/use-element-playhead"; +import { KeyframeToggle } from "../components/keyframe-toggle"; +import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; +import { usePropertiesStore } from "../stores/properties-store"; + +export function parseNumericInput({ input }: { input: string }): number | null { + const parsed = parseFloat(input); + return Number.isNaN(parsed) ? null : parsed; +} + +export function isPropertyAtDefault({ + hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue, + staticValue, + defaultValue, +}: { + hasAnimatedKeyframes: boolean; + isPlayheadWithinElementRange: boolean; + resolvedValue: number; + staticValue: number; + defaultValue: number; +}): boolean { + if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { + return isNearlyEqual({ + leftValue: resolvedValue, + rightValue: defaultValue, + }); + } + + return staticValue === defaultValue; +} + +export function TransformTab({ + element, + trackId, +}: { + element: VisualElement; + trackId: string; +}) { + const editor = useEditor(); + const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); + const setTransformScaleLocked = usePropertiesStore( + (s) => s.setTransformScaleLocked, + ); + const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ + startTime: element.startTime, + duration: element.duration, + }); + const resolvedTransform = resolveTransformAtTime({ + baseTransform: element.transform, + animations: element.animations, + localTime, + }); + + const positionX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.positionX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.position.x).toString(), + parse: (input) => parseNumericInput({ input }), + valueAtPlayhead: resolvedTransform.position.x, + step: 1, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + position: { ...element.transform.position, x: value }, + }, + }), + }); + + const positionY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.positionY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.position.y).toString(), + parse: (input) => parseNumericInput({ input }), + valueAtPlayhead: resolvedTransform.position.y, + step: 1, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + position: { ...element.transform.position, y: value }, + }, + }), + }); + + const parseScale = (input: string) => { + const parsed = parseNumericInput({ input }); + if (parsed === null) return null; + return parsed / 100; + }; + + const scaleX = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.scaleX", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), + parse: parseScale, + valueAtPlayhead: resolvedTransform.scaleX, + step: 0.01, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + scaleX: value, + ...(isScaleLocked ? { scaleY: value } : {}), + }, + }), + buildAdditionalKeyframes: isScaleLocked + ? ({ value }) => [{ propertyPath: "transform.scaleY", value }] + : undefined, + }); + + const scaleY = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.scaleY", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), + parse: parseScale, + valueAtPlayhead: resolvedTransform.scaleY, + step: 0.01, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + scaleY: value, + ...(isScaleLocked ? { scaleX: value } : {}), + }, + }), + buildAdditionalKeyframes: isScaleLocked + ? ({ value }) => [{ propertyPath: "transform.scaleX", value }] + : undefined, + }); + + const scaleFieldPropsX = { + value: scaleX.displayValue, + onFocus: scaleX.onFocus, + onChange: scaleX.onChange, + onBlur: scaleX.onBlur, + dragSensitivity: "slow" as const, + onScrub: scaleX.scrubTo, + onScrubEnd: scaleX.commitScrub, + onReset: () => + scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), + isDefault: isPropertyAtDefault({ + hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.scaleX, + staticValue: element.transform.scaleX, + defaultValue: DEFAULTS.element.transform.scaleX, + }), + }; + + const scaleFieldPropsY = { + value: scaleY.displayValue, + onFocus: scaleY.onFocus, + onChange: scaleY.onChange, + onBlur: scaleY.onBlur, + dragSensitivity: "slow" as const, + onScrub: scaleY.scrubTo, + onScrubEnd: scaleY.commitScrub, + onReset: () => + scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), + isDefault: isPropertyAtDefault({ + hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.scaleY, + staticValue: element.transform.scaleY, + defaultValue: DEFAULTS.element.transform.scaleY, + }), + }; + + const rotation = useKeyframedNumberProperty({ + trackId, + elementId: element.id, + animations: element.animations, + propertyPath: "transform.rotate", + localTime, + isPlayheadWithinElementRange, + displayValue: Math.round(resolvedTransform.rotate).toString(), + parse: (input) => { + const parsed = parseNumericInput({ input }); + if (parsed === null) return null; + return clamp({ value: parsed, min: -360, max: 360 }); + }, + valueAtPlayhead: resolvedTransform.rotate, + step: 1, + buildBaseUpdates: ({ value }) => ({ + transform: { + ...element.transform, + rotate: value, + }, + }), + }); + + const hasScaleKeyframe = hasGroupKeyframeAtTime({ + animations: element.animations, + group: "transform.scale", + time: localTime, + }); + + const toggleScaleKeyframe = () => { + if (!isPlayheadWithinElementRange) return; + const existing = getGroupKeyframesAtTime({ + animations: element.animations, + group: "transform.scale", + time: localTime, + }); + if (existing.length > 0) { + editor.timeline.removeKeyframes({ + keyframes: existing.map((ref) => ({ + trackId, + elementId: element.id, + ...ref, + })), + }); + return; + } + editor.timeline.upsertKeyframes({ + keyframes: [ + { + trackId, + elementId: element.id, + propertyPath: "transform.scaleX", + time: localTime, + value: resolvedTransform.scaleX, + }, + { + trackId, + elementId: element.id, + propertyPath: "transform.scaleY", + time: localTime, + value: resolvedTransform.scaleY, + }, + ], + }); + }; + + const scaleLockButton = ( + + ); + + return ( +
+ + Transform + + + +
+ {isScaleLocked ? ( + <> + + } + > + } + {...scaleFieldPropsX} + /> + + {scaleLockButton} + + ) : ( + <> + + } + > + + + {scaleLockButton} + + } + > + + + + )} +
+
+ + } + > + + positionX.commitValue({ + value: DEFAULTS.element.transform.position.x, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: positionX.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.x, + staticValue: element.transform.position.x, + defaultValue: DEFAULTS.element.transform.position.x, + })} + /> + + + } + > + + positionY.commitValue({ + value: DEFAULTS.element.transform.position.y, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: positionY.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.position.y, + staticValue: element.transform.position.y, + defaultValue: DEFAULTS.element.transform.position.y, + })} + /> + +
+ + + } + > +
+ } + className="flex-none" + value={rotation.displayValue} + onFocus={rotation.onFocus} + onChange={rotation.onChange} + onBlur={rotation.onBlur} + dragSensitivity="slow" + onScrub={rotation.scrubTo} + onScrubEnd={rotation.commitScrub} + onReset={() => + rotation.commitValue({ + value: DEFAULTS.element.transform.rotate, + }) + } + isDefault={isPropertyAtDefault({ + hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, + isPlayheadWithinElementRange, + resolvedValue: resolvedTransform.rotate, + staticValue: element.transform.rotate, + defaultValue: DEFAULTS.element.transform.rotate, + })} + /> +
+
+
+
+
+ ); +} diff --git a/apps/web/src/lib/animation/binding-values.ts b/apps/web/src/lib/animation/binding-values.ts index 344fd776..66fb2b5d 100644 --- a/apps/web/src/lib/animation/binding-values.ts +++ b/apps/web/src/lib/animation/binding-values.ts @@ -47,6 +47,18 @@ 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, }: { diff --git a/apps/web/src/lib/animation/graph-channels.ts b/apps/web/src/lib/animation/graph-channels.ts index 240b00ed..19d53e27 100644 --- a/apps/web/src/lib/animation/graph-channels.ts +++ b/apps/web/src/lib/animation/graph-channels.ts @@ -1,4 +1,5 @@ import type { + AnimationBindingInstance, AnimationPath, ElementAnimations, ScalarAnimationChannel, @@ -6,6 +7,11 @@ import type { ScalarGraphKeyframeContext, } from "@/lib/animation/types"; +export interface EditableScalarChannels { + binding: AnimationBindingInstance; + channels: ScalarGraphChannel[]; +} + function isScalarAnimationChannel( channel: ElementAnimations["channels"][string], ): channel is ScalarAnimationChannel { @@ -18,13 +24,13 @@ export function getEditableScalarChannels({ }: { animations: ElementAnimations | undefined; propertyPath: AnimationPath; -}): ScalarGraphChannel[] { +}): EditableScalarChannels | null { const binding = animations?.bindings[propertyPath]; if (!binding) { - return []; + return null; } - return binding.components.flatMap((component) => { + const channels = binding.components.flatMap((component) => { const channel = animations?.channels[component.channelId]; if (!isScalarAnimationChannel(channel)) { return []; @@ -36,9 +42,11 @@ export function getEditableScalarChannels({ componentKey: component.key, channelId: component.channelId, channel, - }, + } satisfies ScalarGraphChannel, ]; }); + + return { binding, channels }; } export function getEditableScalarChannel({ @@ -50,12 +58,8 @@ export function getEditableScalarChannel({ propertyPath: AnimationPath; componentKey: string; }): ScalarGraphChannel | null { - return ( - getEditableScalarChannels({ - animations, - propertyPath, - }).find((channel) => channel.componentKey === componentKey) ?? null - ); + const result = getEditableScalarChannels({ animations, propertyPath }); + return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null; } export function getScalarKeyframeContext({ diff --git a/apps/web/src/lib/animation/index.ts b/apps/web/src/lib/animation/index.ts index 7d2646d9..384f3d17 100644 --- a/apps/web/src/lib/animation/index.ts +++ b/apps/web/src/lib/animation/index.ts @@ -49,6 +49,7 @@ export { } from "./keyframe-query"; export { + type EditableScalarChannels, getEditableScalarChannel, getEditableScalarChannels, getScalarKeyframeContext, @@ -90,5 +91,6 @@ export { } from "./property-groups"; export { - isVectorValue, + type EasingMode, + getEasingModeForKind, } from "./binding-values"; diff --git a/apps/web/src/lib/animation/keyframes.ts b/apps/web/src/lib/animation/keyframes.ts index 05290c1d..a5efb6ee 100644 --- a/apps/web/src/lib/animation/keyframes.ts +++ b/apps/web/src/lib/animation/keyframes.ts @@ -173,7 +173,7 @@ function createScalarKey({ id: string; time: number; value: number; - interpolation: AnimationInterpolation; + interpolation?: AnimationInterpolation; previousKey?: ScalarAnimationKey; }): ScalarAnimationKey { return { @@ -183,7 +183,8 @@ function createScalarKey({ leftHandle: previousKey?.leftHandle, rightHandle: previousKey?.rightHandle, segmentToNext: - previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }), + previousKey?.segmentToNext ?? + getScalarSegmentType({ interpolation: interpolation ?? "linear" }), tangentMode: previousKey?.tangentMode ?? "flat", }; } @@ -332,12 +333,14 @@ function upsertScalarChannelKey({ time, value, interpolation, + defaultInterpolation, keyframeId, }: { channel: ScalarAnimationChannel | undefined; time: number; value: number; - interpolation: AnimationInterpolation; + interpolation?: AnimationInterpolation; + defaultInterpolation?: AnimationInterpolation; keyframeId?: string; }): ScalarAnimationChannel { const normalizedChannel = normalizeChannel({ @@ -352,10 +355,13 @@ function upsertScalarChannelKey({ time, value, interpolation, - previousKey: { - ...keys[existingIndex], - segmentToNext: getScalarSegmentType({ interpolation }), - }, + previousKey: + interpolation != null + ? { + ...keys[existingIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + } + : keys[existingIndex], }); return normalizeChannel({ channel: { @@ -376,10 +382,13 @@ function upsertScalarChannelKey({ time: keys[existingAtTimeIndex].time, value, interpolation, - previousKey: { - ...keys[existingAtTimeIndex], - segmentToNext: getScalarSegmentType({ interpolation }), - }, + previousKey: + interpolation != null + ? { + ...keys[existingAtTimeIndex], + segmentToNext: getScalarSegmentType({ interpolation }), + } + : keys[existingAtTimeIndex], }); return normalizeChannel({ channel: { @@ -395,7 +404,7 @@ function upsertScalarChannelKey({ id: keyframeId ?? generateUUID(), time, value, - interpolation, + interpolation: interpolation ?? defaultInterpolation, }), ); return normalizeChannel({ @@ -472,9 +481,13 @@ export function upsertPathKeyframe({ return animations; } - const nextInterpolation = getInterpolationForBinding({ + const explicitInterpolation = + interpolation != null + ? getInterpolationForBinding({ kind, interpolation }) + : undefined; + const validatedDefaultInterpolation = getInterpolationForBinding({ kind, - interpolation: interpolation ?? defaultInterpolation, + interpolation: defaultInterpolation, }); nextAnimations.bindings[propertyPath] = binding; for (const component of binding.components) { @@ -503,7 +516,8 @@ export function upsertPathKeyframe({ channel: targetChannel, time: targetKey.time, value: nextValue as number, - interpolation: nextInterpolation, + interpolation: explicitInterpolation, + defaultInterpolation: validatedDefaultInterpolation, keyframeId: targetKey.id, }); } @@ -592,7 +606,7 @@ export function upsertKeyframe({ channel, time, value, - interpolation: interpolation ?? "linear", + interpolation, keyframeId, }); } diff --git a/apps/web/src/lib/animation/property-registry.ts b/apps/web/src/lib/animation/property-registry.ts index ac15805e..65713e6d 100644 --- a/apps/web/src/lib/animation/property-registry.ts +++ b/apps/web/src/lib/animation/property-registry.ts @@ -3,9 +3,8 @@ import type { AnimationInterpolation, AnimationPropertyPath, AnimationValue, - VectorValue, } from "@/lib/animation/types"; -import { isVectorValue, parseColorToLinearRgba } from "./binding-values"; +import { parseColorToLinearRgba } from "./binding-values"; import type { TimelineElement } from "@/lib/timeline"; import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; import { @@ -116,24 +115,38 @@ const ANIMATION_PROPERTY_REGISTRY: Record< AnimationPropertyPath, AnimationPropertyDefinition > = { - "transform.position": { - kind: "vector2", - defaultInterpolation: "linear", + "transform.positionX": createNumberPropertyDefinition({ + numericRange: { step: 1 }, supportsElement: ({ element }) => isVisualElement(element), getValue: ({ element }) => - isVisualElement(element) ? element.transform.position : null, - coerceValue: ({ value }) => (isVectorValue(value) ? value : null), + isVisualElement(element) ? element.transform.position.x : null, setValue: ({ element, value }) => isVisualElement(element) ? { ...element, transform: { ...element.transform, - position: value as VectorValue, + 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), diff --git a/apps/web/src/lib/animation/resolve.ts b/apps/web/src/lib/animation/resolve.ts index 67f4a487..42c1c73c 100644 --- a/apps/web/src/lib/animation/resolve.ts +++ b/apps/web/src/lib/animation/resolve.ts @@ -48,12 +48,20 @@ export function resolveTransformAtTime({ }): Transform { const safeLocalTime = Math.max(0, localTime); return { - position: resolveAnimationPathValueAtTime({ - animations, - propertyPath: "transform.position", - localTime: safeLocalTime, - fallbackValue: baseTransform.position, - }), + 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", diff --git a/apps/web/src/lib/animation/types.ts b/apps/web/src/lib/animation/types.ts index 453723b0..115b4dbf 100644 --- a/apps/web/src/lib/animation/types.ts +++ b/apps/web/src/lib/animation/types.ts @@ -1,7 +1,8 @@ import type { ParamValues } from "@/lib/params"; export const ANIMATION_PROPERTY_PATHS = [ - "transform.position", + "transform.positionX", + "transform.positionY", "transform.scaleX", "transform.scaleY", "transform.rotate", @@ -34,7 +35,8 @@ export type VectorValue = { x: number; y: number }; export type DiscreteValue = boolean | string; export type AnimationValue = number | string | boolean | VectorValue; export interface AnimationPropertyValueMap { - "transform.position": VectorValue; + "transform.positionX": number; + "transform.positionY": number; "transform.scaleX": number; "transform.scaleY": number; "transform.rotate": number; diff --git a/apps/web/src/services/storage/migrations/index.ts b/apps/web/src/services/storage/migrations/index.ts index b7ee37f5..bb2d9709 100644 --- a/apps/web/src/services/storage/migrations/index.ts +++ b/apps/web/src/services/storage/migrations/index.ts @@ -1,56 +1,58 @@ -export { StorageMigration } from "./base"; -import { V0toV1Migration } from "./v0-to-v1"; -import { V1toV2Migration } from "./v1-to-v2"; -import { V2toV3Migration } from "./v2-to-v3"; -import { V3toV4Migration } from "./v3-to-v4"; -import { V4toV5Migration } from "./v4-to-v5"; -import { V5toV6Migration } from "./v5-to-v6"; -import { V6toV7Migration } from "./v6-to-v7"; -import { V7toV8Migration } from "./v7-to-v8"; -import { V8toV9Migration } from "./v8-to-v9"; -import { V9toV10Migration } from "./v9-to-v10"; -import { V10toV11Migration } from "./v10-to-v11"; -import { V11toV12Migration } from "./v11-to-v12"; -import { V12toV13Migration } from "./v12-to-v13"; -import { V13toV14Migration } from "./v13-to-v14"; -import { V14toV15Migration } from "./v14-to-v15"; -import { V15toV16Migration } from "./v15-to-v16"; -import { V16toV17Migration } from "./v16-to-v17"; -import { V17toV18Migration } from "./v17-to-v18"; -import { V18toV19Migration } from "./v18-to-v19"; -import { V19toV20Migration } from "./v19-to-v20"; -import { V20toV21Migration } from "./v20-to-v21"; -import { V21toV22Migration } from "./v21-to-v22"; -import { V22toV23Migration } from "./v22-to-v23"; -import { V23toV24Migration } from "./v23-to-v24"; -export { runStorageMigrations } from "./runner"; -export type { MigrationProgress } from "./runner"; - -export const CURRENT_PROJECT_VERSION = 24; - -export const migrations = [ - new V0toV1Migration(), - new V1toV2Migration(), - new V2toV3Migration(), - new V3toV4Migration(), - new V4toV5Migration(), - new V5toV6Migration(), - new V6toV7Migration(), - new V7toV8Migration(), - new V8toV9Migration(), - new V9toV10Migration(), - new V10toV11Migration(), - new V11toV12Migration(), - new V12toV13Migration(), - new V13toV14Migration(), - new V14toV15Migration(), - new V15toV16Migration(), - new V16toV17Migration(), - new V17toV18Migration(), - new V18toV19Migration(), - new V19toV20Migration(), - new V20toV21Migration(), - new V21toV22Migration(), - new V22toV23Migration(), - new V23toV24Migration(), -]; +export { StorageMigration } from "./base"; +import { V0toV1Migration } from "./v0-to-v1"; +import { V1toV2Migration } from "./v1-to-v2"; +import { V2toV3Migration } from "./v2-to-v3"; +import { V3toV4Migration } from "./v3-to-v4"; +import { V4toV5Migration } from "./v4-to-v5"; +import { V5toV6Migration } from "./v5-to-v6"; +import { V6toV7Migration } from "./v6-to-v7"; +import { V7toV8Migration } from "./v7-to-v8"; +import { V8toV9Migration } from "./v8-to-v9"; +import { V9toV10Migration } from "./v9-to-v10"; +import { V10toV11Migration } from "./v10-to-v11"; +import { V11toV12Migration } from "./v11-to-v12"; +import { V12toV13Migration } from "./v12-to-v13"; +import { V13toV14Migration } from "./v13-to-v14"; +import { V14toV15Migration } from "./v14-to-v15"; +import { V15toV16Migration } from "./v15-to-v16"; +import { V16toV17Migration } from "./v16-to-v17"; +import { V17toV18Migration } from "./v17-to-v18"; +import { V18toV19Migration } from "./v18-to-v19"; +import { V19toV20Migration } from "./v19-to-v20"; +import { V20toV21Migration } from "./v20-to-v21"; +import { V21toV22Migration } from "./v21-to-v22"; +import { V22toV23Migration } from "./v22-to-v23"; +import { V23toV24Migration } from "./v23-to-v24"; +import { V24toV25Migration } from "./v24-to-v25"; +export { runStorageMigrations } from "./runner"; +export type { MigrationProgress } from "./runner"; + +export const CURRENT_PROJECT_VERSION = 25; + +export const migrations = [ + new V0toV1Migration(), + new V1toV2Migration(), + new V2toV3Migration(), + new V3toV4Migration(), + new V4toV5Migration(), + new V5toV6Migration(), + new V6toV7Migration(), + new V7toV8Migration(), + new V8toV9Migration(), + new V9toV10Migration(), + new V10toV11Migration(), + new V11toV12Migration(), + new V12toV13Migration(), + new V13toV14Migration(), + new V14toV15Migration(), + new V15toV16Migration(), + new V16toV17Migration(), + new V17toV18Migration(), + new V18toV19Migration(), + new V19toV20Migration(), + new V20toV21Migration(), + new V21toV22Migration(), + new V22toV23Migration(), + new V23toV24Migration(), + new V24toV25Migration(), +]; diff --git a/apps/web/src/services/storage/migrations/transformers/v24-to-v25.ts b/apps/web/src/services/storage/migrations/transformers/v24-to-v25.ts new file mode 100644 index 00000000..b3b48f42 --- /dev/null +++ b/apps/web/src/services/storage/migrations/transformers/v24-to-v25.ts @@ -0,0 +1,140 @@ +import type { MigrationResult, ProjectRecord } from "./types"; +import { getProjectId, isRecord } from "./utils"; + +export function transformProjectV24ToV25({ + project, +}: { + project: ProjectRecord; +}): MigrationResult { + if (!getProjectId({ project })) { + return { project, skipped: true, reason: "no project id" }; + } + + const version = project.version; + if (typeof version !== "number") { + return { project, skipped: true, reason: "invalid version" }; + } + if (version >= 25) { + return { project, skipped: true, reason: "already v25" }; + } + if (version !== 24) { + return { project, skipped: true, reason: "not v24" }; + } + + return { + project: { + ...migrateProject({ project }), + version: 25, + }, + skipped: false, + }; +} + +function migrateProject({ + project, +}: { + project: ProjectRecord; +}): ProjectRecord { + if (!Array.isArray(project.scenes)) { + return project; + } + + return { + ...project, + scenes: project.scenes.map((scene) => migrateScene({ scene })), + }; +} + +function migrateScene({ scene }: { scene: unknown }): unknown { + if (!isRecord(scene) || !isRecord(scene.tracks)) { + return scene; + } + + const tracks = scene.tracks; + const nextTracks: ProjectRecord = { ...tracks }; + + if (isRecord(tracks.main)) { + nextTracks.main = migrateTrack({ track: tracks.main }); + } + + if (Array.isArray(tracks.overlay)) { + nextTracks.overlay = tracks.overlay.map((track) => + migrateTrack({ track }), + ); + } + + if (Array.isArray(tracks.audio)) { + nextTracks.audio = tracks.audio.map((track) => migrateTrack({ track })); + } + + return { ...scene, tracks: nextTracks }; +} + +function migrateTrack({ track }: { track: unknown }): unknown { + if (!isRecord(track) || !Array.isArray(track.elements)) { + return track; + } + + return { + ...track, + elements: track.elements.map((element) => migrateElement({ element })), + }; +} + +function migrateElement({ element }: { element: unknown }): unknown { + if (!isRecord(element) || !isRecord(element.animations)) { + return element; + } + + const nextAnimations = migrateAnimations({ animations: element.animations }); + if (nextAnimations === element.animations) { + return element; + } + + return { ...element, animations: nextAnimations }; +} + +function migrateAnimations({ + animations, +}: { + animations: ProjectRecord; +}): ProjectRecord { + if (!isRecord(animations.bindings) || !isRecord(animations.channels)) { + return animations; + } + + const positionBinding = animations.bindings["transform.position"]; + if (!isRecord(positionBinding) || positionBinding.kind !== "vector2") { + return animations; + } + + const xChannel = animations.channels["transform.position:x"]; + const yChannel = animations.channels["transform.position:y"]; + + const nextBindings: ProjectRecord = { ...animations.bindings }; + const nextChannels: ProjectRecord = { ...animations.channels }; + + delete nextBindings["transform.position"]; + delete nextChannels["transform.position:x"]; + delete nextChannels["transform.position:y"]; + + nextBindings["transform.positionX"] = { + path: "transform.positionX", + kind: "number", + components: [{ key: "value", channelId: "transform.positionX:value" }], + }; + nextBindings["transform.positionY"] = { + path: "transform.positionY", + kind: "number", + components: [{ key: "value", channelId: "transform.positionY:value" }], + }; + + if (isRecord(xChannel)) { + nextChannels["transform.positionX:value"] = xChannel; + } + if (isRecord(yChannel)) { + nextChannels["transform.positionY:value"] = yChannel; + } + + return { ...animations, bindings: nextBindings, channels: nextChannels }; +} diff --git a/apps/web/src/services/storage/migrations/v24-to-v25.ts b/apps/web/src/services/storage/migrations/v24-to-v25.ts new file mode 100644 index 00000000..2e2a6935 --- /dev/null +++ b/apps/web/src/services/storage/migrations/v24-to-v25.ts @@ -0,0 +1,16 @@ +import { StorageMigration } from "./base"; +import type { ProjectRecord } from "./transformers/types"; +import { transformProjectV24ToV25 } from "./transformers/v24-to-v25"; + +export class V24toV25Migration extends StorageMigration { + from = 24; + to = 25; + + async transform(project: ProjectRecord): Promise<{ + project: ProjectRecord; + skipped: boolean; + reason?: string; + }> { + return transformProjectV24ToV25({ project }); + } +}