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 { useElementPlayhead } from "../hooks/use-element-playhead";
import { KeyframeToggle } from "../components/keyframe-toggle"; import { KeyframeToggle } from "../components/keyframe-toggle";
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property"; import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
import { usePropertiesStore } from "../stores/properties-store"; import { usePropertiesStore } from "../stores/properties-store";
export function parseNumericInput({ input }: { input: string }): number | null { export function parseNumericInput({ input }: { input: string }): number | null {
@@ -79,20 +78,41 @@ export function TransformTab({
localTime, localTime,
}); });
const position = useKeyframedVectorProperty({ const positionX = useKeyframedNumberProperty({
trackId, trackId,
elementId: element.id, elementId: element.id,
animations: element.animations, animations: element.animations,
propertyPath: "transform.position", propertyPath: "transform.positionX",
localTime, localTime,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position, displayValue: Math.round(resolvedTransform.position.x).toString(),
displayX: Math.round(resolvedTransform.position.x).toString(), parse: (input) => parseNumericInput({ input }),
displayY: Math.round(resolvedTransform.position.y).toString(), valueAtPlayhead: resolvedTransform.position.x,
parseComponent: (input) => parseNumericInput({ input }),
step: 1, step: 1,
buildBaseUpdates: ({ value }) => ({ 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>
<div className="flex items-end gap-2">
<SectionField <SectionField
label="Position" label="X"
className="min-w-0 flex-1"
beforeLabel={ beforeLabel={
<KeyframeToggle <KeyframeToggle
isActive={position.isKeyframedAtTime} isActive={positionX.isKeyframedAtTime}
isDisabled={!isPlayheadWithinElementRange} isDisabled={!isPlayheadWithinElementRange}
title="Toggle position keyframe" title="Toggle X position keyframe"
onToggle={position.toggleKeyframe} onToggle={positionX.toggleKeyframe}
/> />
} }
> >
<div className="flex items-center gap-2">
<NumberField <NumberField
icon="X" icon="X"
className="flex-1" value={positionX.displayValue}
value={position.x.displayValue} onFocus={positionX.onFocus}
onFocus={position.x.onFocus} onChange={positionX.onChange}
onChange={position.x.onChange} onBlur={positionX.onBlur}
onBlur={position.x.onBlur} onScrub={positionX.scrubTo}
onScrub={position.x.scrubTo} onScrubEnd={positionX.commitScrub}
onScrubEnd={position.x.commitScrub}
onReset={() => onReset={() =>
position.commitX({ positionX.commitValue({
value: DEFAULTS.element.transform.position.x, value: DEFAULTS.element.transform.position.x,
}) })
} }
isDefault={isPropertyAtDefault({ isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: position.hasAnimatedKeyframes, hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.x, resolvedValue: resolvedTransform.position.x,
staticValue: element.transform.position.x, staticValue: element.transform.position.x,
defaultValue: DEFAULTS.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 <NumberField
icon="Y" icon="Y"
className="flex-1" value={positionY.displayValue}
value={position.y.displayValue} onFocus={positionY.onFocus}
onFocus={position.y.onFocus} onChange={positionY.onChange}
onChange={position.y.onChange} onBlur={positionY.onBlur}
onBlur={position.y.onBlur} onScrub={positionY.scrubTo}
onScrub={position.y.scrubTo} onScrubEnd={positionY.commitScrub}
onScrubEnd={position.y.commitScrub}
onReset={() => onReset={() =>
position.commitY({ positionY.commitValue({
value: DEFAULTS.element.transform.position.y, value: DEFAULTS.element.transform.position.y,
}) })
} }
isDefault={isPropertyAtDefault({ isDefault={isPropertyAtDefault({
hasAnimatedKeyframes: position.hasAnimatedKeyframes, hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
isPlayheadWithinElementRange, isPlayheadWithinElementRange,
resolvedValue: resolvedTransform.position.y, resolvedValue: resolvedTransform.position.y,
staticValue: element.transform.position.y, staticValue: element.transform.position.y,
defaultValue: DEFAULTS.element.transform.position.y, defaultValue: DEFAULTS.element.transform.position.y,
})} })}
/> />
</div>
</SectionField> </SectionField>
</div>
<SectionField <SectionField
label="Rotation" 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"; 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({ export function getBindingComponentKeys({
kind, kind,
}: { }: {
+14 -10
View File
@@ -1,4 +1,5 @@
import type { import type {
AnimationBindingInstance,
AnimationPath, AnimationPath,
ElementAnimations, ElementAnimations,
ScalarAnimationChannel, ScalarAnimationChannel,
@@ -6,6 +7,11 @@ import type {
ScalarGraphKeyframeContext, ScalarGraphKeyframeContext,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
export interface EditableScalarChannels {
binding: AnimationBindingInstance;
channels: ScalarGraphChannel[];
}
function isScalarAnimationChannel( function isScalarAnimationChannel(
channel: ElementAnimations["channels"][string], channel: ElementAnimations["channels"][string],
): channel is ScalarAnimationChannel { ): channel is ScalarAnimationChannel {
@@ -18,13 +24,13 @@ export function getEditableScalarChannels({
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPath; propertyPath: AnimationPath;
}): ScalarGraphChannel[] { }): EditableScalarChannels | null {
const binding = animations?.bindings[propertyPath]; const binding = animations?.bindings[propertyPath];
if (!binding) { if (!binding) {
return []; return null;
} }
return binding.components.flatMap((component) => { const channels = binding.components.flatMap((component) => {
const channel = animations?.channels[component.channelId]; const channel = animations?.channels[component.channelId];
if (!isScalarAnimationChannel(channel)) { if (!isScalarAnimationChannel(channel)) {
return []; return [];
@@ -36,9 +42,11 @@ export function getEditableScalarChannels({
componentKey: component.key, componentKey: component.key,
channelId: component.channelId, channelId: component.channelId,
channel, channel,
}, } satisfies ScalarGraphChannel,
]; ];
}); });
return { binding, channels };
} }
export function getEditableScalarChannel({ export function getEditableScalarChannel({
@@ -50,12 +58,8 @@ export function getEditableScalarChannel({
propertyPath: AnimationPath; propertyPath: AnimationPath;
componentKey: string; componentKey: string;
}): ScalarGraphChannel | null { }): ScalarGraphChannel | null {
return ( const result = getEditableScalarChannels({ animations, propertyPath });
getEditableScalarChannels({ return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null;
animations,
propertyPath,
}).find((channel) => channel.componentKey === componentKey) ?? null
);
} }
export function getScalarKeyframeContext({ export function getScalarKeyframeContext({
+3 -1
View File
@@ -49,6 +49,7 @@ export {
} from "./keyframe-query"; } from "./keyframe-query";
export { export {
type EditableScalarChannels,
getEditableScalarChannel, getEditableScalarChannel,
getEditableScalarChannels, getEditableScalarChannels,
getScalarKeyframeContext, getScalarKeyframeContext,
@@ -90,5 +91,6 @@ export {
} from "./property-groups"; } from "./property-groups";
export { export {
isVectorValue, type EasingMode,
getEasingModeForKind,
} from "./binding-values"; } from "./binding-values";
+26 -12
View File
@@ -173,7 +173,7 @@ function createScalarKey({
id: string; id: string;
time: number; time: number;
value: number; value: number;
interpolation: AnimationInterpolation; interpolation?: AnimationInterpolation;
previousKey?: ScalarAnimationKey; previousKey?: ScalarAnimationKey;
}): ScalarAnimationKey { }): ScalarAnimationKey {
return { return {
@@ -183,7 +183,8 @@ function createScalarKey({
leftHandle: previousKey?.leftHandle, leftHandle: previousKey?.leftHandle,
rightHandle: previousKey?.rightHandle, rightHandle: previousKey?.rightHandle,
segmentToNext: segmentToNext:
previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }), previousKey?.segmentToNext ??
getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
tangentMode: previousKey?.tangentMode ?? "flat", tangentMode: previousKey?.tangentMode ?? "flat",
}; };
} }
@@ -332,12 +333,14 @@ function upsertScalarChannelKey({
time, time,
value, value,
interpolation, interpolation,
defaultInterpolation,
keyframeId, keyframeId,
}: { }: {
channel: ScalarAnimationChannel | undefined; channel: ScalarAnimationChannel | undefined;
time: number; time: number;
value: number; value: number;
interpolation: AnimationInterpolation; interpolation?: AnimationInterpolation;
defaultInterpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
}): ScalarAnimationChannel { }): ScalarAnimationChannel {
const normalizedChannel = normalizeChannel({ const normalizedChannel = normalizeChannel({
@@ -352,10 +355,13 @@ function upsertScalarChannelKey({
time, time,
value, value,
interpolation, interpolation,
previousKey: { previousKey:
interpolation != null
? {
...keys[existingIndex], ...keys[existingIndex],
segmentToNext: getScalarSegmentType({ interpolation }), segmentToNext: getScalarSegmentType({ interpolation }),
}, }
: keys[existingIndex],
}); });
return normalizeChannel({ return normalizeChannel({
channel: { channel: {
@@ -376,10 +382,13 @@ function upsertScalarChannelKey({
time: keys[existingAtTimeIndex].time, time: keys[existingAtTimeIndex].time,
value, value,
interpolation, interpolation,
previousKey: { previousKey:
interpolation != null
? {
...keys[existingAtTimeIndex], ...keys[existingAtTimeIndex],
segmentToNext: getScalarSegmentType({ interpolation }), segmentToNext: getScalarSegmentType({ interpolation }),
}, }
: keys[existingAtTimeIndex],
}); });
return normalizeChannel({ return normalizeChannel({
channel: { channel: {
@@ -395,7 +404,7 @@ function upsertScalarChannelKey({
id: keyframeId ?? generateUUID(), id: keyframeId ?? generateUUID(),
time, time,
value, value,
interpolation, interpolation: interpolation ?? defaultInterpolation,
}), }),
); );
return normalizeChannel({ return normalizeChannel({
@@ -472,9 +481,13 @@ export function upsertPathKeyframe({
return animations; return animations;
} }
const nextInterpolation = getInterpolationForBinding({ const explicitInterpolation =
interpolation != null
? getInterpolationForBinding({ kind, interpolation })
: undefined;
const validatedDefaultInterpolation = getInterpolationForBinding({
kind, kind,
interpolation: interpolation ?? defaultInterpolation, interpolation: defaultInterpolation,
}); });
nextAnimations.bindings[propertyPath] = binding; nextAnimations.bindings[propertyPath] = binding;
for (const component of binding.components) { for (const component of binding.components) {
@@ -503,7 +516,8 @@ export function upsertPathKeyframe({
channel: targetChannel, channel: targetChannel,
time: targetKey.time, time: targetKey.time,
value: nextValue as number, value: nextValue as number,
interpolation: nextInterpolation, interpolation: explicitInterpolation,
defaultInterpolation: validatedDefaultInterpolation,
keyframeId: targetKey.id, keyframeId: targetKey.id,
}); });
} }
@@ -592,7 +606,7 @@ export function upsertKeyframe({
channel, channel,
time, time,
value, value,
interpolation: interpolation ?? "linear", interpolation,
keyframeId, keyframeId,
}); });
} }
@@ -3,9 +3,8 @@ import type {
AnimationInterpolation, AnimationInterpolation,
AnimationPropertyPath, AnimationPropertyPath,
AnimationValue, AnimationValue,
VectorValue,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
import { isVectorValue, parseColorToLinearRgba } from "./binding-values"; import { parseColorToLinearRgba } from "./binding-values";
import type { TimelineElement } from "@/lib/timeline"; import type { TimelineElement } from "@/lib/timeline";
import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants"; import { MIN_TRANSFORM_SCALE } from "@/constants/animation-constants";
import { import {
@@ -116,24 +115,38 @@ const ANIMATION_PROPERTY_REGISTRY: Record<
AnimationPropertyPath, AnimationPropertyPath,
AnimationPropertyDefinition AnimationPropertyDefinition
> = { > = {
"transform.position": { "transform.positionX": createNumberPropertyDefinition({
kind: "vector2", numericRange: { step: 1 },
defaultInterpolation: "linear",
supportsElement: ({ element }) => isVisualElement(element), supportsElement: ({ element }) => isVisualElement(element),
getValue: ({ element }) => getValue: ({ element }) =>
isVisualElement(element) ? element.transform.position : null, isVisualElement(element) ? element.transform.position.x : null,
coerceValue: ({ value }) => (isVectorValue(value) ? value : null),
setValue: ({ element, value }) => setValue: ({ element, value }) =>
isVisualElement(element) isVisualElement(element)
? { ? {
...element, ...element,
transform: { transform: {
...element.transform, ...element.transform,
position: value as VectorValue, position: { ...element.transform.position, x: value as number },
}, },
} }
: element, : 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({ "transform.scaleX": createNumberPropertyDefinition({
numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 }, numericRange: { min: MIN_TRANSFORM_SCALE, step: 0.01 },
supportsElement: ({ element }) => isVisualElement(element), supportsElement: ({ element }) => isVisualElement(element),
+11 -3
View File
@@ -48,12 +48,20 @@ export function resolveTransformAtTime({
}): Transform { }): Transform {
const safeLocalTime = Math.max(0, localTime); const safeLocalTime = Math.max(0, localTime);
return { return {
position: resolveAnimationPathValueAtTime({ position: {
x: resolveAnimationPathValueAtTime({
animations, animations,
propertyPath: "transform.position", propertyPath: "transform.positionX",
localTime: safeLocalTime, localTime: safeLocalTime,
fallbackValue: baseTransform.position, fallbackValue: baseTransform.position.x,
}), }),
y: resolveAnimationPathValueAtTime({
animations,
propertyPath: "transform.positionY",
localTime: safeLocalTime,
fallbackValue: baseTransform.position.y,
}),
},
scaleX: resolveAnimationPathValueAtTime({ scaleX: resolveAnimationPathValueAtTime({
animations, animations,
propertyPath: "transform.scaleX", propertyPath: "transform.scaleX",
+4 -2
View File
@@ -1,7 +1,8 @@
import type { ParamValues } from "@/lib/params"; import type { ParamValues } from "@/lib/params";
export const ANIMATION_PROPERTY_PATHS = [ export const ANIMATION_PROPERTY_PATHS = [
"transform.position", "transform.positionX",
"transform.positionY",
"transform.scaleX", "transform.scaleX",
"transform.scaleY", "transform.scaleY",
"transform.rotate", "transform.rotate",
@@ -34,7 +35,8 @@ export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string; export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue; export type AnimationValue = number | string | boolean | VectorValue;
export interface AnimationPropertyValueMap { export interface AnimationPropertyValueMap {
"transform.position": VectorValue; "transform.positionX": number;
"transform.positionY": number;
"transform.scaleX": number; "transform.scaleX": number;
"transform.scaleY": number; "transform.scaleY": number;
"transform.rotate": number; "transform.rotate": number;
@@ -23,10 +23,11 @@ import { V20toV21Migration } from "./v20-to-v21";
import { V21toV22Migration } from "./v21-to-v22"; import { V21toV22Migration } from "./v21-to-v22";
import { V22toV23Migration } from "./v22-to-v23"; import { V22toV23Migration } from "./v22-to-v23";
import { V23toV24Migration } from "./v23-to-v24"; import { V23toV24Migration } from "./v23-to-v24";
import { V24toV25Migration } from "./v24-to-v25";
export { runStorageMigrations } from "./runner"; export { runStorageMigrations } from "./runner";
export type { MigrationProgress } from "./runner"; export type { MigrationProgress } from "./runner";
export const CURRENT_PROJECT_VERSION = 24; export const CURRENT_PROJECT_VERSION = 25;
export const migrations = [ export const migrations = [
new V0toV1Migration(), new V0toV1Migration(),
@@ -53,4 +54,5 @@ export const migrations = [
new V21toV22Migration(), new V21toV22Migration(),
new V22toV23Migration(), new V22toV23Migration(),
new V23toV24Migration(), 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 });
}
}