feat: position animated independently per axis

Made-with: Cursor
This commit is contained in:
Maze Winther 2026-04-13 04:40:38 +02:00
parent 8230faf082
commit 108d3f4eca
12 changed files with 775 additions and 720 deletions

View File

@ -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,
};
}

View File

@ -1,430 +1,462 @@
import { NumberField } from "@/components/ui/number-field"; import { NumberField } from "@/components/ui/number-field";
import { useEditor } from "@/hooks/use-editor"; import { useEditor } from "@/hooks/use-editor";
import { clamp, isNearlyEqual } from "@/utils/math"; import { clamp, isNearlyEqual } from "@/utils/math";
import type { VisualElement } from "@/lib/timeline"; import type { VisualElement } from "@/lib/timeline";
import { import {
Section, Section,
SectionContent, SectionContent,
SectionField, SectionField,
SectionFields, SectionFields,
SectionHeader, SectionHeader,
SectionTitle, SectionTitle,
} from "@/components/section"; } from "@/components/section";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { HugeiconsIcon } from "@hugeicons/react"; import { HugeiconsIcon } from "@hugeicons/react";
import { import {
ArrowExpandIcon, ArrowExpandIcon,
Link05Icon, Link05Icon,
RotateClockwiseIcon, RotateClockwiseIcon,
} from "@hugeicons/core-free-icons"; } from "@hugeicons/core-free-icons";
import { import {
getGroupKeyframesAtTime, getGroupKeyframesAtTime,
hasGroupKeyframeAtTime, hasGroupKeyframeAtTime,
resolveTransformAtTime, resolveTransformAtTime,
} from "@/lib/animation"; } from "@/lib/animation";
import { DEFAULTS } from "@/lib/timeline/defaults"; 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 { const parsed = parseFloat(input);
const parsed = parseFloat(input); return Number.isNaN(parsed) ? null : parsed;
return Number.isNaN(parsed) ? null : parsed; }
}
export function isPropertyAtDefault({
export function isPropertyAtDefault({ hasAnimatedKeyframes,
hasAnimatedKeyframes, isPlayheadWithinElementRange,
isPlayheadWithinElementRange, resolvedValue,
resolvedValue, staticValue,
staticValue, defaultValue,
defaultValue, }: {
}: { hasAnimatedKeyframes: boolean;
hasAnimatedKeyframes: boolean; isPlayheadWithinElementRange: boolean;
isPlayheadWithinElementRange: boolean; resolvedValue: number;
resolvedValue: number; staticValue: number;
staticValue: number; defaultValue: number;
defaultValue: number; }): boolean {
}): boolean { if (hasAnimatedKeyframes && isPlayheadWithinElementRange) {
if (hasAnimatedKeyframes && isPlayheadWithinElementRange) { return isNearlyEqual({
return isNearlyEqual({ leftValue: resolvedValue,
leftValue: resolvedValue, rightValue: defaultValue,
rightValue: defaultValue, });
}); }
}
return staticValue === defaultValue;
return staticValue === defaultValue; }
}
export function TransformTab({
export function TransformTab({ element,
element, trackId,
trackId, }: {
}: { element: VisualElement;
element: VisualElement; trackId: string;
trackId: string; }) {
}) { const editor = useEditor();
const editor = useEditor(); const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked);
const isScaleLocked = usePropertiesStore((s) => s.isTransformScaleLocked); const setTransformScaleLocked = usePropertiesStore(
const setTransformScaleLocked = usePropertiesStore( (s) => s.setTransformScaleLocked,
(s) => s.setTransformScaleLocked, );
); const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({ startTime: element.startTime,
startTime: element.startTime, duration: element.duration,
duration: element.duration, });
}); const resolvedTransform = resolveTransformAtTime({
const resolvedTransform = resolveTransformAtTime({ baseTransform: element.transform,
baseTransform: element.transform, animations: element.animations,
animations: element.animations, localTime,
localTime, });
});
const positionX = useKeyframedNumberProperty({
const position = useKeyframedVectorProperty({ trackId,
trackId, elementId: element.id,
elementId: element.id, animations: element.animations,
animations: element.animations, propertyPath: "transform.positionX",
propertyPath: "transform.position", localTime,
localTime, isPlayheadWithinElementRange,
isPlayheadWithinElementRange, displayValue: Math.round(resolvedTransform.position.x).toString(),
resolvedValue: resolvedTransform.position, parse: (input) => parseNumericInput({ input }),
displayX: Math.round(resolvedTransform.position.x).toString(), valueAtPlayhead: resolvedTransform.position.x,
displayY: Math.round(resolvedTransform.position.y).toString(), step: 1,
parseComponent: (input) => parseNumericInput({ input }), buildBaseUpdates: ({ value }) => ({
step: 1, transform: {
buildBaseUpdates: ({ value }) => ({ ...element.transform,
transform: { ...element.transform, position: value }, position: { ...element.transform.position, x: value },
}), },
}); }),
});
const parseScale = (input: string) => {
const parsed = parseNumericInput({ input }); const positionY = useKeyframedNumberProperty({
if (parsed === null) return null; trackId,
return parsed / 100; elementId: element.id,
}; animations: element.animations,
propertyPath: "transform.positionY",
const scaleX = useKeyframedNumberProperty({ localTime,
trackId, isPlayheadWithinElementRange,
elementId: element.id, displayValue: Math.round(resolvedTransform.position.y).toString(),
animations: element.animations, parse: (input) => parseNumericInput({ input }),
propertyPath: "transform.scaleX", valueAtPlayhead: resolvedTransform.position.y,
localTime, step: 1,
isPlayheadWithinElementRange, buildBaseUpdates: ({ value }) => ({
displayValue: Math.round(resolvedTransform.scaleX * 100).toString(), transform: {
parse: parseScale, ...element.transform,
valueAtPlayhead: resolvedTransform.scaleX, position: { ...element.transform.position, y: value },
step: 0.01, },
buildBaseUpdates: ({ value }) => ({ }),
transform: { });
...element.transform,
scaleX: value, const parseScale = (input: string) => {
...(isScaleLocked ? { scaleY: value } : {}), const parsed = parseNumericInput({ input });
}, if (parsed === null) return null;
}), return parsed / 100;
buildAdditionalKeyframes: isScaleLocked };
? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
: undefined, const scaleX = useKeyframedNumberProperty({
}); trackId,
elementId: element.id,
const scaleY = useKeyframedNumberProperty({ animations: element.animations,
trackId, propertyPath: "transform.scaleX",
elementId: element.id, localTime,
animations: element.animations, isPlayheadWithinElementRange,
propertyPath: "transform.scaleY", displayValue: Math.round(resolvedTransform.scaleX * 100).toString(),
localTime, parse: parseScale,
isPlayheadWithinElementRange, valueAtPlayhead: resolvedTransform.scaleX,
displayValue: Math.round(resolvedTransform.scaleY * 100).toString(), step: 0.01,
parse: parseScale, buildBaseUpdates: ({ value }) => ({
valueAtPlayhead: resolvedTransform.scaleY, transform: {
step: 0.01, ...element.transform,
buildBaseUpdates: ({ value }) => ({ scaleX: value,
transform: { ...(isScaleLocked ? { scaleY: value } : {}),
...element.transform, },
scaleY: value, }),
...(isScaleLocked ? { scaleX: value } : {}), buildAdditionalKeyframes: isScaleLocked
}, ? ({ value }) => [{ propertyPath: "transform.scaleY", value }]
}), : undefined,
buildAdditionalKeyframes: isScaleLocked });
? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
: undefined, const scaleY = useKeyframedNumberProperty({
}); trackId,
elementId: element.id,
const scaleFieldPropsX = { animations: element.animations,
value: scaleX.displayValue, propertyPath: "transform.scaleY",
onFocus: scaleX.onFocus, localTime,
onChange: scaleX.onChange, isPlayheadWithinElementRange,
onBlur: scaleX.onBlur, displayValue: Math.round(resolvedTransform.scaleY * 100).toString(),
dragSensitivity: "slow" as const, parse: parseScale,
onScrub: scaleX.scrubTo, valueAtPlayhead: resolvedTransform.scaleY,
onScrubEnd: scaleX.commitScrub, step: 0.01,
onReset: () => buildBaseUpdates: ({ value }) => ({
scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }), transform: {
isDefault: isPropertyAtDefault({ ...element.transform,
hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes, scaleY: value,
isPlayheadWithinElementRange, ...(isScaleLocked ? { scaleX: value } : {}),
resolvedValue: resolvedTransform.scaleX, },
staticValue: element.transform.scaleX, }),
defaultValue: DEFAULTS.element.transform.scaleX, buildAdditionalKeyframes: isScaleLocked
}), ? ({ value }) => [{ propertyPath: "transform.scaleX", value }]
}; : undefined,
});
const scaleFieldPropsY = {
value: scaleY.displayValue, const scaleFieldPropsX = {
onFocus: scaleY.onFocus, value: scaleX.displayValue,
onChange: scaleY.onChange, onFocus: scaleX.onFocus,
onBlur: scaleY.onBlur, onChange: scaleX.onChange,
dragSensitivity: "slow" as const, onBlur: scaleX.onBlur,
onScrub: scaleY.scrubTo, dragSensitivity: "slow" as const,
onScrubEnd: scaleY.commitScrub, onScrub: scaleX.scrubTo,
onReset: () => onScrubEnd: scaleX.commitScrub,
scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }), onReset: () =>
isDefault: isPropertyAtDefault({ scaleX.commitValue({ value: DEFAULTS.element.transform.scaleX }),
hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes, isDefault: isPropertyAtDefault({
isPlayheadWithinElementRange, hasAnimatedKeyframes: scaleX.hasAnimatedKeyframes,
resolvedValue: resolvedTransform.scaleY, isPlayheadWithinElementRange,
staticValue: element.transform.scaleY, resolvedValue: resolvedTransform.scaleX,
defaultValue: DEFAULTS.element.transform.scaleY, staticValue: element.transform.scaleX,
}), defaultValue: DEFAULTS.element.transform.scaleX,
}; }),
};
const rotation = useKeyframedNumberProperty({
trackId, const scaleFieldPropsY = {
elementId: element.id, value: scaleY.displayValue,
animations: element.animations, onFocus: scaleY.onFocus,
propertyPath: "transform.rotate", onChange: scaleY.onChange,
localTime, onBlur: scaleY.onBlur,
isPlayheadWithinElementRange, dragSensitivity: "slow" as const,
displayValue: Math.round(resolvedTransform.rotate).toString(), onScrub: scaleY.scrubTo,
parse: (input) => { onScrubEnd: scaleY.commitScrub,
const parsed = parseNumericInput({ input }); onReset: () =>
if (parsed === null) return null; scaleY.commitValue({ value: DEFAULTS.element.transform.scaleY }),
return clamp({ value: parsed, min: -360, max: 360 }); isDefault: isPropertyAtDefault({
}, hasAnimatedKeyframes: scaleY.hasAnimatedKeyframes,
valueAtPlayhead: resolvedTransform.rotate, isPlayheadWithinElementRange,
step: 1, resolvedValue: resolvedTransform.scaleY,
buildBaseUpdates: ({ value }) => ({ staticValue: element.transform.scaleY,
transform: { defaultValue: DEFAULTS.element.transform.scaleY,
...element.transform, }),
rotate: value, };
},
}), const rotation = useKeyframedNumberProperty({
}); trackId,
elementId: element.id,
const hasScaleKeyframe = hasGroupKeyframeAtTime({ animations: element.animations,
animations: element.animations, propertyPath: "transform.rotate",
group: "transform.scale", localTime,
time: localTime, isPlayheadWithinElementRange,
}); displayValue: Math.round(resolvedTransform.rotate).toString(),
parse: (input) => {
const toggleScaleKeyframe = () => { const parsed = parseNumericInput({ input });
if (!isPlayheadWithinElementRange) return; if (parsed === null) return null;
const existing = getGroupKeyframesAtTime({ return clamp({ value: parsed, min: -360, max: 360 });
animations: element.animations, },
group: "transform.scale", valueAtPlayhead: resolvedTransform.rotate,
time: localTime, step: 1,
}); buildBaseUpdates: ({ value }) => ({
if (existing.length > 0) { transform: {
editor.timeline.removeKeyframes({ ...element.transform,
keyframes: existing.map((ref) => ({ rotate: value,
trackId, },
elementId: element.id, }),
...ref, });
})),
}); const hasScaleKeyframe = hasGroupKeyframeAtTime({
return; animations: element.animations,
} group: "transform.scale",
editor.timeline.upsertKeyframes({ time: localTime,
keyframes: [ });
{
trackId, const toggleScaleKeyframe = () => {
elementId: element.id, if (!isPlayheadWithinElementRange) return;
propertyPath: "transform.scaleX", const existing = getGroupKeyframesAtTime({
time: localTime, animations: element.animations,
value: resolvedTransform.scaleX, group: "transform.scale",
}, time: localTime,
{ });
trackId, if (existing.length > 0) {
elementId: element.id, editor.timeline.removeKeyframes({
propertyPath: "transform.scaleY", keyframes: existing.map((ref) => ({
time: localTime, trackId,
value: resolvedTransform.scaleY, elementId: element.id,
}, ...ref,
], })),
}); });
}; return;
}
const scaleLockButton = ( editor.timeline.upsertKeyframes({
<Button keyframes: [
type="button" {
variant={isScaleLocked ? "secondary" : "ghost"} trackId,
size="icon" elementId: element.id,
aria-pressed={isScaleLocked} propertyPath: "transform.scaleX",
onClick={() => setTransformScaleLocked(!isScaleLocked)} time: localTime,
> value: resolvedTransform.scaleX,
<HugeiconsIcon icon={Link05Icon} /> },
</Button> {
); trackId,
elementId: element.id,
return ( propertyPath: "transform.scaleY",
<Section collapsible sectionKey={`${element.id}:transform`}> time: localTime,
<SectionHeader> value: resolvedTransform.scaleY,
<SectionTitle>Transform</SectionTitle> },
</SectionHeader> ],
<SectionContent> });
<SectionFields> };
<div className="flex items-end gap-2">
{isScaleLocked ? ( const scaleLockButton = (
<> <Button
<SectionField type="button"
label="Scale" variant={isScaleLocked ? "secondary" : "ghost"}
className="min-w-0 flex-1" size="icon"
beforeLabel={ aria-pressed={isScaleLocked}
<KeyframeToggle onClick={() => setTransformScaleLocked(!isScaleLocked)}
isActive={hasScaleKeyframe} >
isDisabled={!isPlayheadWithinElementRange} <HugeiconsIcon icon={Link05Icon} />
title="Toggle scale keyframe" </Button>
onToggle={toggleScaleKeyframe} );
/>
} return (
> <Section collapsible sectionKey={`${element.id}:transform`}>
<NumberField <SectionHeader>
icon={<HugeiconsIcon icon={ArrowExpandIcon} />} <SectionTitle>Transform</SectionTitle>
{...scaleFieldPropsX} </SectionHeader>
/> <SectionContent>
</SectionField> <SectionFields>
{scaleLockButton} <div className="flex items-end gap-2">
</> {isScaleLocked ? (
) : ( <>
<> <SectionField
<SectionField label="Scale"
label="Width" className="min-w-0 flex-1"
className="min-w-0 flex-1" beforeLabel={
beforeLabel={ <KeyframeToggle
<KeyframeToggle isActive={hasScaleKeyframe}
isActive={scaleX.isKeyframedAtTime} isDisabled={!isPlayheadWithinElementRange}
isDisabled={!isPlayheadWithinElementRange} title="Toggle scale keyframe"
title="Toggle width scale keyframe" onToggle={toggleScaleKeyframe}
onToggle={scaleX.toggleKeyframe} />
/> }
} >
> <NumberField
<NumberField icon="W" {...scaleFieldPropsX} /> icon={<HugeiconsIcon icon={ArrowExpandIcon} />}
</SectionField> {...scaleFieldPropsX}
{scaleLockButton} />
<SectionField </SectionField>
label="Height" {scaleLockButton}
className="min-w-0 flex-1" </>
beforeLabel={ ) : (
<KeyframeToggle <>
isActive={scaleY.isKeyframedAtTime} <SectionField
isDisabled={!isPlayheadWithinElementRange} label="Width"
title="Toggle height scale keyframe" className="min-w-0 flex-1"
onToggle={scaleY.toggleKeyframe} beforeLabel={
/> <KeyframeToggle
} isActive={scaleX.isKeyframedAtTime}
> isDisabled={!isPlayheadWithinElementRange}
<NumberField icon="H" {...scaleFieldPropsY} /> title="Toggle width scale keyframe"
</SectionField> onToggle={scaleX.toggleKeyframe}
</> />
)} }
</div> >
<SectionField <NumberField icon="W" {...scaleFieldPropsX} />
label="Position" </SectionField>
beforeLabel={ {scaleLockButton}
<KeyframeToggle <SectionField
isActive={position.isKeyframedAtTime} label="Height"
isDisabled={!isPlayheadWithinElementRange} className="min-w-0 flex-1"
title="Toggle position keyframe" beforeLabel={
onToggle={position.toggleKeyframe} <KeyframeToggle
/> isActive={scaleY.isKeyframedAtTime}
} isDisabled={!isPlayheadWithinElementRange}
> title="Toggle height scale keyframe"
<div className="flex items-center gap-2"> onToggle={scaleY.toggleKeyframe}
<NumberField />
icon="X" }
className="flex-1" >
value={position.x.displayValue} <NumberField icon="H" {...scaleFieldPropsY} />
onFocus={position.x.onFocus} </SectionField>
onChange={position.x.onChange} </>
onBlur={position.x.onBlur} )}
onScrub={position.x.scrubTo} </div>
onScrubEnd={position.x.commitScrub} <div className="flex items-end gap-2">
onReset={() => <SectionField
position.commitX({ label="X"
value: DEFAULTS.element.transform.position.x, className="min-w-0 flex-1"
}) beforeLabel={
} <KeyframeToggle
isDefault={isPropertyAtDefault({ isActive={positionX.isKeyframedAtTime}
hasAnimatedKeyframes: position.hasAnimatedKeyframes, isDisabled={!isPlayheadWithinElementRange}
isPlayheadWithinElementRange, title="Toggle X position keyframe"
resolvedValue: resolvedTransform.position.x, onToggle={positionX.toggleKeyframe}
staticValue: element.transform.position.x, />
defaultValue: DEFAULTS.element.transform.position.x, }
})} >
/> <NumberField
<NumberField icon="X"
icon="Y" value={positionX.displayValue}
className="flex-1" onFocus={positionX.onFocus}
value={position.y.displayValue} onChange={positionX.onChange}
onFocus={position.y.onFocus} onBlur={positionX.onBlur}
onChange={position.y.onChange} onScrub={positionX.scrubTo}
onBlur={position.y.onBlur} onScrubEnd={positionX.commitScrub}
onScrub={position.y.scrubTo} onReset={() =>
onScrubEnd={position.y.commitScrub} positionX.commitValue({
onReset={() => value: DEFAULTS.element.transform.position.x,
position.commitY({ })
value: DEFAULTS.element.transform.position.y, }
}) isDefault={isPropertyAtDefault({
} hasAnimatedKeyframes: positionX.hasAnimatedKeyframes,
isDefault={isPropertyAtDefault({ isPlayheadWithinElementRange,
hasAnimatedKeyframes: position.hasAnimatedKeyframes, resolvedValue: resolvedTransform.position.x,
isPlayheadWithinElementRange, staticValue: element.transform.position.x,
resolvedValue: resolvedTransform.position.y, defaultValue: DEFAULTS.element.transform.position.x,
staticValue: element.transform.position.y, })}
defaultValue: DEFAULTS.element.transform.position.y, />
})} </SectionField>
/> <SectionField
</div> label="Y"
</SectionField> className="min-w-0 flex-1"
beforeLabel={
<SectionField <KeyframeToggle
label="Rotation" isActive={positionY.isKeyframedAtTime}
beforeLabel={ isDisabled={!isPlayheadWithinElementRange}
<KeyframeToggle title="Toggle Y position keyframe"
isActive={rotation.isKeyframedAtTime} onToggle={positionY.toggleKeyframe}
isDisabled={!isPlayheadWithinElementRange} />
title="Toggle rotation keyframe" }
onToggle={rotation.toggleKeyframe} >
/> <NumberField
} icon="Y"
> value={positionY.displayValue}
<div className="flex items-center gap-2"> onFocus={positionY.onFocus}
<NumberField onChange={positionY.onChange}
icon={<HugeiconsIcon icon={RotateClockwiseIcon} />} onBlur={positionY.onBlur}
className="flex-none" onScrub={positionY.scrubTo}
value={rotation.displayValue} onScrubEnd={positionY.commitScrub}
onFocus={rotation.onFocus} onReset={() =>
onChange={rotation.onChange} positionY.commitValue({
onBlur={rotation.onBlur} value: DEFAULTS.element.transform.position.y,
dragSensitivity="slow" })
onScrub={rotation.scrubTo} }
onScrubEnd={rotation.commitScrub} isDefault={isPropertyAtDefault({
onReset={() => hasAnimatedKeyframes: positionY.hasAnimatedKeyframes,
rotation.commitValue({ isPlayheadWithinElementRange,
value: DEFAULTS.element.transform.rotate, resolvedValue: resolvedTransform.position.y,
}) staticValue: element.transform.position.y,
} defaultValue: DEFAULTS.element.transform.position.y,
isDefault={isPropertyAtDefault({ })}
hasAnimatedKeyframes: rotation.hasAnimatedKeyframes, />
isPlayheadWithinElementRange, </SectionField>
resolvedValue: resolvedTransform.rotate, </div>
staticValue: element.transform.rotate,
defaultValue: DEFAULTS.element.transform.rotate, <SectionField
})} label="Rotation"
/> beforeLabel={
</div> <KeyframeToggle
</SectionField> isActive={rotation.isKeyframedAtTime}
</SectionFields> isDisabled={!isPlayheadWithinElementRange}
</SectionContent> title="Toggle rotation keyframe"
</Section> 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>
);
}

View File

@ -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,
}: { }: {

View File

@ -1,4 +1,5 @@
import type { import type {
AnimationBindingInstance,
AnimationPath, AnimationPath,
ElementAnimations, ElementAnimations,
ScalarAnimationChannel, ScalarAnimationChannel,
@ -6,6 +7,11 @@ import type {
ScalarGraphKeyframeContext, ScalarGraphKeyframeContext,
} from "@/lib/animation/types"; } from "@/lib/animation/types";
export interface EditableScalarChannels {
binding: AnimationBindingInstance;
channels: ScalarGraphChannel[];
}
function isScalarAnimationChannel( function isScalarAnimationChannel(
channel: ElementAnimations["channels"][string], channel: ElementAnimations["channels"][string],
): channel is ScalarAnimationChannel { ): channel is ScalarAnimationChannel {
@ -18,13 +24,13 @@ export function getEditableScalarChannels({
}: { }: {
animations: ElementAnimations | undefined; animations: ElementAnimations | undefined;
propertyPath: AnimationPath; propertyPath: AnimationPath;
}): ScalarGraphChannel[] { }): EditableScalarChannels | null {
const binding = animations?.bindings[propertyPath]; const binding = animations?.bindings[propertyPath];
if (!binding) { if (!binding) {
return []; return null;
} }
return binding.components.flatMap((component) => { const channels = binding.components.flatMap((component) => {
const channel = animations?.channels[component.channelId]; const channel = animations?.channels[component.channelId];
if (!isScalarAnimationChannel(channel)) { if (!isScalarAnimationChannel(channel)) {
return []; return [];
@ -36,9 +42,11 @@ export function getEditableScalarChannels({
componentKey: component.key, componentKey: component.key,
channelId: component.channelId, channelId: component.channelId,
channel, channel,
}, } satisfies ScalarGraphChannel,
]; ];
}); });
return { binding, channels };
} }
export function getEditableScalarChannel({ export function getEditableScalarChannel({
@ -50,12 +58,8 @@ export function getEditableScalarChannel({
propertyPath: AnimationPath; propertyPath: AnimationPath;
componentKey: string; componentKey: string;
}): ScalarGraphChannel | null { }): ScalarGraphChannel | null {
return ( const result = getEditableScalarChannels({ animations, propertyPath });
getEditableScalarChannels({ return result?.channels.find((channel) => channel.componentKey === componentKey) ?? null;
animations,
propertyPath,
}).find((channel) => channel.componentKey === componentKey) ?? null
);
} }
export function getScalarKeyframeContext({ export function getScalarKeyframeContext({

View File

@ -49,6 +49,7 @@ export {
} from "./keyframe-query"; } from "./keyframe-query";
export { export {
type EditableScalarChannels,
getEditableScalarChannel, getEditableScalarChannel,
getEditableScalarChannels, getEditableScalarChannels,
getScalarKeyframeContext, getScalarKeyframeContext,
@ -90,5 +91,6 @@ export {
} from "./property-groups"; } from "./property-groups";
export { export {
isVectorValue, type EasingMode,
getEasingModeForKind,
} from "./binding-values"; } from "./binding-values";

View File

@ -173,7 +173,7 @@ function createScalarKey({
id: string; id: string;
time: number; time: number;
value: number; value: number;
interpolation: AnimationInterpolation; interpolation?: AnimationInterpolation;
previousKey?: ScalarAnimationKey; previousKey?: ScalarAnimationKey;
}): ScalarAnimationKey { }): ScalarAnimationKey {
return { return {
@ -183,7 +183,8 @@ function createScalarKey({
leftHandle: previousKey?.leftHandle, leftHandle: previousKey?.leftHandle,
rightHandle: previousKey?.rightHandle, rightHandle: previousKey?.rightHandle,
segmentToNext: segmentToNext:
previousKey?.segmentToNext ?? getScalarSegmentType({ interpolation }), previousKey?.segmentToNext ??
getScalarSegmentType({ interpolation: interpolation ?? "linear" }),
tangentMode: previousKey?.tangentMode ?? "flat", tangentMode: previousKey?.tangentMode ?? "flat",
}; };
} }
@ -332,12 +333,14 @@ function upsertScalarChannelKey({
time, time,
value, value,
interpolation, interpolation,
defaultInterpolation,
keyframeId, keyframeId,
}: { }: {
channel: ScalarAnimationChannel | undefined; channel: ScalarAnimationChannel | undefined;
time: number; time: number;
value: number; value: number;
interpolation: AnimationInterpolation; interpolation?: AnimationInterpolation;
defaultInterpolation?: AnimationInterpolation;
keyframeId?: string; keyframeId?: string;
}): ScalarAnimationChannel { }): ScalarAnimationChannel {
const normalizedChannel = normalizeChannel({ const normalizedChannel = normalizeChannel({
@ -352,10 +355,13 @@ function upsertScalarChannelKey({
time, time,
value, value,
interpolation, interpolation,
previousKey: { previousKey:
...keys[existingIndex], interpolation != null
segmentToNext: getScalarSegmentType({ interpolation }), ? {
}, ...keys[existingIndex],
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:
...keys[existingAtTimeIndex], interpolation != null
segmentToNext: getScalarSegmentType({ interpolation }), ? {
}, ...keys[existingAtTimeIndex],
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,
}); });
} }

View File

@ -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),

View File

@ -48,12 +48,20 @@ export function resolveTransformAtTime({
}): Transform { }): Transform {
const safeLocalTime = Math.max(0, localTime); const safeLocalTime = Math.max(0, localTime);
return { return {
position: resolveAnimationPathValueAtTime({ position: {
animations, x: resolveAnimationPathValueAtTime({
propertyPath: "transform.position", animations,
localTime: safeLocalTime, propertyPath: "transform.positionX",
fallbackValue: baseTransform.position, localTime: safeLocalTime,
}), 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",

View File

@ -1,7 +1,8 @@
import type { ParamValues } from "@/lib/params"; import type { ParamValues } from "@/lib/params";
export const ANIMATION_PROPERTY_PATHS = [ export const ANIMATION_PROPERTY_PATHS = [
"transform.position", "transform.positionX",
"transform.positionY",
"transform.scaleX", "transform.scaleX",
"transform.scaleY", "transform.scaleY",
"transform.rotate", "transform.rotate",
@ -34,7 +35,8 @@ export type VectorValue = { x: number; y: number };
export type DiscreteValue = boolean | string; export type DiscreteValue = boolean | string;
export type AnimationValue = number | string | boolean | VectorValue; export type AnimationValue = number | string | boolean | VectorValue;
export interface AnimationPropertyValueMap { export interface AnimationPropertyValueMap {
"transform.position": VectorValue; "transform.positionX": number;
"transform.positionY": number;
"transform.scaleX": number; "transform.scaleX": number;
"transform.scaleY": number; "transform.scaleY": number;
"transform.rotate": number; "transform.rotate": number;

View File

@ -1,56 +1,58 @@
export { StorageMigration } from "./base"; export { StorageMigration } from "./base";
import { V0toV1Migration } from "./v0-to-v1"; import { V0toV1Migration } from "./v0-to-v1";
import { V1toV2Migration } from "./v1-to-v2"; import { V1toV2Migration } from "./v1-to-v2";
import { V2toV3Migration } from "./v2-to-v3"; import { V2toV3Migration } from "./v2-to-v3";
import { V3toV4Migration } from "./v3-to-v4"; import { V3toV4Migration } from "./v3-to-v4";
import { V4toV5Migration } from "./v4-to-v5"; import { V4toV5Migration } from "./v4-to-v5";
import { V5toV6Migration } from "./v5-to-v6"; import { V5toV6Migration } from "./v5-to-v6";
import { V6toV7Migration } from "./v6-to-v7"; import { V6toV7Migration } from "./v6-to-v7";
import { V7toV8Migration } from "./v7-to-v8"; import { V7toV8Migration } from "./v7-to-v8";
import { V8toV9Migration } from "./v8-to-v9"; import { V8toV9Migration } from "./v8-to-v9";
import { V9toV10Migration } from "./v9-to-v10"; import { V9toV10Migration } from "./v9-to-v10";
import { V10toV11Migration } from "./v10-to-v11"; import { V10toV11Migration } from "./v10-to-v11";
import { V11toV12Migration } from "./v11-to-v12"; import { V11toV12Migration } from "./v11-to-v12";
import { V12toV13Migration } from "./v12-to-v13"; import { V12toV13Migration } from "./v12-to-v13";
import { V13toV14Migration } from "./v13-to-v14"; import { V13toV14Migration } from "./v13-to-v14";
import { V14toV15Migration } from "./v14-to-v15"; import { V14toV15Migration } from "./v14-to-v15";
import { V15toV16Migration } from "./v15-to-v16"; import { V15toV16Migration } from "./v15-to-v16";
import { V16toV17Migration } from "./v16-to-v17"; import { V16toV17Migration } from "./v16-to-v17";
import { V17toV18Migration } from "./v17-to-v18"; import { V17toV18Migration } from "./v17-to-v18";
import { V18toV19Migration } from "./v18-to-v19"; import { V18toV19Migration } from "./v18-to-v19";
import { V19toV20Migration } from "./v19-to-v20"; import { V19toV20Migration } from "./v19-to-v20";
import { V20toV21Migration } from "./v20-to-v21"; 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";
export { runStorageMigrations } from "./runner"; import { V24toV25Migration } from "./v24-to-v25";
export type { MigrationProgress } from "./runner"; 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(), export const migrations = [
new V1toV2Migration(), new V0toV1Migration(),
new V2toV3Migration(), new V1toV2Migration(),
new V3toV4Migration(), new V2toV3Migration(),
new V4toV5Migration(), new V3toV4Migration(),
new V5toV6Migration(), new V4toV5Migration(),
new V6toV7Migration(), new V5toV6Migration(),
new V7toV8Migration(), new V6toV7Migration(),
new V8toV9Migration(), new V7toV8Migration(),
new V9toV10Migration(), new V8toV9Migration(),
new V10toV11Migration(), new V9toV10Migration(),
new V11toV12Migration(), new V10toV11Migration(),
new V12toV13Migration(), new V11toV12Migration(),
new V13toV14Migration(), new V12toV13Migration(),
new V14toV15Migration(), new V13toV14Migration(),
new V15toV16Migration(), new V14toV15Migration(),
new V16toV17Migration(), new V15toV16Migration(),
new V17toV18Migration(), new V16toV17Migration(),
new V18toV19Migration(), new V17toV18Migration(),
new V19toV20Migration(), new V18toV19Migration(),
new V20toV21Migration(), new V19toV20Migration(),
new V21toV22Migration(), new V20toV21Migration(),
new V22toV23Migration(), new V21toV22Migration(),
new V23toV24Migration(), new V22toV23Migration(),
]; new V23toV24Migration(),
new V24toV25Migration(),
];

View File

@ -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 };
}

View File

@ -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 });
}
}