Browse Source

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 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
4b758f14f1
  1. 2
      src/components/ConnectionDropMenu.tsx
  2. 24
      src/components/WorkflowCanvas.tsx
  3. 1
      src/lib/quickstart/validation.ts
  4. 1
      src/store/utils/nodeDefaults.ts
  5. 77
      src/store/workflowStore.ts
  6. 3
      src/types/nodes.ts

2
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;

24
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";
}

1
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",

1
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",

77
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<string, string | string[]> };
getConnectedInputs: (nodeId: string) => { images: string[]; videos: string[]; audio: string[]; text: string | null; dynamicInputs: Record<string, string | string[]>; 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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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<WorkflowStore>((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]
);
}

3
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<WorkflowNodeData, NodeType> & {
/**
* 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

Loading…
Cancel
Save