diff --git a/.planning/phases/07-video-connections/07-01-SUMMARY.md b/.planning/phases/07-video-connections/07-01-SUMMARY.md new file mode 100644 index 00000000..2fb23d68 --- /dev/null +++ b/.planning/phases/07-video-connections/07-01-SUMMARY.md @@ -0,0 +1,28 @@ +# Phase 7 Plan 1: Video Connections Summary + +**Video output handles now only connect to valid targets (generateVideo, output nodes)** + +## Accomplishments +- Added "video" as a new handle type alongside "image" and "text" +- Implemented connection validation that restricts video sources to only connect to generateVideo or output nodes +- Updated GenerateVideoNode to use video handle type (id="video", data-handletype="video") +- Updated getSourceOutput to return type "video" for generateVideo nodes + +## Files Modified +- `src/components/WorkflowCanvas.tsx` - Added isValidConnection function with video-specific rules, updated getHandleType to recognize video handles, updated getNodeHandles for generateVideo outputs +- `src/components/ConnectionDropMenu.tsx` - Added VIDEO_TARGET_OPTIONS and VIDEO_SOURCE_OPTIONS, updated props and getOptions to support video type +- `src/components/nodes/GenerateVideoNode.tsx` - Changed output handle from id="image" to id="video" with matching data-handletype +- `src/store/workflowStore.ts` - Updated getSourceOutput to return type "video" for generateVideo nodes + +## Decisions Made +- Video connections flow to the images array for backward compatibility since: + - generateVideo nodes treat video input similarly to image input (for video-to-video) + - output node handles both image and video in contentSrc + - Connection validation already prevents wrong connections +- Output node keeps id="image" input handle since it can accept both images and videos + +## Issues Encountered +None + +## Next Phase Readiness +Phase 7 complete, ready for Phase 8 (Error Display) diff --git a/src/app/api/generate/route.ts b/src/app/api/generate/route.ts index 78657e0d..7ff4d987 100644 --- a/src/app/api/generate/route.ts +++ b/src/app/api/generate/route.ts @@ -901,13 +901,13 @@ export async function POST(request: NextRequest) { // Route to appropriate provider if (provider === "replicate") { - // Get Replicate API key from request headers - const replicateApiKey = request.headers.get("X-Replicate-API-Key"); + // User-provided key takes precedence over env variable + const replicateApiKey = request.headers.get("X-Replicate-API-Key") || process.env.REPLICATE_API_KEY; if (!replicateApiKey) { return NextResponse.json( { success: false, - error: "Replicate API key not provided. Include X-Replicate-API-Key header.", + error: "Replicate API key not configured. Add REPLICATE_API_KEY to .env.local or configure in Settings.", }, { status: 401 } ); @@ -1002,8 +1002,8 @@ export async function POST(request: NextRequest) { } if (provider === "fal") { - // Get fal.ai API key from request headers (optional - fal.ai works without key but rate limited) - const falApiKey = request.headers.get("X-Fal-API-Key"); + // User-provided key takes precedence over env variable + const falApiKey = request.headers.get("X-Fal-API-Key") || process.env.FAL_API_KEY || null; // For fal.ai, keep Data URIs as-is since localhost URLs won't work // fal.ai accepts Data URIs directly diff --git a/src/app/api/models/[modelId]/route.ts b/src/app/api/models/[modelId]/route.ts index 1d96c21d..422a81a4 100644 --- a/src/app/api/models/[modelId]/route.ts +++ b/src/app/api/models/[modelId]/route.ts @@ -448,19 +448,21 @@ export async function GET( let result: ExtractedSchema; if (provider === "replicate") { - const apiKey = request.headers.get("X-Replicate-Key"); + // User-provided key takes precedence over env variable + const apiKey = request.headers.get("X-Replicate-Key") || process.env.REPLICATE_API_KEY; if (!apiKey) { return NextResponse.json( { success: false, - error: "Replicate API key required. Include X-Replicate-Key header.", + error: "Replicate API key required. Add REPLICATE_API_KEY to .env.local or configure in Settings.", }, { status: 401 } ); } result = await fetchReplicateSchema(decodedModelId, apiKey); } else { - const apiKey = request.headers.get("X-Fal-Key"); + // User-provided key takes precedence over env variable + const apiKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null; result = await fetchFalSchema(decodedModelId, apiKey); } diff --git a/src/app/api/models/route.ts b/src/app/api/models/route.ts index 1e2f75b9..53e8cbee 100644 --- a/src/app/api/models/route.ts +++ b/src/app/api/models/route.ts @@ -320,9 +320,9 @@ export async function GET( ? (capabilitiesParam.split(",") as ModelCapability[]) : null; - // Get API keys from headers - const replicateKey = request.headers.get("X-Replicate-Key"); - const falKey = request.headers.get("X-Fal-Key"); + // Get API keys from headers, falling back to env variables + const replicateKey = request.headers.get("X-Replicate-Key") || process.env.REPLICATE_API_KEY || null; + const falKey = request.headers.get("X-Fal-Key") || process.env.FAL_API_KEY || null; console.log( `[Models:${requestId}] Provider filter: ${providerFilter || "all"}, Search: ${searchQuery || "none"}, Refresh: ${refresh}` @@ -357,7 +357,7 @@ export async function GET( { success: false, error: - "No providers available. Provide API keys via X-Replicate-Key or X-Fal-Key headers.", + "No providers available. Add REPLICATE_API_KEY or FAL_API_KEY to .env.local or configure in Settings.", }, { status: 400 } ); diff --git a/src/components/ConnectionDropMenu.tsx b/src/components/ConnectionDropMenu.tsx index 3dd35795..00ad2c8e 100644 --- a/src/components/ConnectionDropMenu.tsx +++ b/src/components/ConnectionDropMenu.tsx @@ -154,9 +154,44 @@ const TEXT_SOURCE_OPTIONS: MenuOption[] = [ }, ]; +// Video can only connect to generateVideo (video-to-video) or output nodes +const VIDEO_TARGET_OPTIONS: MenuOption[] = [ + { + type: "generateVideo", + label: "Generate Video", + icon: ( + + + + ), + }, + { + type: "output", + label: "Output", + icon: ( + + + + ), + }, +]; + +// Only generateVideo nodes produce video output +const VIDEO_SOURCE_OPTIONS: MenuOption[] = [ + { + type: "generateVideo", + label: "Generate Video", + icon: ( + + + + ), + }, +]; + interface ConnectionDropMenuProps { position: { x: number; y: number }; - handleType: "image" | "text" | null; + handleType: "image" | "text" | "video" | null; connectionType: "source" | "target"; // source = dragging from output, target = dragging from input onSelect: (selection: { type: NodeType | MenuAction; isAction: boolean }) => void; onClose: () => void; @@ -178,9 +213,11 @@ export function ConnectionDropMenu({ if (connectionType === "source") { // Dragging from a source handle (output), need nodes with target handles (inputs) + if (handleType === "video") return VIDEO_TARGET_OPTIONS; return handleType === "image" ? IMAGE_TARGET_OPTIONS : TEXT_TARGET_OPTIONS; } else { // Dragging from a target handle (input), need nodes with source handles (outputs) + if (handleType === "video") return VIDEO_SOURCE_OPTIONS; return handleType === "image" ? IMAGE_SOURCE_OPTIONS : TEXT_SOURCE_OPTIONS; } }, [handleType, connectionType]); diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 54087d53..6077a80c 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -134,53 +134,59 @@ export function Header() { {workflowName} | - - {saveDirectoryPath && ( + + {/* File operations group */} +
- )} + {saveDirectoryPath && ( + + )} +
+ + {/* Settings - separated */} + + ) : ( +
updateLocalProvider("replicate", { enabled: e.target.checked })} - className="sr-only peer" + type={showApiKey.replicate ? "text" : "password"} + value={localProviders.providers.replicate?.apiKey || ""} + onChange={(e) => updateLocalProvider("replicate", { apiKey: e.target.value || null })} + placeholder="r8_..." + className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" /> -
- -
- {localProviders.providers.replicate?.enabled && ( - envStatus?.replicate && !overrideActive.replicate ? ( -
- Configured via .env - -
- ) : ( -
- updateLocalProvider("replicate", { apiKey: e.target.value || null })} - placeholder="r8_..." - className="w-48 px-2 py-1 bg-neutral-800 border border-neutral-600 rounded text-neutral-100 text-xs focus:outline-none focus:border-neutral-500" - /> + + {envStatus?.replicate && ( - {envStatus?.replicate && ( - - )} -
- ) + )} + )} @@ -477,60 +464,47 @@ export function ProjectSetupModal({ {/* fal.ai Provider */}
-
- fal.ai -
)}
diff --git a/src/components/WorkflowCanvas.tsx b/src/components/WorkflowCanvas.tsx index 821b5aae..26961f07 100644 --- a/src/components/WorkflowCanvas.tsx +++ b/src/components/WorkflowCanvas.tsx @@ -57,55 +57,21 @@ const edgeTypes: EdgeTypes = { // Connection validation rules // - Image handles (green) can only connect to image handles // - Text handles (blue) can only connect to text handles +// - 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" | null => { +const getHandleType = (handleId: string | null | undefined): "image" | "text" | "video" | null => { if (!handleId) return null; // Standard handles + if (handleId === "video") return "video"; if (handleId === "image" || handleId === "text") return handleId; // Dynamic handles - check naming patterns + if (handleId.includes("video")) return "video"; if (handleId.includes("image") || handleId.includes("frame")) return "image"; if (handleId === "prompt" || handleId === "negative_prompt" || handleId.includes("prompt")) return "text"; return null; }; -// Connection validation: ensures type matching between source and target handles -// - Image handles can connect to image handles -// - Text handles can connect to text handles -// - NanoBanana image input accepts multiple connections -// - All other inputs accept only one connection -const isValidConnection = (connection: Edge | Connection): boolean => { - const sourceHandle = connection.sourceHandle; - const targetHandle = connection.targetHandle; - - const sourceType = getHandleType(sourceHandle); - const targetType = getHandleType(targetHandle); - - // Strict type matching: image <-> image, text <-> text - if (sourceType === "image" && targetType !== "image") { - logger.warn('connection.validation', 'Connection validation failed: type mismatch', { - source: connection.source, - target: connection.target, - sourceHandle, - targetHandle, - reason: 'Cannot connect image handle to non-image handle', - }); - return false; - } - if (sourceType === "text" && targetType !== "text") { - logger.warn('connection.validation', 'Connection validation failed: type mismatch', { - source: connection.source, - target: connection.target, - sourceHandle, - targetHandle, - reason: 'Cannot connect text handle to non-text handle', - }); - return false; - } - - return true; -}; - // Define which handles each node type has const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] } => { switch (nodeType) { @@ -118,7 +84,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] case "nanoBanana": return { inputs: ["image", "text"], outputs: ["image"] }; case "generateVideo": - return { inputs: ["image", "text"], outputs: ["image"] }; + return { inputs: ["image", "text"], outputs: ["video"] }; case "llmGenerate": return { inputs: ["text", "image"], outputs: ["text"] }; case "splitGrid": @@ -133,7 +99,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[] interface ConnectionDropState { position: { x: number; y: number }; flowPosition: { x: number; y: number }; - handleType: "image" | "text" | null; + handleType: "image" | "text" | "video" | null; connectionType: "source" | "target"; sourceNodeId: string | null; sourceHandleId: string | null; @@ -264,6 +230,41 @@ export function WorkflowCanvas() { [groups, nodes, setNodeGroupId] ); + // Connection validation - checks if a connection is valid based on handle types and node types + // Defined inside component to have access to nodes array for video validation + const isValidConnection = useCallback( + (connection: Connection | Edge): boolean => { + const sourceType = getHandleType(connection.sourceHandle); + const targetType = getHandleType(connection.targetHandle); + + // If we can't determine types, allow the connection + if (!sourceType || !targetType) return true; + + // Video connections have special rules + if (sourceType === "video") { + // Video source can ONLY connect to: + // 1. generateVideo nodes (for video-to-video) + // 2. output nodes (for display) + const targetNode = nodes.find((n) => n.id === connection.target); + if (!targetNode) return false; + + const targetNodeType = targetNode.type; + if (targetNodeType === "generateVideo" || targetNodeType === "output") { + // For output node, we allow video even though its handle is typed as "image" + // because output node can display both images and videos + return true; + } + // Video cannot connect to other node types + return false; + } + + // Standard type matching for image and text + // Image handles connect to image handles, text handles connect to text handles + return sourceType === targetType; + }, + [nodes] + ); + const handleConnect = useCallback( (connection: Connection) => { if (!isValidConnection(connection)) return; @@ -325,7 +326,7 @@ export function WorkflowCanvas() { // Helper to find a compatible handle on a node by type const findCompatibleHandle = ( node: Node, - handleType: "image" | "text", + handleType: "image" | "text" | "video", needInput: boolean ): string | null => { // Check for dynamic inputSchema first @@ -336,7 +337,8 @@ export function WorkflowCanvas() { const match = nodeData.inputSchema.find(i => i.type === handleType); if (match) return match.name; } - // Output is still "image" for generate nodes + // Output handle - check for video or image type + if (handleType === "video") return "video"; return handleType === "image" ? "image" : null; } @@ -347,6 +349,11 @@ export function WorkflowCanvas() { // First try exact match if (handleList.includes(handleType)) return handleType; + // For video output connecting to output node, allow "image" input (output node accepts both) + if (handleType === "video" && needInput && node.type === "output") { + return "image"; + } + // Then check each handle's type for (const h of handleList) { if (getHandleType(h) === handleType) return h;