feat: position animated independently per axis

Made-with: Cursor
This commit is contained in:
Maze Winther
2026-04-13 05:01:51 +02:00
parent 8230faf082
commit 108d3f4eca
12 changed files with 775 additions and 720 deletions
@@ -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<TimelineElement>;
}) {
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,
};
}
@@ -26,7 +26,6 @@ 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 {
@@ -79,20 +78,41 @@ export function TransformTab({
localTime,
});
const position = useKeyframedVectorProperty({
const positionX = useKeyframedNumberProperty({
trackId,
elementId: element.id,
animations: element.animations,
propertyPath: "transform.position",
propertyPath: "transform.positionX",
localTime,
isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position,
displayX: Math.round(resolvedTransform.position.x).toString(),
displayY: Math.round(resolvedTransform.position.y).toString(),
parseComponent: (input) => parseNumericInput({ input }),
displayValue: Math.round(resolvedTransform.position.x).toString(),
parse: (input) => parseNumericInput({ input }),
valueAtPlayhead: resolvedTransform.position.x,
step: 1,
buildBaseUpdates: ({ value }) => ({
transform: { ...element.transform, position: 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 },
},
}),
});
@@ -327,64 +347,76 @@ export function TransformTab({
</>
)}
</div>
<div className="flex items-end gap-2">
<SectionField
label="Position"
label="X"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={position.isKeyframedAtTime}
isActive={positionX.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle position keyframe"
onToggle={position.toggleKeyframe}
title="Toggle X position keyframe"
onToggle={positionX.toggleKeyframe}
/>
}
>
<div className="flex items-center gap-2">
<NumberField
icon="X"
className="flex-1"
value={position.x.displayValue}
onFocus={position.x.onFocus}
onChange={position.x.onChange}
onBlur={position.x.onBlur}
onScrub={position.x.scrubTo}
onScrubEnd={position.x.commitScrub}
onReset={() =>
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,
})}
/>
<NumberField
icon="Y"
className="flex-1"
value={position.y.displayValue}
onFocus={position.y.onFocus}
onChange={position.y.onChange}
onBlur={position.y.onBlur}
onScrub={position.y.scrubTo}
onScrubEnd={position.y.commitScrub}
onReset={() =>
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,
})}
/>
</div>
<NumberField
icon="X"
value={positionX.displayValue}
onFocus={positionX.onFocus}
onChange={positionX.onChange}
onBlur={positionX.onBlur}
onScrub={positionX.scrubTo}
onScrubEnd={positionX.commitScrub}
onReset={() =>
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,
})}
/>
</SectionField>
<SectionField
label="Y"
className="min-w-0 flex-1"
beforeLabel={
<KeyframeToggle
isActive={positionY.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange}
title="Toggle Y position keyframe"
onToggle={positionY.toggleKeyframe}
/>
}
>
<NumberField
icon="Y"
value={positionY.displayValue}
onFocus={positionY.onFocus}
onChange={positionY.onChange}
onBlur={positionY.onBlur}
onScrub={positionY.scrubTo}
onScrubEnd={positionY.commitScrub}
onReset={() =>
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,
})}
/>
</SectionField>
</div>
<SectionField
label="Rotation"
@@ -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,
}: {
+14 -10
View File
@@ -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({
+3 -1
View File
@@ -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";
+30 -16
View File
@@ -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,
});
}
@@ -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),
+14 -6
View File
@@ -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",
+4 -2
View File
@@ -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;
@@ -23,10 +23,11 @@ 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 = 24;
export const CURRENT_PROJECT_VERSION = 25;
export const migrations = [
new V0toV1Migration(),
@@ -53,4 +54,5 @@ export const migrations = [
new V21toV22Migration(),
new V22toV23Migration(),
new V23toV24Migration(),
new V24toV25Migration(),
];
@@ -0,0 +1,140 @@
import type { MigrationResult, ProjectRecord } from "./types";
import { getProjectId, isRecord } from "./utils";
export function transformProjectV24ToV25({
project,
}: {
project: ProjectRecord;
}): MigrationResult<ProjectRecord> {
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 };
}
@@ -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 });
}
}