feat: position animated independently per axis
Made-with: Cursor
This commit is contained in:
parent
8230faf082
commit
108d3f4eca
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -1,430 +1,462 @@
|
|||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||
import type { VisualElement } from "@/lib/timeline";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowExpandIcon,
|
||||
Link05Icon,
|
||||
RotateClockwiseIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { useKeyframedVectorProperty } from "../hooks/use-keyframed-vector-property";
|
||||
import { usePropertiesStore } from "../stores/properties-store";
|
||||
|
||||
export function parseNumericInput({ input }: { input: string }): number | null {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
export function isPropertyAtDefault({
|
||||
hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
staticValue,
|
||||
defaultValue,
|
||||
}: {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number;
|
||||
staticValue: number;
|
||||
defaultValue: number;
|
||||
}): boolean {
|
||||
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
|
||||
return isNearlyEqual({
|
||||
leftValue: resolvedValue,
|
||||
rightValue: defaultValue,
|
||||
});
|
||||
}
|
||||
|
||||
return staticValue === defaultValue;
|
||||
}
|
||||
|
||||
export function TransformTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
|
||||
const setTransformScaleLocked = usePropertiesStore(
|
||||
(s) => s.setTransformScaleLocked,
|
||||
);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const position = useKeyframedVectorProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.position",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.position,
|
||||
displayX: Math.round(resolvedTransform.position.x).toString(),
|
||||
displayY: Math.round(resolvedTransform.position.y).toString(),
|
||||
parseComponent: (input) => parseNumericInput({ input }),
|
||||
step: 1,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: { ...element.transform, position: value },
|
||||
}),
|
||||
});
|
||||
|
||||
const parseScale = (input: string) => {
|
||||
const parsed = parseNumericInput({ input });
|
||||
if (parsed === null) return null;
|
||||
return parsed / 100;
|
||||
};
|
||||
|
||||
const scaleX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
|
||||
parse: parseScale,
|
||||
valueAtPlayhead: resolvedTransform.scaleX,
|
||||
step: 0.01,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
scaleX: value,
|
||||
...(isScaleLocked ? { scaleY: value } : {}),
|
||||
},
|
||||
}),
|
||||
buildAdditionalKeyframes: isScaleLocked
|
||||
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const scaleY = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
|
||||
parse: parseScale,
|
||||
valueAtPlayhead: resolvedTransform.scaleY,
|
||||
step: 0.01,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
scaleY: value,
|
||||
...(isScaleLocked ? { scaleX: value } : {}),
|
||||
},
|
||||
}),
|
||||
buildAdditionalKeyframes: isScaleLocked
|
||||
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const scaleFieldPropsX = {
|
||||
value: scaleX.displayValue,
|
||||
onFocus: scaleX.onFocus,
|
||||
onChange: scaleX.onChange,
|
||||
onBlur: scaleX.onBlur,
|
||||
dragSensitivity: "slow" as const,
|
||||
onScrub: scaleX.scrubTo,
|
||||
onScrubEnd: scaleX.commitScrub,
|
||||
onReset: () =>
|
||||
scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
|
||||
isDefault: isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.scaleX,
|
||||
staticValue: element.transform.scaleX,
|
||||
defaultValue: DEFAULTS.element.transform.scaleX,
|
||||
}),
|
||||
};
|
||||
|
||||
const scaleFieldPropsY = {
|
||||
value: scaleY.displayValue,
|
||||
onFocus: scaleY.onFocus,
|
||||
onChange: scaleY.onChange,
|
||||
onBlur: scaleY.onBlur,
|
||||
dragSensitivity: "slow" as const,
|
||||
onScrub: scaleY.scrubTo,
|
||||
onScrubEnd: scaleY.commitScrub,
|
||||
onReset: () =>
|
||||
scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
|
||||
isDefault: isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.scaleY,
|
||||
staticValue: element.transform.scaleY,
|
||||
defaultValue: DEFAULTS.element.transform.scaleY,
|
||||
}),
|
||||
};
|
||||
|
||||
const rotation = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.rotate).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseNumericInput({ input });
|
||||
if (parsed === null) return null;
|
||||
return clamp({ value: parsed, min: -360, max: 360 });
|
||||
},
|
||||
valueAtPlayhead: resolvedTransform.rotate,
|
||||
step: 1,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
rotate: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const hasScaleKeyframe = hasGroupKeyframeAtTime({
|
||||
animations: element.animations,
|
||||
group: "transform.scale",
|
||||
time: localTime,
|
||||
});
|
||||
|
||||
const toggleScaleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) return;
|
||||
const existing = getGroupKeyframesAtTime({
|
||||
animations: element.animations,
|
||||
group: "transform.scale",
|
||||
time: localTime,
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: existing.map((ref) => ({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
...ref,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.scaleX",
|
||||
time: localTime,
|
||||
value: resolvedTransform.scaleX,
|
||||
},
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.scaleY",
|
||||
time: localTime,
|
||||
value: resolvedTransform.scaleY,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const scaleLockButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant={isScaleLocked ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-pressed={isScaleLocked}
|
||||
onClick={() => setTransformScaleLocked(!isScaleLocked)}
|
||||
>
|
||||
<HugeiconsIcon icon={Link05Icon} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.id}:transform`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Transform</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<div className="flex items-end gap-2">
|
||||
{isScaleLocked ? (
|
||||
<>
|
||||
<SectionField
|
||||
label="Scale"
|
||||
className="min-w-0 flex-1"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={hasScaleKeyframe}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle scale keyframe"
|
||||
onToggle={toggleScaleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||
{...scaleFieldPropsX}
|
||||
/>
|
||||
</SectionField>
|
||||
{scaleLockButton}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SectionField
|
||||
label="Width"
|
||||
className="min-w-0 flex-1"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={scaleX.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle width scale keyframe"
|
||||
onToggle={scaleX.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField icon="W" {...scaleFieldPropsX} />
|
||||
</SectionField>
|
||||
{scaleLockButton}
|
||||
<SectionField
|
||||
label="Height"
|
||||
className="min-w-0 flex-1"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={scaleY.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle height scale keyframe"
|
||||
onToggle={scaleY.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField icon="H" {...scaleFieldPropsY} />
|
||||
</SectionField>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<SectionField
|
||||
label="Position"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={position.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle position keyframe"
|
||||
onToggle={position.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>
|
||||
</SectionField>
|
||||
|
||||
<SectionField
|
||||
label="Rotation"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={rotation.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle rotation keyframe"
|
||||
onToggle={rotation.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
||||
className="flex-none"
|
||||
value={rotation.displayValue}
|
||||
onFocus={rotation.onFocus}
|
||||
onChange={rotation.onChange}
|
||||
onBlur={rotation.onBlur}
|
||||
dragSensitivity="slow"
|
||||
onScrub={rotation.scrubTo}
|
||||
onScrubEnd={rotation.commitScrub}
|
||||
onReset={() =>
|
||||
rotation.commitValue({
|
||||
value: DEFAULTS.element.transform.rotate,
|
||||
})
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.rotate,
|
||||
staticValue: element.transform.rotate,
|
||||
defaultValue: DEFAULTS.element.transform.rotate,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
import { NumberField } from "@/components/ui/number-field";
|
||||
import { useEditor } from "@/hooks/use-editor";
|
||||
import { clamp, isNearlyEqual } from "@/utils/math";
|
||||
import type { VisualElement } from "@/lib/timeline";
|
||||
import {
|
||||
Section,
|
||||
SectionContent,
|
||||
SectionField,
|
||||
SectionFields,
|
||||
SectionHeader,
|
||||
SectionTitle,
|
||||
} from "@/components/section";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HugeiconsIcon } from "@hugeicons/react";
|
||||
import {
|
||||
ArrowExpandIcon,
|
||||
Link05Icon,
|
||||
RotateClockwiseIcon,
|
||||
} from "@hugeicons/core-free-icons";
|
||||
import {
|
||||
getGroupKeyframesAtTime,
|
||||
hasGroupKeyframeAtTime,
|
||||
resolveTransformAtTime,
|
||||
} from "@/lib/animation";
|
||||
import { DEFAULTS } from "@/lib/timeline/defaults";
|
||||
import { useElementPlayhead } from "../hooks/use-element-playhead";
|
||||
import { KeyframeToggle } from "../components/keyframe-toggle";
|
||||
import { useKeyframedNumberProperty } from "../hooks/use-keyframed-number-property";
|
||||
import { usePropertiesStore } from "../stores/properties-store";
|
||||
|
||||
export function parseNumericInput({ input }: { input: string }): number | null {
|
||||
const parsed = parseFloat(input);
|
||||
return Number.isNaN(parsed) ? null : parsed;
|
||||
}
|
||||
|
||||
export function isPropertyAtDefault({
|
||||
hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue,
|
||||
staticValue,
|
||||
defaultValue,
|
||||
}: {
|
||||
hasAnimatedKeyframes: boolean;
|
||||
isPlayheadWithinElementRange: boolean;
|
||||
resolvedValue: number;
|
||||
staticValue: number;
|
||||
defaultValue: number;
|
||||
}): boolean {
|
||||
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
|
||||
return isNearlyEqual({
|
||||
leftValue: resolvedValue,
|
||||
rightValue: defaultValue,
|
||||
});
|
||||
}
|
||||
|
||||
return staticValue === defaultValue;
|
||||
}
|
||||
|
||||
export function TransformTab({
|
||||
element,
|
||||
trackId,
|
||||
}: {
|
||||
element: VisualElement;
|
||||
trackId: string;
|
||||
}) {
|
||||
const editor = useEditor();
|
||||
const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
|
||||
const setTransformScaleLocked = usePropertiesStore(
|
||||
(s) => s.setTransformScaleLocked,
|
||||
);
|
||||
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
|
||||
startTime: element.startTime,
|
||||
duration: element.duration,
|
||||
});
|
||||
const resolvedTransform = resolveTransformAtTime({
|
||||
baseTransform: element.transform,
|
||||
animations: element.animations,
|
||||
localTime,
|
||||
});
|
||||
|
||||
const positionX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.positionX",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.position.x).toString(),
|
||||
parse: (input) => parseNumericInput({ input }),
|
||||
valueAtPlayhead: resolvedTransform.position.x,
|
||||
step: 1,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, x: value },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const positionY = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.positionY",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.position.y).toString(),
|
||||
parse: (input) => parseNumericInput({ input }),
|
||||
valueAtPlayhead: resolvedTransform.position.y,
|
||||
step: 1,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
position: { ...element.transform.position, y: value },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const parseScale = (input: string) => {
|
||||
const parsed = parseNumericInput({ input });
|
||||
if (parsed === null) return null;
|
||||
return parsed / 100;
|
||||
};
|
||||
|
||||
const scaleX = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleX",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
|
||||
parse: parseScale,
|
||||
valueAtPlayhead: resolvedTransform.scaleX,
|
||||
step: 0.01,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
scaleX: value,
|
||||
...(isScaleLocked ? { scaleY: value } : {}),
|
||||
},
|
||||
}),
|
||||
buildAdditionalKeyframes: isScaleLocked
|
||||
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const scaleY = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.scaleY",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
|
||||
parse: parseScale,
|
||||
valueAtPlayhead: resolvedTransform.scaleY,
|
||||
step: 0.01,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
scaleY: value,
|
||||
...(isScaleLocked ? { scaleX: value } : {}),
|
||||
},
|
||||
}),
|
||||
buildAdditionalKeyframes: isScaleLocked
|
||||
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
|
||||
: undefined,
|
||||
});
|
||||
|
||||
const scaleFieldPropsX = {
|
||||
value: scaleX.displayValue,
|
||||
onFocus: scaleX.onFocus,
|
||||
onChange: scaleX.onChange,
|
||||
onBlur: scaleX.onBlur,
|
||||
dragSensitivity: "slow" as const,
|
||||
onScrub: scaleX.scrubTo,
|
||||
onScrubEnd: scaleX.commitScrub,
|
||||
onReset: () =>
|
||||
scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
|
||||
isDefault: isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.scaleX,
|
||||
staticValue: element.transform.scaleX,
|
||||
defaultValue: DEFAULTS.element.transform.scaleX,
|
||||
}),
|
||||
};
|
||||
|
||||
const scaleFieldPropsY = {
|
||||
value: scaleY.displayValue,
|
||||
onFocus: scaleY.onFocus,
|
||||
onChange: scaleY.onChange,
|
||||
onBlur: scaleY.onBlur,
|
||||
dragSensitivity: "slow" as const,
|
||||
onScrub: scaleY.scrubTo,
|
||||
onScrubEnd: scaleY.commitScrub,
|
||||
onReset: () =>
|
||||
scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
|
||||
isDefault: isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.scaleY,
|
||||
staticValue: element.transform.scaleY,
|
||||
defaultValue: DEFAULTS.element.transform.scaleY,
|
||||
}),
|
||||
};
|
||||
|
||||
const rotation = useKeyframedNumberProperty({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
animations: element.animations,
|
||||
propertyPath: "transform.rotate",
|
||||
localTime,
|
||||
isPlayheadWithinElementRange,
|
||||
displayValue: Math.round(resolvedTransform.rotate).toString(),
|
||||
parse: (input) => {
|
||||
const parsed = parseNumericInput({ input });
|
||||
if (parsed === null) return null;
|
||||
return clamp({ value: parsed, min: -360, max: 360 });
|
||||
},
|
||||
valueAtPlayhead: resolvedTransform.rotate,
|
||||
step: 1,
|
||||
buildBaseUpdates: ({ value }) => ({
|
||||
transform: {
|
||||
...element.transform,
|
||||
rotate: value,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const hasScaleKeyframe = hasGroupKeyframeAtTime({
|
||||
animations: element.animations,
|
||||
group: "transform.scale",
|
||||
time: localTime,
|
||||
});
|
||||
|
||||
const toggleScaleKeyframe = () => {
|
||||
if (!isPlayheadWithinElementRange) return;
|
||||
const existing = getGroupKeyframesAtTime({
|
||||
animations: element.animations,
|
||||
group: "transform.scale",
|
||||
time: localTime,
|
||||
});
|
||||
if (existing.length > 0) {
|
||||
editor.timeline.removeKeyframes({
|
||||
keyframes: existing.map((ref) => ({
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
...ref,
|
||||
})),
|
||||
});
|
||||
return;
|
||||
}
|
||||
editor.timeline.upsertKeyframes({
|
||||
keyframes: [
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.scaleX",
|
||||
time: localTime,
|
||||
value: resolvedTransform.scaleX,
|
||||
},
|
||||
{
|
||||
trackId,
|
||||
elementId: element.id,
|
||||
propertyPath: "transform.scaleY",
|
||||
time: localTime,
|
||||
value: resolvedTransform.scaleY,
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const scaleLockButton = (
|
||||
<Button
|
||||
type="button"
|
||||
variant={isScaleLocked ? "secondary" : "ghost"}
|
||||
size="icon"
|
||||
aria-pressed={isScaleLocked}
|
||||
onClick={() => setTransformScaleLocked(!isScaleLocked)}
|
||||
>
|
||||
<HugeiconsIcon icon={Link05Icon} />
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Section collapsible sectionKey={`${element.id}:transform`}>
|
||||
<SectionHeader>
|
||||
<SectionTitle>Transform</SectionTitle>
|
||||
</SectionHeader>
|
||||
<SectionContent>
|
||||
<SectionFields>
|
||||
<div className="flex items-end gap-2">
|
||||
{isScaleLocked ? (
|
||||
<>
|
||||
<SectionField
|
||||
label="Scale"
|
||||
className="min-w-0 flex-1"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={hasScaleKeyframe}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle scale keyframe"
|
||||
onToggle={toggleScaleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
|
||||
{...scaleFieldPropsX}
|
||||
/>
|
||||
</SectionField>
|
||||
{scaleLockButton}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SectionField
|
||||
label="Width"
|
||||
className="min-w-0 flex-1"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={scaleX.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle width scale keyframe"
|
||||
onToggle={scaleX.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField icon="W" {...scaleFieldPropsX} />
|
||||
</SectionField>
|
||||
{scaleLockButton}
|
||||
<SectionField
|
||||
label="Height"
|
||||
className="min-w-0 flex-1"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={scaleY.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle height scale keyframe"
|
||||
onToggle={scaleY.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<NumberField icon="H" {...scaleFieldPropsY} />
|
||||
</SectionField>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-end gap-2">
|
||||
<SectionField
|
||||
label="X"
|
||||
className="min-w-0 flex-1"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={positionX.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle X position keyframe"
|
||||
onToggle={positionX.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<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"
|
||||
beforeLabel={
|
||||
<KeyframeToggle
|
||||
isActive={rotation.isKeyframedAtTime}
|
||||
isDisabled={!isPlayheadWithinElementRange}
|
||||
title="Toggle rotation keyframe"
|
||||
onToggle={rotation.toggleKeyframe}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<NumberField
|
||||
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />}
|
||||
className="flex-none"
|
||||
value={rotation.displayValue}
|
||||
onFocus={rotation.onFocus}
|
||||
onChange={rotation.onChange}
|
||||
onBlur={rotation.onBlur}
|
||||
dragSensitivity="slow"
|
||||
onScrub={rotation.scrubTo}
|
||||
onScrubEnd={rotation.commitScrub}
|
||||
onReset={() =>
|
||||
rotation.commitValue({
|
||||
value: DEFAULTS.element.transform.rotate,
|
||||
})
|
||||
}
|
||||
isDefault={isPropertyAtDefault({
|
||||
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes,
|
||||
isPlayheadWithinElementRange,
|
||||
resolvedValue: resolvedTransform.rotate,
|
||||
staticValue: element.transform.rotate,
|
||||
defaultValue: DEFAULTS.element.transform.rotate,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
</SectionField>
|
||||
</SectionFields>
|
||||
</SectionContent>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,56 +1,58 @@
|
|||
export { StorageMigration } from "./base";
|
||||
import { V0toV1Migration } from "./v0-to-v1";
|
||||
import { V1toV2Migration } from "./v1-to-v2";
|
||||
import { V2toV3Migration } from "./v2-to-v3";
|
||||
import { V3toV4Migration } from "./v3-to-v4";
|
||||
import { V4toV5Migration } from "./v4-to-v5";
|
||||
import { V5toV6Migration } from "./v5-to-v6";
|
||||
import { V6toV7Migration } from "./v6-to-v7";
|
||||
import { V7toV8Migration } from "./v7-to-v8";
|
||||
import { V8toV9Migration } from "./v8-to-v9";
|
||||
import { V9toV10Migration } from "./v9-to-v10";
|
||||
import { V10toV11Migration } from "./v10-to-v11";
|
||||
import { V11toV12Migration } from "./v11-to-v12";
|
||||
import { V12toV13Migration } from "./v12-to-v13";
|
||||
import { V13toV14Migration } from "./v13-to-v14";
|
||||
import { V14toV15Migration } from "./v14-to-v15";
|
||||
import { V15toV16Migration } from "./v15-to-v16";
|
||||
import { V16toV17Migration } from "./v16-to-v17";
|
||||
import { V17toV18Migration } from "./v17-to-v18";
|
||||
import { V18toV19Migration } from "./v18-to-v19";
|
||||
import { V19toV20Migration } from "./v19-to-v20";
|
||||
import { V20toV21Migration } from "./v20-to-v21";
|
||||
import { V21toV22Migration } from "./v21-to-v22";
|
||||
import { V22toV23Migration } from "./v22-to-v23";
|
||||
import { V23toV24Migration } from "./v23-to-v24";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 24;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
new V1toV2Migration(),
|
||||
new V2toV3Migration(),
|
||||
new V3toV4Migration(),
|
||||
new V4toV5Migration(),
|
||||
new V5toV6Migration(),
|
||||
new V6toV7Migration(),
|
||||
new V7toV8Migration(),
|
||||
new V8toV9Migration(),
|
||||
new V9toV10Migration(),
|
||||
new V10toV11Migration(),
|
||||
new V11toV12Migration(),
|
||||
new V12toV13Migration(),
|
||||
new V13toV14Migration(),
|
||||
new V14toV15Migration(),
|
||||
new V15toV16Migration(),
|
||||
new V16toV17Migration(),
|
||||
new V17toV18Migration(),
|
||||
new V18toV19Migration(),
|
||||
new V19toV20Migration(),
|
||||
new V20toV21Migration(),
|
||||
new V21toV22Migration(),
|
||||
new V22toV23Migration(),
|
||||
new V23toV24Migration(),
|
||||
];
|
||||
export { StorageMigration } from "./base";
|
||||
import { V0toV1Migration } from "./v0-to-v1";
|
||||
import { V1toV2Migration } from "./v1-to-v2";
|
||||
import { V2toV3Migration } from "./v2-to-v3";
|
||||
import { V3toV4Migration } from "./v3-to-v4";
|
||||
import { V4toV5Migration } from "./v4-to-v5";
|
||||
import { V5toV6Migration } from "./v5-to-v6";
|
||||
import { V6toV7Migration } from "./v6-to-v7";
|
||||
import { V7toV8Migration } from "./v7-to-v8";
|
||||
import { V8toV9Migration } from "./v8-to-v9";
|
||||
import { V9toV10Migration } from "./v9-to-v10";
|
||||
import { V10toV11Migration } from "./v10-to-v11";
|
||||
import { V11toV12Migration } from "./v11-to-v12";
|
||||
import { V12toV13Migration } from "./v12-to-v13";
|
||||
import { V13toV14Migration } from "./v13-to-v14";
|
||||
import { V14toV15Migration } from "./v14-to-v15";
|
||||
import { V15toV16Migration } from "./v15-to-v16";
|
||||
import { V16toV17Migration } from "./v16-to-v17";
|
||||
import { V17toV18Migration } from "./v17-to-v18";
|
||||
import { V18toV19Migration } from "./v18-to-v19";
|
||||
import { V19toV20Migration } from "./v19-to-v20";
|
||||
import { V20toV21Migration } from "./v20-to-v21";
|
||||
import { V21toV22Migration } from "./v21-to-v22";
|
||||
import { V22toV23Migration } from "./v22-to-v23";
|
||||
import { V23toV24Migration } from "./v23-to-v24";
|
||||
import { V24toV25Migration } from "./v24-to-v25";
|
||||
export { runStorageMigrations } from "./runner";
|
||||
export type { MigrationProgress } from "./runner";
|
||||
|
||||
export const CURRENT_PROJECT_VERSION = 25;
|
||||
|
||||
export const migrations = [
|
||||
new V0toV1Migration(),
|
||||
new V1toV2Migration(),
|
||||
new V2toV3Migration(),
|
||||
new V3toV4Migration(),
|
||||
new V4toV5Migration(),
|
||||
new V5toV6Migration(),
|
||||
new V6toV7Migration(),
|
||||
new V7toV8Migration(),
|
||||
new V8toV9Migration(),
|
||||
new V9toV10Migration(),
|
||||
new V10toV11Migration(),
|
||||
new V11toV12Migration(),
|
||||
new V12toV13Migration(),
|
||||
new V13toV14Migration(),
|
||||
new V14toV15Migration(),
|
||||
new V15toV16Migration(),
|
||||
new V16toV17Migration(),
|
||||
new V17toV18Migration(),
|
||||
new V18toV19Migration(),
|
||||
new V19toV20Migration(),
|
||||
new V20toV21Migration(),
|
||||
new V21toV22Migration(),
|
||||
new V22toV23Migration(),
|
||||
new V23toV24Migration(),
|
||||
new V24toV25Migration(),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue