feat: graph editor supports multiple properties at once

This commit is contained in:
Maze Winther
2026-04-15 00:59:54 +02:00
parent 3db30c72e7
commit dc0a8e7c96
3 changed files with 644 additions and 512 deletions
@@ -23,6 +23,9 @@ const LINEAR_CURVE_EPSILON = 1e-6;
export type GraphEditorUnavailableReason =
| "no-keyframe-selected"
| "multiple-keyframes-selected"
| "selected-keyframes-span-multiple-elements"
| "selected-keyframes-are-not-adjacent"
| "selected-properties-have-no-shared-component"
| "selected-element-missing"
| "selected-element-has-no-animations"
| "selected-keyframe-has-no-scalar-channel"
@@ -36,6 +39,22 @@ export interface GraphEditorComponentOption {
label: string;
}
interface GraphEditorPropertyOption {
key: string;
label: string;
context: ScalarGraphKeyframeContext;
allContexts: ScalarGraphKeyframeContext[];
}
export interface GraphEditorResolvedSegment {
propertyPath: SelectedKeyframeRef["propertyPath"];
keyframeId: string;
context: ScalarGraphKeyframeContext;
allContexts: ScalarGraphKeyframeContext[];
cubicBezier: NormalizedCubicBezier;
referenceSpanValue: number;
}
interface GraphEditorBaseSelectionState {
componentOptions: GraphEditorComponentOption[];
activeComponentKey: string | null;
@@ -52,24 +71,9 @@ export interface GraphEditorReadyState extends GraphEditorBaseSelectionState {
status: "ready";
trackId: string;
elementId: string;
propertyPath: SelectedKeyframeRef["propertyPath"];
keyframeId: string;
element: TimelineElement;
/** Primary channel context, used for displaying the curve. */
context: ScalarGraphKeyframeContext;
/**
* All channel contexts that share this curve. For independent-easing bindings
* this is [context]. For shared-easing bindings (e.g. color) this contains
* all component contexts so patches can be applied to every channel at once.
*/
allContexts: ScalarGraphKeyframeContext[];
segments: GraphEditorResolvedSegment[];
cubicBezier: NormalizedCubicBezier;
/**
* Y-axis scale used for flat segments (where spanValue ≈ 0). Derived from
* the nearest non-flat adjacent segment so that handle positions correspond
* to a meaningful value range. Always positive.
*/
referenceSpanValue: number;
}
export type GraphEditorSelectionState =
@@ -152,6 +156,40 @@ function findKeyframeTime({
return null;
}
function groupSelectedKeyframesByProperty({
selectedKeyframes,
}: {
selectedKeyframes: SelectedKeyframeRef[];
}) {
const groups = new Map<
string,
{
trackId: string;
elementId: string;
propertyPath: SelectedKeyframeRef["propertyPath"];
keyframes: SelectedKeyframeRef[];
}
>();
for (const keyframe of selectedKeyframes) {
const groupKey = `${keyframe.trackId}:${keyframe.elementId}:${keyframe.propertyPath}`;
const existingGroup = groups.get(groupKey);
if (existingGroup) {
existingGroup.keyframes.push(keyframe);
continue;
}
groups.set(groupKey, {
trackId: keyframe.trackId,
elementId: keyframe.elementId,
propertyPath: keyframe.propertyPath,
keyframes: [keyframe],
});
}
return [...groups.values()];
}
function getComponentLabel({ componentKey }: { componentKey: string }): string {
switch (componentKey) {
case "value":
@@ -192,115 +230,82 @@ function getReferenceSpanValue({
return 1.0;
}
function isLinearCurve({
cubicBezier,
}: {
cubicBezier: NormalizedCubicBezier;
}): boolean {
return (
Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
);
interface GraphEditorPropertySelection {
propertyPath: SelectedKeyframeRef["propertyPath"];
keyframeId: string;
secondaryKeyframeId: string | null;
options: GraphEditorPropertyOption[];
}
export function resolveGraphEditorSelectionState({
tracks,
selectedKeyframes,
preferredComponentKey,
function resolvePropertySelection({
element,
propertyKeyframes,
}: {
tracks: SceneTracks;
selectedKeyframes: SelectedKeyframeRef[];
preferredComponentKey?: string | null;
}): GraphEditorSelectionState {
if (selectedKeyframes.length === 0) {
return createUnavailableState({
reason: "no-keyframe-selected",
message: "Select a keyframe to edit its curve.",
});
}
if (selectedKeyframes.length > 2) {
return createUnavailableState({
element: TimelineElement;
propertyKeyframes: ReturnType<
typeof groupSelectedKeyframesByProperty
>[number];
}):
| GraphEditorPropertySelection
| {
reason: GraphEditorUnavailableReason;
message: string;
} {
if (propertyKeyframes.keyframes.length > 2) {
return {
reason: "multiple-keyframes-selected",
message: "Select one or two adjacent keyframes to edit a curve.",
});
message: "Select at most two adjacent keyframes per property.",
};
}
if (selectedKeyframes.length === 2) {
const [firstKeyframe, secondKeyframe] = selectedKeyframes;
if (
firstKeyframe.trackId !== secondKeyframe.trackId ||
firstKeyframe.elementId !== secondKeyframe.elementId ||
firstKeyframe.propertyPath !== secondKeyframe.propertyPath
) {
return createUnavailableState({
reason: "multiple-keyframes-selected",
message: "Selected keyframes must be on the same element and property.",
});
}
}
const primaryKeyframe = selectedKeyframes[0];
const secondaryKeyframeId =
selectedKeyframes.length === 2 ? selectedKeyframes[1].keyframeId : null;
const selectedElement = findElementByKeyframe({
tracks,
keyframe: primaryKeyframe,
});
if (!selectedElement) {
return createUnavailableState({
reason: "selected-element-missing",
message: "The selected keyframe could not be resolved.",
});
}
if (!selectedElement.element.animations) {
return createUnavailableState({
if (!element.animations) {
return {
reason: "selected-element-has-no-animations",
message: "The selected keyframe has no editable graph.",
});
};
}
const scalarResult = getEditableScalarChannels({
animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath,
animations: element.animations,
propertyPath: propertyKeyframes.propertyPath,
});
if (!scalarResult || scalarResult.channels.length === 0) {
return createUnavailableState({
return {
reason: "selected-keyframe-has-no-scalar-channel",
message: "The selected keyframe has no editable graph channel.",
});
};
}
const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
// When 2 keyframes are selected, resolve the earlier one as the outgoing-segment
// anchor so the graph editor edits the curve between the two selected keyframes.
const primaryKeyframe = propertyKeyframes.keyframes[0];
let resolvedKeyframeId = primaryKeyframe.keyframeId;
let secondaryKeyframeId =
propertyKeyframes.keyframes.length === 2
? propertyKeyframes.keyframes[1].keyframeId
: null;
if (secondaryKeyframeId !== null) {
const time1 = findKeyframeTime({
animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath,
animations: element.animations,
propertyPath: propertyKeyframes.propertyPath,
keyframeId: primaryKeyframe.keyframeId,
});
const time2 = findKeyframeTime({
animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath,
animations: element.animations,
propertyPath: propertyKeyframes.propertyPath,
keyframeId: secondaryKeyframeId,
});
if (time2 !== null && (time1 === null || time2 < time1)) {
resolvedKeyframeId = secondaryKeyframeId;
secondaryKeyframeId = primaryKeyframe.keyframeId;
}
}
const { binding: resolvedBinding, channels: scalarChannels } = scalarResult;
const easingMode = getEasingModeForKind(resolvedBinding.kind);
const contexts = scalarChannels.flatMap((channel) => {
const context = getScalarKeyframeContext({
animations: selectedElement.element.animations,
propertyPath: primaryKeyframe.propertyPath,
animations: element.animations,
propertyPath: propertyKeyframes.propertyPath,
componentKey: channel.componentKey,
keyframeId: resolvedKeyframeId,
});
@@ -320,77 +325,290 @@ export function resolveGraphEditorSelectionState({
});
if (contexts.length === 0) {
return createUnavailableState({
return {
reason: "selected-keyframe-missing-on-channel",
message: "The selected keyframe is not editable as a graph segment.",
});
};
}
// For shared-easing bindings (e.g. color), all components always use the same
// curve. Collapse to a single option so no per-component tabs are shown.
const visibleContexts =
easingMode === "shared" ? [contexts[0]] : contexts;
const allContexts = contexts.map(({ context }) => context);
// curve. Collapse them to a single "value" option so the key is compatible with
// single-component scalar bindings (e.g. opacity), enabling mixed selections.
const options =
easingMode === "shared"
? [
{
key: "value",
label: "Curve",
context: contexts[0].context,
allContexts: contexts.map(({ context }) => context),
},
]
: contexts.map(({ context, option }) => ({
key: option.key,
label: option.label,
context,
allContexts: [context],
}));
const nextSegmentContexts = visibleContexts.filter(
({ context }) => context.nextKey !== null,
return {
propertyPath: propertyKeyframes.propertyPath,
keyframeId: resolvedKeyframeId,
secondaryKeyframeId,
options,
};
}
function isLinearCurve({
cubicBezier,
}: {
cubicBezier: NormalizedCubicBezier;
}): boolean {
return (
Math.abs(cubicBezier[0]) <= LINEAR_CURVE_EPSILON &&
Math.abs(cubicBezier[1]) <= LINEAR_CURVE_EPSILON &&
Math.abs(cubicBezier[2] - 1) <= LINEAR_CURVE_EPSILON &&
Math.abs(cubicBezier[3] - 1) <= LINEAR_CURVE_EPSILON
);
const preferredContext =
visibleContexts.find(({ option }) => option.key === preferredComponentKey) ?? null;
const activeContext =
preferredContext ?? nextSegmentContexts[0] ?? visibleContexts[0];
const componentOptions = visibleContexts.map(({ option }) => option);
}
if (!activeContext.context.nextKey) {
return createUnavailableState({
function resolveSegmentForOption({
propertySelection,
componentKey,
}: {
propertySelection: GraphEditorPropertySelection;
componentKey: string;
}):
| {
segment: GraphEditorResolvedSegment;
}
| {
reason: GraphEditorUnavailableReason;
message: string;
} {
const option = propertySelection.options.find(
(propertyOption) => propertyOption.key === componentKey,
);
if (!option) {
return {
reason: "selected-properties-have-no-shared-component",
message: "Selected properties do not share a graph-editable channel.",
};
}
if (!option.context.nextKey) {
return {
reason: "selected-keyframe-has-no-next-segment",
message: "Select a keyframe that has an outgoing segment.",
componentOptions,
activeComponentKey: activeContext.option.key,
});
};
}
if (activeContext.context.keyframe.segmentToNext === "step") {
return createUnavailableState({
if (
propertySelection.secondaryKeyframeId !== null &&
option.context.nextKey.id !== propertySelection.secondaryKeyframeId
) {
return {
reason: "selected-keyframes-are-not-adjacent",
message: "Selected keyframes must be adjacent on each property.",
};
}
if (option.context.keyframe.segmentToNext === "step") {
return {
reason: "selected-segment-is-hold",
message: "Hold segments have a fixed value easing has no effect here.",
componentOptions,
activeComponentKey: activeContext.option.key,
});
message: "Hold segments have a fixed value - easing has no effect here.",
};
}
const referenceSpanValue = getReferenceSpanValue({ context: activeContext.context });
const referenceSpanValue = getReferenceSpanValue({ context: option.context });
const cubicBezier =
activeContext.context.keyframe.segmentToNext === "linear"
option.context.keyframe.segmentToNext === "linear"
? GRAPH_LINEAR_CURVE
: getNormalizedCubicBezierForScalarSegment({
leftKey: activeContext.context.keyframe,
rightKey: activeContext.context.nextKey,
leftKey: option.context.keyframe,
rightKey: option.context.nextKey,
referenceSpanValue,
});
if (!cubicBezier) {
return createUnavailableState({
return {
reason: "selected-segment-is-flat",
message: "Cannot edit a segment where both keyframes are at the same time.",
message:
"Cannot edit a segment where both keyframes are at the same time.",
};
}
return {
segment: {
propertyPath: propertySelection.propertyPath,
keyframeId: propertySelection.keyframeId,
context: option.context,
allContexts: option.allContexts,
cubicBezier,
referenceSpanValue,
},
};
}
export function resolveGraphEditorSelectionState({
tracks,
selectedKeyframes,
preferredComponentKey,
}: {
tracks: SceneTracks;
selectedKeyframes: SelectedKeyframeRef[];
preferredComponentKey?: string | null;
}): GraphEditorSelectionState {
if (selectedKeyframes.length === 0) {
return createUnavailableState({
reason: "no-keyframe-selected",
message: "Select a keyframe to edit its curve.",
});
}
const propertyKeyframes = groupSelectedKeyframesByProperty({
selectedKeyframes,
});
const primaryKeyframe = propertyKeyframes[0]?.keyframes[0];
if (!primaryKeyframe) {
return createUnavailableState({
reason: "no-keyframe-selected",
message: "Select a keyframe to edit its curve.",
});
}
const selectedElement = findElementByKeyframe({
tracks,
keyframe: primaryKeyframe,
});
if (!selectedElement) {
return createUnavailableState({
reason: "selected-element-missing",
message: "The selected keyframe could not be resolved.",
});
}
const spansMultipleElements = propertyKeyframes.some(
(propertySelection) =>
propertySelection.trackId !== selectedElement.trackId ||
propertySelection.elementId !== selectedElement.elementId,
);
if (spansMultipleElements) {
return createUnavailableState({
reason: "selected-keyframes-span-multiple-elements",
message: "Selected keyframes must be on the same element.",
});
}
const propertySelections = propertyKeyframes.map((propertySelection) =>
resolvePropertySelection({
element: selectedElement.element,
propertyKeyframes: propertySelection,
}),
);
const unavailablePropertySelection = propertySelections.find(
(propertySelection) => "reason" in propertySelection,
);
if (
unavailablePropertySelection &&
"reason" in unavailablePropertySelection
) {
return createUnavailableState({
reason: unavailablePropertySelection.reason,
message: unavailablePropertySelection.message,
});
}
const resolvedPropertySelections = propertySelections.filter(
(propertySelection): propertySelection is GraphEditorPropertySelection =>
!("reason" in propertySelection),
);
const sharedComponentOptions =
resolvedPropertySelections[0]?.options.filter((componentOption) =>
resolvedPropertySelections.every((propertySelection) =>
propertySelection.options.some(
(option) => option.key === componentOption.key,
),
),
) ?? [];
const componentOptions = sharedComponentOptions.map(({ key, label }) => ({
key,
label,
}));
if (componentOptions.length === 0) {
return createUnavailableState({
reason: "selected-properties-have-no-shared-component",
message: "Selected properties do not share a graph-editable channel.",
});
}
// Try each component option in preference order (preferred first, then the rest)
// and stop at the first key where every property resolves to a valid segment.
// This single pass both selects the active key and produces the segment list.
const candidateKeys = [
...(preferredComponentKey &&
componentOptions.some((option) => option.key === preferredComponentKey)
? [preferredComponentKey]
: []),
...componentOptions
.filter((option) => option.key !== preferredComponentKey)
.map((option) => option.key),
];
let activeComponentKey = componentOptions[0].key;
let segmentResults: ReturnType<typeof resolveSegmentForOption>[] = [];
for (const candidateKey of candidateKeys) {
const results = resolvedPropertySelections.map((propertySelection) =>
resolveSegmentForOption({
propertySelection,
componentKey: candidateKey,
}),
);
activeComponentKey = candidateKey;
segmentResults = results;
if (results.every((result) => "segment" in result)) {
break;
}
}
const unavailableSegment = segmentResults.find(
(result) => !("segment" in result),
);
if (unavailableSegment && !("segment" in unavailableSegment)) {
return createUnavailableState({
reason: unavailableSegment.reason,
message: unavailableSegment.message,
componentOptions,
activeComponentKey: activeContext.option.key,
activeComponentKey,
});
}
const segments = segmentResults.flatMap((result) =>
"segment" in result ? [result.segment] : [],
);
const primarySegment = segments[0];
if (!primarySegment) {
return createUnavailableState({
reason: "selected-keyframe-missing-on-channel",
message: "The selected keyframe is not editable as a graph segment.",
componentOptions,
activeComponentKey,
});
}
return {
status: "ready",
message: "Edit graph",
message:
segments.length === 1
? "Edit graph"
: `Edit graph for ${segments.length} properties`,
componentOptions,
activeComponentKey: activeContext.option.key,
activeComponentKey,
trackId: selectedElement.trackId,
elementId: selectedElement.elementId,
propertyPath: primaryKeyframe.propertyPath,
keyframeId: resolvedKeyframeId,
element: selectedElement.element,
context: activeContext.context,
allContexts,
cubicBezier,
referenceSpanValue,
segments,
cubicBezier: primarySegment.cubicBezier,
};
}
@@ -14,8 +14,9 @@ import {
export function useGraphEditorController() {
const editor = useEditor();
const renderTracks = useEditor((currentEditor) =>
currentEditor.timeline.getPreviewTracks() ??
const renderTracks = useEditor(
(currentEditor) =>
currentEditor.timeline.getPreviewTracks() ??
currentEditor.scenes.getActiveScene().tracks,
);
const { selectedKeyframes } = useKeyframeSelection();
@@ -37,7 +38,12 @@ export function useGraphEditorController() {
const stateKey =
state.status === "ready"
? `${state.trackId}:${state.elementId}:${state.propertyPath}:${state.keyframeId}:${state.activeComponentKey}`
? `${state.trackId}:${state.elementId}:${state.activeComponentKey}:${state.segments
.map(
(segment) =>
`${segment.propertyPath}:${segment.keyframeId}:${segment.context.componentKey}`,
)
.join("|")}`
: `${state.status}:${state.reason}:${state.activeComponentKey ?? "none"}`;
const previousStateKeyRef = useRef(stateKey);
@@ -96,16 +102,20 @@ export function useGraphEditorController() {
return;
}
const nextAnimations = state.allContexts.reduce(
(animations, context) =>
applyGraphEditorCurvePreview({
animations,
context,
cubicBezier: nextValue,
referenceSpanValue: state.referenceSpanValue,
}),
state.element.animations,
);
const nextAnimations = state.segments.reduce(
(animations, segment) =>
segment.allContexts.reduce(
(nextAnimationsForSegment, context) =>
applyGraphEditorCurvePreview({
animations: nextAnimationsForSegment,
context,
cubicBezier: nextValue,
referenceSpanValue: segment.referenceSpanValue,
}),
animations,
),
state.element.animations,
);
editor.timeline.previewElements({
updates: [
{
@@ -126,28 +136,28 @@ export function useGraphEditorController() {
return;
}
// Build patches from the primary context (all shared-easing channels have
// the same keyframe IDs, so the same patches apply to each).
const patches = buildGraphEditorCurvePatches({
context: state.context,
cubicBezier: nextValue,
referenceSpanValue: state.referenceSpanValue,
});
if (!patches) {
return;
}
editor.timeline.updateKeyframeCurves({
keyframes: state.allContexts.flatMap((context) =>
patches.map(({ keyframeId, patch }) => ({
trackId: state.trackId,
elementId: state.elementId,
propertyPath: state.propertyPath,
componentKey: context.componentKey,
keyframeId,
patch,
})),
),
keyframes: state.segments.flatMap((segment) => {
const patches = buildGraphEditorCurvePatches({
context: segment.context,
cubicBezier: nextValue,
referenceSpanValue: segment.referenceSpanValue,
});
if (!patches) {
return [];
}
return segment.allContexts.flatMap((context) =>
patches.map(({ keyframeId, patch }) => ({
trackId: state.trackId,
elementId: state.elementId,
propertyPath: segment.propertyPath,
componentKey: context.componentKey,
keyframeId,
patch,
})),
);
}),
});
hasPreviewRef.current = false;
},
+4 -100
View File
@@ -30,8 +30,8 @@ interface KeybindingsState {
resetToDefaults: () => void;
importKeybindings: (config: KeybindingConfig) => void;
exportKeybindings: () => KeybindingConfig;
openOverlay: (overlayId: string, source: string) => void;
closeOverlay: (overlayId: string, source: string) => void;
openOverlay: (overlayId: string) => void;
closeOverlay: (overlayId: string) => void;
setLoadingProject: (loading: boolean) => void;
setIsRecording: (isRecording: boolean) => void;
validateKeybinding: (
@@ -56,104 +56,29 @@ export const useKeybindingsStore = create<KeybindingsState>()(
isLoadingProject: false,
isRecording: false,
openOverlay: (overlayId, source) =>
openOverlay: (overlayId) =>
set((s) => {
const openOverlayIds = s.openOverlayIds.includes(overlayId)
? s.openOverlayIds
: [...s.openOverlayIds, overlayId];
const nextOverlayDepth = openOverlayIds.length;
// #region agent log
fetch(
"http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "3997d9",
},
body: JSON.stringify({
sessionId: "3997d9",
runId: "initial",
hypothesisId: "H2",
location: "keybindings-store.ts:openOverlay",
message: "Overlay depth incremented",
data: {
source,
overlayId,
overlayDepth: s.overlayDepth,
nextOverlayDepth,
openOverlayIds,
},
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
return {
openOverlayIds,
overlayDepth: nextOverlayDepth,
};
}),
closeOverlay: (overlayId, source) =>
closeOverlay: (overlayId) =>
set((s) => {
const openOverlayIds = s.openOverlayIds.filter(
(id) => id !== overlayId,
);
const nextOverlayDepth = openOverlayIds.length;
// #region agent log
fetch(
"http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "3997d9",
},
body: JSON.stringify({
sessionId: "3997d9",
runId: "initial",
hypothesisId: "H2",
location: "keybindings-store.ts:closeOverlay",
message: "Overlay depth decremented",
data: {
source,
overlayId,
overlayDepth: s.overlayDepth,
nextOverlayDepth,
openOverlayIds,
},
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
return {
openOverlayIds,
overlayDepth: nextOverlayDepth,
};
}),
setLoadingProject: (loading) => {
// #region agent log
fetch(
"http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "3997d9",
},
body: JSON.stringify({
sessionId: "3997d9",
runId: "initial",
hypothesisId: "H2",
location: "keybindings-store.ts:setLoadingProject",
message: "Loading gate updated",
data: { loading },
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
set({ isLoadingProject: loading });
},
@@ -222,27 +147,6 @@ export const useKeybindingsStore = create<KeybindingsState>()(
return null;
},
setIsRecording: (isRecording: boolean) => {
// #region agent log
fetch(
"http://127.0.0.1:7245/ingest/669b22f8-172b-4e65-aa3f-1c702ede83f7",
{
method: "POST",
headers: {
"Content-Type": "application/json",
"X-Debug-Session-Id": "3997d9",
},
body: JSON.stringify({
sessionId: "3997d9",
runId: "initial",
hypothesisId: "H2",
location: "keybindings-store.ts:setIsRecording",
message: "Recording gate updated",
data: { isRecording },
timestamp: Date.now(),
}),
},
).catch(() => {});
// #endregion
set({ isRecording });
},