From 4b758f14f1935c4151a0dde73a7e7629863c3c22 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Wed, 4 Feb 2026 16:00:18 +1300 Subject: [PATCH] feat(quick-004): add easeCurve handle type and wire up connection routing - Add inheritedFrom field to EaseCurveNodeData interface - Add easeCurve to HandleType union - Update getHandleType, getNodeHandles, isValidConnection for easeCurve - Add easeCurve extraction in getConnectedInputs - Propagate parent bezierHandles/easingPreset in both execution paths - Fix validation.ts default data for easeCurve node Co-Authored-By: Claude Opus 4.5 --- src/components/ConnectionDropMenu.tsx | 2 +- src/components/WorkflowCanvas.tsx | 24 +++++++-- src/lib/quickstart/validation.ts | 1 + src/store/utils/nodeDefaults.ts | 1 + src/store/workflowStore.ts | 77 ++++++++++++++++++++++----- src/types/nodes.ts | 3 +- 6 files changed, 87 insertions(+), 21 deletions(-) diff --git a/src/components/ConnectionDropMenu.tsx b/src/components/ConnectionDropMenu.tsx index afba791f..5127de91 100644 --- a/src/components/ConnectionDropMenu.tsx +++ b/src/components/ConnectionDropMenu.tsx @@ -298,7 +298,7 @@ const AUDIO_SOURCE_OPTIONS: MenuOption[] = [ interface ConnectionDropMenuProps { position: { x: number; y: number }; - handleType: "image" | "text" | "video" | "audio" | null; + handleType: "image" | "text" | "video" | "audio" | "easeCurve" | null; connectionType: "source" | "target"; // source = dragging from output, target = dragging from input onSelect: (selection: { type: NodeType | MenuAction; isAction: boolean }) => void; onClose: () => void; diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 3b80e1a8..0ee25cba 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -78,8 +78,10 @@ const edgeTypes: EdgeTypes = { // - Video handles can only connect to generateVideo or output nodes // Helper to determine handle type from handle ID // For dynamic handles, we use naming convention: image inputs contain "image", text inputs are "prompt" or "negative_prompt" -const getHandleType = (handleId: string | null | undefined): "image" | "text" | "video" | "audio" | null => { +const getHandleType = (handleId: string | null | undefined): "image" | "text" | "video" | "audio" | "easeCurve" | null => { if (!handleId) return null; + // EaseCurve handles (must check before other types) + if (handleId === "easeCurve") return "easeCurve"; // Standard handles if (handleId === "video") return "video"; if (handleId === "audio" || handleId.startsWith("audio")) return "audio"; @@ -121,7 +123,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] case "videoStitch": return { inputs: ["video", "audio"], outputs: ["video"] }; case "easeCurve": - return { inputs: ["video"], outputs: ["video"] }; + return { inputs: ["video", "easeCurve"], outputs: ["video", "easeCurve"] }; default: return { inputs: [], outputs: [] }; } @@ -130,7 +132,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] interface ConnectionDropState { position: { x: number; y: number }; flowPosition: { x: number; y: number }; - handleType: "image" | "text" | "video" | "audio" | null; + handleType: "image" | "text" | "video" | "audio" | "easeCurve" | null; connectionType: "source" | "target"; sourceNodeId: string | null; sourceHandleId: string | null; @@ -294,6 +296,13 @@ export function WorkflowCanvas() { // If we can't determine types, allow the connection if (!sourceType || !targetType) return true; + // EaseCurve connections: only between easeCurve nodes + if (sourceType === "easeCurve" || targetType === "easeCurve") { + if (sourceType !== "easeCurve" || targetType !== "easeCurve") return false; + const targetNode = nodes.find((n) => n.id === connection.target); + return targetNode?.type === "easeCurve"; + } + // Video connections have special rules if (sourceType === "video") { // Video source can ONLY connect to: @@ -406,7 +415,7 @@ export function WorkflowCanvas() { // Helper to find a compatible handle on a node by type const findCompatibleHandle = ( node: Node, - handleType: "image" | "text" | "video" | "audio", + handleType: "image" | "text" | "video" | "audio" | "easeCurve", needInput: boolean ): string | null => { // Check for dynamic inputSchema first @@ -781,6 +790,11 @@ export function WorkflowCanvas() { // VideoStitch accepts audio targetHandleId = "audio"; } + } else if (handleType === "easeCurve") { + if (nodeType === "easeCurve") { + targetHandleId = "easeCurve"; + sourceHandleIdForNewNode = "easeCurve"; + } } // Get all selected nodes to connect them all to the new node @@ -1544,7 +1558,7 @@ export function WorkflowCanvas() { case "videoStitch": return "#f97316"; case "easeCurve": - return "#f59e0b"; // amber-500 + return "#bef264"; // lime-300 (easy-peasy-ease) default: return "#94a3b8"; } diff --git a/src/lib/quickstart/validation.ts b/src/lib/quickstart/validation.ts index 85c9eba0..5aa1acab 100644 --- a/src/lib/quickstart/validation.ts +++ b/src/lib/quickstart/validation.ts @@ -306,6 +306,7 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData { return { bezierHandles: [0.445, 0.05, 0.55, 0.95] as [number, number, number, number], easingPreset: "easeInOutSine", + inheritedFrom: null, outputDuration: 1.5, outputVideo: null, status: "idle", diff --git a/src/store/utils/nodeDefaults.ts b/src/store/utils/nodeDefaults.ts index 9f4fc860..96133b82 100644 --- a/src/store/utils/nodeDefaults.ts +++ b/src/store/utils/nodeDefaults.ts @@ -204,6 +204,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => { return { bezierHandles: [0.445, 0.05, 0.55, 0.95], // easeInOutSine preset easingPreset: "easeInOutSine", + inheritedFrom: null, outputDuration: 1.5, outputVideo: null, status: "idle", diff --git a/src/store/workflowStore.ts b/src/store/workflowStore.ts index a8d79372..1d38e56b 100644 --- a/src/store/workflowStore.ts +++ b/src/store/workflowStore.ts @@ -138,7 +138,7 @@ interface WorkflowStore { // Helpers getNodeById: (id: string) => WorkflowNode | undefined; - getConnectedInputs: (nodeId: string) => { images: string[]; videos: string[]; audio: string[]; text: string | null; dynamicInputs: Record }; + getConnectedInputs: (nodeId: string) => { images: string[]; videos: string[]; audio: string[]; text: string | null; dynamicInputs: Record; easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null } | null }; validateWorkflow: () => { valid: boolean; errors: string[] }; // Global Image History @@ -868,7 +868,23 @@ export const useWorkflowStore = create((set, get) => ({ } }); - return { images, videos, audio, text, dynamicInputs }; + // Extract easeCurve data from parent EaseCurve node + let easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null } | null = null; + const easeCurveEdge = edges.find( + (e) => e.target === nodeId && e.targetHandle === "easeCurve" + ); + if (easeCurveEdge) { + const sourceNode = nodes.find((n) => n.id === easeCurveEdge.source); + if (sourceNode?.type === "easeCurve") { + const sourceData = sourceNode.data as EaseCurveNodeData; + easeCurve = { + bezierHandles: sourceData.bezierHandles, + easingPreset: sourceData.easingPreset, + }; + } + } + + return { images, videos, audio, text, dynamicInputs, easeCurve }; }, validateWorkflow: () => { @@ -1985,6 +2001,22 @@ export const useWorkflowStore = create((set, get) => ({ try { const inputs = getConnectedInputs(node.id); + // Propagate parent easeCurve settings if inherited + let activeBezierHandles = nodeData.bezierHandles; + let activeEasingPreset = nodeData.easingPreset; + if (inputs.easeCurve) { + activeBezierHandles = inputs.easeCurve.bezierHandles; + activeEasingPreset = inputs.easeCurve.easingPreset; + const easeCurveSourceId = edges.filter( + (e) => e.target === node.id && e.targetHandle === "easeCurve" + )[0]?.source ?? null; + updateNodeData(node.id, { + bezierHandles: activeBezierHandles, + easingPreset: activeEasingPreset, + inheritedFrom: easeCurveSourceId, + }); + } + if (inputs.videos.length === 0) { updateNodeData(node.id, { status: "error", @@ -2015,15 +2047,15 @@ export const useWorkflowStore = create((set, get) => ({ // Determine easing function: use named preset if set, otherwise create from Bezier handles let easingFunction: string | ((t: number) => number); - if (nodeData.easingPreset) { - easingFunction = nodeData.easingPreset; + if (activeEasingPreset) { + easingFunction = activeEasingPreset; } else { const { createBezierEasing } = await import('@/lib/easing-functions'); easingFunction = createBezierEasing( - nodeData.bezierHandles[0], - nodeData.bezierHandles[1], - nodeData.bezierHandles[2], - nodeData.bezierHandles[3] + activeBezierHandles[0], + activeBezierHandles[1], + activeBezierHandles[2], + activeBezierHandles[3] ); } @@ -2783,6 +2815,23 @@ export const useWorkflowStore = create((set, get) => ({ try { const inputs = getConnectedInputs(nodeId); + // Propagate parent easeCurve settings if inherited + let activeBezierHandles = nodeData.bezierHandles; + let activeEasingPreset = nodeData.easingPreset; + if (inputs.easeCurve) { + activeBezierHandles = inputs.easeCurve.bezierHandles; + activeEasingPreset = inputs.easeCurve.easingPreset; + const { edges } = get(); + const easeCurveSourceId = edges.filter( + (e) => e.target === nodeId && e.targetHandle === "easeCurve" + )[0]?.source ?? null; + updateNodeData(nodeId, { + bezierHandles: activeBezierHandles, + easingPreset: activeEasingPreset, + inheritedFrom: easeCurveSourceId, + }); + } + if (inputs.videos.length === 0) { updateNodeData(nodeId, { status: "error", @@ -2812,15 +2861,15 @@ export const useWorkflowStore = create((set, get) => ({ }); let easingFunction: string | ((t: number) => number); - if (nodeData.easingPreset) { - easingFunction = nodeData.easingPreset; + if (activeEasingPreset) { + easingFunction = activeEasingPreset; } else { const { createBezierEasing } = await import('@/lib/easing-functions'); easingFunction = createBezierEasing( - nodeData.bezierHandles[0], - nodeData.bezierHandles[1], - nodeData.bezierHandles[2], - nodeData.bezierHandles[3] + activeBezierHandles[0], + activeBezierHandles[1], + activeBezierHandles[2], + activeBezierHandles[3] ); } diff --git a/src/types/nodes.ts b/src/types/nodes.ts index 86870e95..9cf5b111 100644 --- a/src/types/nodes.ts +++ b/src/types/nodes.ts @@ -245,6 +245,7 @@ export interface VideoStitchNodeData extends BaseNodeData { export interface EaseCurveNodeData extends BaseNodeData { bezierHandles: [number, number, number, number]; easingPreset: string | null; + inheritedFrom: string | null; outputDuration: number; outputVideo: string | null; status: NodeStatus; @@ -308,7 +309,7 @@ export type WorkflowNode = Node & { /** * Handle types for node connections */ -export type HandleType = "image" | "text" | "audio" | "video"; +export type HandleType = "image" | "text" | "audio" | "video" | "easeCurve"; /** * Default settings for node types - stored in localStorage