mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: position animated independently per axis
Made-with: Cursor
This commit is contained in:
-190
@@ -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,
|
||||||
}: {
|
}: {
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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";
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user