diff options
| author | Maze Winther <mazewinther@gmail.com> | 2026-04-15 00:59:54 +0200 |
|---|---|---|
| committer | Maze Winther <mazewinther@gmail.com> | 2026-04-15 00:59:54 +0200 |
| commit | dc0a8e7c963823432d8c89b6970ead024f06b091 (patch) | |
| tree | 1a30dff9c329e81d280cff0f437541569f65b874 /apps/web/src/components/editor | |
| parent | 3db30c72e76c1df16dc9723cf7382861211aa6a4 (diff) | |
| download | opencut-dc0a8e7c963823432d8c89b6970ead024f06b091.tar.gz | |
feat: graph editor supports multiple properties at once
Diffstat (limited to 'apps/web/src/components/editor')
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/graph-editor/session.ts | 480 | ||||
| -rw-r--r-- | apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts | 78 |
2 files changed, 393 insertions, 165 deletions
diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts index 60e4723e..cc0fb070 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/session.ts @@ -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.", - }); - } - - 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.", - }); + message: "Select at most two adjacent keyframes per property.", + }; } - 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], + })); + + return { + propertyPath: propertyKeyframes.propertyPath, + keyframeId: resolvedKeyframeId, + secondaryKeyframeId, + options, + }; +} - const nextSegmentContexts = visibleContexts.filter( - ({ context }) => context.nextKey !== null, +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, }; } diff --git a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts index cd4c792f..51d5a0ff 100644 --- a/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts +++ b/apps/web/src/components/editor/panels/timeline/graph-editor/use-controller.ts @@ -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; }, |
