refactor: rewrite animation system to use bindings and scalar channels

This commit is contained in:
Maze
2026-04-05 13:15:58 +02:00
parent 69da5bee0c
commit 1bd9fac203
35 changed files with 5242 additions and 3235 deletions
@@ -1,148 +1,150 @@
"use client";
import { useEditor } from "@/hooks/use-editor";
import {
buildGraphicParamPath,
getKeyframeAtTime,
getParamDefaultInterpolation,
getParamValueKind,
hasKeyframesForPath,
upsertPathKeyframe,
} from "@/lib/animation";
import type {
ElementAnimations,
} from "@/lib/animation/types";
import type { ParamDefinition } from "@/lib/params";
import type { TimelineElement } from "@/lib/timeline";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
isKeyframedAtTime: boolean;
keyframeIdAtTime: string | null;
onPreview: (value: number | string | boolean) => void;
onCommit: () => void;
toggleKeyframe: () => void;
}
export function useKeyframedParamProperty({
param,
trackId,
elementId,
animations,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
buildBaseUpdates,
}: {
param: ParamDefinition;
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
buildBaseUpdates: ({
value,
}: {
value: number | string | boolean;
}) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult {
const editor = useEditor();
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
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 previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertPathKeyframe({
animations,
propertyPath,
time: localTime,
value,
valueKind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({
param,
}),
numericRange:
param.type === "number"
? { min: param.min, max: param.max, step: param.step }
: undefined,
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value }),
},
],
});
};
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,
},
],
});
};
return {
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
onPreview: previewValue,
onCommit: () => editor.timeline.commitPreview(),
toggleKeyframe,
};
}
"use client";
import { useEditor } from "@/hooks/use-editor";
import {
buildGraphicParamPath,
coerceAnimationValueForParam,
getKeyframeAtTime,
getParamDefaultInterpolation,
getParamValueKind,
hasKeyframesForPath,
upsertPathKeyframe,
} from "@/lib/animation";
import type {
ElementAnimations,
} from "@/lib/animation/types";
import type { ParamDefinition } from "@/lib/params";
import type { TimelineElement } from "@/lib/timeline";
export interface KeyframedParamPropertyResult {
hasAnimatedKeyframes: boolean;
isKeyframedAtTime: boolean;
keyframeIdAtTime: string | null;
onPreview: (value: number | string | boolean) => void;
onCommit: () => void;
toggleKeyframe: () => void;
}
export function useKeyframedParamProperty({
param,
trackId,
elementId,
animations,
localTime,
isPlayheadWithinElementRange,
resolvedValue,
buildBaseUpdates,
}: {
param: ParamDefinition;
trackId: string;
elementId: string;
animations: ElementAnimations | undefined;
localTime: number;
isPlayheadWithinElementRange: boolean;
resolvedValue: number | string | boolean;
buildBaseUpdates: ({
value,
}: {
value: number | string | boolean;
}) => Partial<TimelineElement>;
}): KeyframedParamPropertyResult {
const editor = useEditor();
const propertyPath = buildGraphicParamPath({ paramKey: param.key });
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 previewValue: KeyframedParamPropertyResult["onPreview"] = (value) => {
if (shouldUseAnimatedChannel) {
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: {
animations: upsertPathKeyframe({
animations,
propertyPath,
time: localTime,
value,
kind: getParamValueKind({ param }),
defaultInterpolation: getParamDefaultInterpolation({
param,
}),
coerceValue: (nextValue) =>
coerceAnimationValueForParam({
param,
value: nextValue,
}),
}),
},
},
],
});
return;
}
editor.timeline.previewElements({
updates: [
{
trackId,
elementId,
updates: buildBaseUpdates({ value }),
},
],
});
};
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,
},
],
});
};
return {
hasAnimatedKeyframes,
isKeyframedAtTime,
keyframeIdAtTime,
onPreview: previewValue,
onCommit: () => editor.timeline.commitPreview(),
toggleKeyframe,
};
}
@@ -292,10 +292,7 @@ export function TimelineElement({
const canToggleCurrentSourceAudio =
selectedElements.length === 1 &&
isCurrentElementSelected &&
canToggleSourceAudio({
element,
mediaAsset,
});
canToggleSourceAudio(element, mediaAsset);
const sourceAudioLabel =
element.type === "video"
? getSourceAudioActionLabel({ element })
@@ -50,6 +50,7 @@ import {
import { HugeiconsIcon } from "@hugeicons/react";
import { OcRippleIcon } from "@/components/icons";
export function TimelineToolbar({
zoomLevel,
minZoom,
@@ -115,10 +116,7 @@ function ToolbarLeftSection() {
})();
const canToggleSelectedSourceAudio =
!!selectedElement &&
canToggleSourceAudio({
element: selectedElement.element,
mediaAsset: selectedMediaAsset,
});
canToggleSourceAudio(selectedElement.element, selectedMediaAsset);
const sourceAudioLabel =
selectedElement?.element.type === "video"
? getSourceAudioActionLabel({
@@ -317,28 +315,34 @@ function ToolbarButton({
onClick,
disabled,
isActive,
buttonWrapper,
}: {
icon: React.ReactNode;
tooltip: string;
onClick: ({ event }: { event: React.MouseEvent }) => void;
onClick?: ({ event }: { event: React.MouseEvent }) => void;
disabled?: boolean;
isActive?: boolean;
buttonWrapper?: (button: React.ReactElement) => React.ReactElement;
}) {
const button = (
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
disabled={disabled}
onClick={onClick ? (event) => onClick({ event }) : undefined}
className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
{icon}
</Button>
);
return (
<Tooltip delayDuration={200}>
<TooltipTrigger asChild>
<Button
variant={isActive ? "secondary" : "text"}
size="icon"
disabled={disabled}
onClick={(event) => onClick({ event })}
className={cn(
"rounded-sm",
disabled ? "cursor-not-allowed opacity-50" : "",
)}
>
{icon}
</Button>
{buttonWrapper ? buttonWrapper(button) : button}
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>