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 { 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,
|
||||
}: {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user