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 { interface ConnectionDropMenuProps {
position: { x: number; y: number }; 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 connectionType: "source" | "target"; // source = dragging from output, target = dragging from input
onSelect: (selection: { type: NodeType | MenuAction; isAction: boolean }) => void; onSelect: (selection: { type: NodeType | MenuAction; isAction: boolean }) => void;
onClose: () => 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 // - Video handles can only connect to generateVideo or output nodes
// Helper to determine handle type from handle ID // 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" // 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; if (!handleId) return null;
// EaseCurve handles (must check before other types)
if (handleId === "easeCurve") return "easeCurve";
// Standard handles // Standard handles
if (handleId === "video") return "video"; if (handleId === "video") return "video";
if (handleId === "audio" || handleId.startsWith("audio")) return "audio"; if (handleId === "audio" || handleId.startsWith("audio")) return "audio";
@ -121,7 +123,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[]
case "videoStitch": case "videoStitch":
return { inputs: ["video", "audio"], outputs: ["video"] }; return { inputs: ["video", "audio"], outputs: ["video"] };
case "easeCurve": case "easeCurve":
return { inputs: ["video"], outputs: ["video"] }; return { inputs: ["video", "easeCurve"], outputs: ["video", "easeCurve"] };
default: default:
return { inputs: [], outputs: [] }; return { inputs: [], outputs: [] };
} }
@ -130,7 +132,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[]
interface ConnectionDropState { interface ConnectionDropState {
position: { x: number; y: number }; position: { x: number; y: number };
flowPosition: { 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"; connectionType: "source" | "target";
sourceNodeId: string | null; sourceNodeId: string | null;
sourceHandleId: string | null; sourceHandleId: string | null;
@ -294,6 +296,13 @@ export function WorkflowCanvas() {
// If we can't determine types, allow the connection // If we can't determine types, allow the connection
if (!sourceType || !targetType) return true; 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 // Video connections have special rules
if (sourceType === "video") { if (sourceType === "video") {
// Video source can ONLY connect to: // Video source can ONLY connect to:
@ -406,7 +415,7 @@ export function WorkflowCanvas() {
// Helper to find a compatible handle on a node by type // Helper to find a compatible handle on a node by type
const findCompatibleHandle = ( const findCompatibleHandle = (
node: Node, node: Node,
handleType: "image" | "text" | "video" | "audio", handleType: "image" | "text" | "video" | "audio" | "easeCurve",
needInput: boolean needInput: boolean
): string | null => { ): string | null => {
// Check for dynamic inputSchema first // Check for dynamic inputSchema first
@ -781,6 +790,11 @@ export function WorkflowCanvas() {
// VideoStitch accepts audio // VideoStitch accepts audio
targetHandleId = "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 // Get all selected nodes to connect them all to the new node
@ -1544,7 +1558,7 @@ export function WorkflowCanvas() {
case "videoStitch": case "videoStitch":
return "#f97316"; return "#f97316";
case "easeCurve": case "easeCurve":
return "#f59e0b"; // amber-500 return "#bef264"; // lime-300 (easy-peasy-ease)
default: default:
return "#94a3b8"; return "#94a3b8";
} }

1
src/lib/quickstart/validation.ts

@ -306,6 +306,7 @@ function createDefaultNodeData(type: NodeType): WorkflowNodeData {
return { return {
bezierHandles: [0.445, 0.05, 0.55, 0.95] as [number, number, number, number], bezierHandles: [0.445, 0.05, 0.55, 0.95] as [number, number, number, number],
easingPreset: "easeInOutSine", easingPreset: "easeInOutSine",
inheritedFrom: null,
outputDuration: 1.5, outputDuration: 1.5,
outputVideo: null, outputVideo: null,
status: "idle", status: "idle",

1
src/store/utils/nodeDefaults.ts

@ -204,6 +204,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
return { return {
bezierHandles: [0.445, 0.05, 0.55, 0.95], // easeInOutSine preset bezierHandles: [0.445, 0.05, 0.55, 0.95], // easeInOutSine preset
easingPreset: "easeInOutSine", easingPreset: "easeInOutSine",
inheritedFrom: null,
outputDuration: 1.5, outputDuration: 1.5,
outputVideo: null, outputVideo: null,
status: "idle", status: "idle",

77
src/store/workflowStore.ts

@ -138,7 +138,7 @@ interface WorkflowStore {
// Helpers // Helpers
getNodeById: (id: string) => WorkflowNode | undefined; 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[] }; validateWorkflow: () => { valid: boolean; errors: string[] };
// Global Image History // 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: () => { validateWorkflow: () => {
@ -1985,6 +2001,22 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
try { try {
const inputs = getConnectedInputs(node.id); 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) { if (inputs.videos.length === 0) {
updateNodeData(node.id, { updateNodeData(node.id, {
status: "error", 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 // Determine easing function: use named preset if set, otherwise create from Bezier handles
let easingFunction: string | ((t: number) => number); let easingFunction: string | ((t: number) => number);
if (nodeData.easingPreset) { if (activeEasingPreset) {
easingFunction = nodeData.easingPreset; easingFunction = activeEasingPreset;
} else { } else {
const { createBezierEasing } = await import('@/lib/easing-functions'); const { createBezierEasing } = await import('@/lib/easing-functions');
easingFunction = createBezierEasing( easingFunction = createBezierEasing(
nodeData.bezierHandles[0], activeBezierHandles[0],
nodeData.bezierHandles[1], activeBezierHandles[1],
nodeData.bezierHandles[2], activeBezierHandles[2],
nodeData.bezierHandles[3] activeBezierHandles[3]
); );
} }
@ -2783,6 +2815,23 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
try { try {
const inputs = getConnectedInputs(nodeId); 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) { if (inputs.videos.length === 0) {
updateNodeData(nodeId, { updateNodeData(nodeId, {
status: "error", status: "error",
@ -2812,15 +2861,15 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
}); });
let easingFunction: string | ((t: number) => number); let easingFunction: string | ((t: number) => number);
if (nodeData.easingPreset) { if (activeEasingPreset) {
easingFunction = nodeData.easingPreset; easingFunction = activeEasingPreset;
} else { } else {
const { createBezierEasing } = await import('@/lib/easing-functions'); const { createBezierEasing } = await import('@/lib/easing-functions');
easingFunction = createBezierEasing( easingFunction = createBezierEasing(
nodeData.bezierHandles[0], activeBezierHandles[0],
nodeData.bezierHandles[1], activeBezierHandles[1],
nodeData.bezierHandles[2], activeBezierHandles[2],
nodeData.bezierHandles[3] activeBezierHandles[3]
); );
} }

3
src/types/nodes.ts

@ -245,6 +245,7 @@ export interface VideoStitchNodeData extends BaseNodeData {
export interface EaseCurveNodeData extends BaseNodeData { export interface EaseCurveNodeData extends BaseNodeData {
bezierHandles: [number, number, number, number]; bezierHandles: [number, number, number, number];
easingPreset: string | null; easingPreset: string | null;
inheritedFrom: string | null;
outputDuration: number; outputDuration: number;
outputVideo: string | null; outputVideo: string | null;
status: NodeStatus; status: NodeStatus;
@ -308,7 +309,7 @@ export type WorkflowNode = Node<WorkflowNodeData, NodeType> & {
/** /**
* Handle types for node connections * 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 * Default settings for node types - stored in localStorage

Loading…
Cancel
Save