Browse Source

feat(11-01): streamline header UI with visual grouping

- Group file operations (save, open folder) together with subtle separator
- Separate settings button with visual spacing
- Add hover background states to icon buttons for better interactivity
- Improve unsaved indicator with ring outline for better visibility
- Use consistent padding (p-1.5) and rounded corners

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 6 months ago
parent
commit
852a4a534d
  1. 28
      .planning/phases/07-video-connections/07-01-SUMMARY.md
  2. 10
      src/app/api/generate/route.ts
  3. 8
      src/app/api/models/[modelId]/route.ts
  4. 8
      src/app/api/models/route.ts
  5. 39
      src/components/ConnectionDropMenu.tsx
  6. 68
      src/components/Header.tsx
  7. 166
      src/components/ProjectSetupModal.tsx
  8. 91
      src/components/WorkflowCanvas.tsx

28
.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)

10
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<GenerateResponse>(
{
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

8
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<SchemaErrorResponse>(
{
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);
}

8
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 }
);

39
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: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
),
},
{
type: "output",
label: "Output",
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 16.5m0 0L7.5 12m4.5 4.5V3" />
</svg>
),
},
];
// Only generateVideo nodes produce video output
const VIDEO_SOURCE_OPTIONS: MenuOption[] = [
{
type: "generateVideo",
label: "Generate Video",
icon: (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="m15.75 10.5 4.72-4.72a.75.75 0 0 1 1.28.53v11.38a.75.75 0 0 1-1.28.53l-4.72-4.72M4.5 18.75h9a2.25 2.25 0 0 0 2.25-2.25v-9a2.25 2.25 0 0 0-2.25-2.25h-9A2.25 2.25 0 0 0 2.25 7.5v9a2.25 2.25 0 0 0 2.25 2.25Z" />
</svg>
),
},
];
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]);

68
src/components/Header.tsx

@ -134,53 +134,59 @@ export function Header() {
<span className="text-sm text-neutral-300">{workflowName}</span>
<span className="text-neutral-600">|</span>
<CostIndicator />
<button
onClick={() => canSave ? saveToFile() : handleOpenSettings()}
disabled={isSaving}
className="relative p-1 text-neutral-400 hover:text-neutral-200 transition-colors disabled:opacity-50"
title={isSaving ? "Saving..." : canSave ? "Save project" : "Configure save location"}
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
{hasUnsavedChanges && !isSaving && (
<span className="absolute -top-0.5 -right-0.5 w-2 h-2 rounded-full bg-red-500" />
)}
</button>
{saveDirectoryPath && (
{/* File operations group */}
<div className="flex items-center gap-0.5 ml-2 pl-2 border-l border-neutral-700/50">
<button
onClick={handleOpenDirectory}
className="p-1 text-neutral-400 hover:text-neutral-200 transition-colors"
title="Open Project Folder"
onClick={() => canSave ? saveToFile() : handleOpenSettings()}
disabled={isSaving}
className="relative p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors disabled:opacity-50"
title={isSaving ? "Saving..." : canSave ? "Save project" : "Configure save location"}
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5a3.375 3.375 0 0 0-3.375-3.375H8.25m.75 12 3 3m0 0 3-3m-3 3v-6m-1.5-9H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 0 0-9-9Z"
/>
</svg>
{hasUnsavedChanges && !isSaving && (
<span className="absolute top-0.5 right-0.5 w-2 h-2 rounded-full bg-red-500 ring-2 ring-neutral-900" />
)}
</button>
)}
{saveDirectoryPath && (
<button
onClick={handleOpenDirectory}
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors"
title="Open Project Folder"
>
<svg
className="w-4 h-4"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={2}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25"
/>
</svg>
</button>
)}
</div>
{/* Settings - separated */}
<button
onClick={handleOpenSettings}
className="p-1 text-neutral-400 hover:text-neutral-200 transition-colors"
className="p-1.5 text-neutral-400 hover:text-neutral-200 hover:bg-neutral-800 rounded transition-colors ml-1"
title="Project settings"
>
<svg

166
src/components/ProjectSetupModal.tsx

@ -416,60 +416,47 @@ export function ProjectSetupModal({
{/* Replicate Provider */}
<div className="p-3 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-100">Replicate</span>
<label className="relative inline-flex items-center cursor-pointer">
<span className="text-sm font-medium text-neutral-100">Replicate</span>
{envStatus?.replicate && !overrideActive.replicate ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-400">Configured via .env</span>
<button
type="button"
onClick={() => setOverrideActive((prev) => ({ ...prev, replicate: true }))}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Override
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={localProviders.providers.replicate?.enabled || false}
onChange={(e) => 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"
/>
<div className="w-8 h-4 bg-neutral-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{localProviders.providers.replicate?.enabled && (
envStatus?.replicate && !overrideActive.replicate ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-400">Configured via .env</span>
<button
type="button"
onClick={() => setOverrideActive((prev) => ({ ...prev, replicate: true }))}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Override
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input
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"
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, replicate: !prev.replicate }))}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{showApiKey.replicate ? "Hide" : "Show"}
</button>
{envStatus?.replicate && (
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, replicate: !prev.replicate }))}
className="text-xs text-neutral-400 hover:text-neutral-200"
onClick={() => {
setOverrideActive((prev) => ({ ...prev, replicate: false }));
updateLocalProvider("replicate", { apiKey: null });
}}
className="text-xs text-neutral-500 hover:text-neutral-300"
>
{showApiKey.replicate ? "Hide" : "Show"}
Cancel
</button>
{envStatus?.replicate && (
<button
type="button"
onClick={() => {
setOverrideActive((prev) => ({ ...prev, replicate: false }));
updateLocalProvider("replicate", { apiKey: null });
}}
className="text-xs text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
)}
</div>
)
)}
</div>
)}
</div>
</div>
@ -477,60 +464,47 @@ export function ProjectSetupModal({
{/* fal.ai Provider */}
<div className="p-3 bg-neutral-900 rounded-lg border border-neutral-700">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<span className="text-sm font-medium text-neutral-100">fal.ai</span>
<label className="relative inline-flex items-center cursor-pointer">
<span className="text-sm font-medium text-neutral-100">fal.ai</span>
{envStatus?.fal && !overrideActive.fal ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-400">Configured via .env</span>
<button
type="button"
onClick={() => setOverrideActive((prev) => ({ ...prev, fal: true }))}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Override
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={localProviders.providers.fal?.enabled || false}
onChange={(e) => updateLocalProvider("fal", { enabled: e.target.checked })}
className="sr-only peer"
type={showApiKey.fal ? "text" : "password"}
value={localProviders.providers.fal?.apiKey || ""}
onChange={(e) => updateLocalProvider("fal", { apiKey: e.target.value || null })}
placeholder="..."
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"
/>
<div className="w-8 h-4 bg-neutral-700 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full rtl:peer-checked:after:-translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:start-[2px] after:bg-white after:border-neutral-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-blue-600"></div>
</label>
</div>
{localProviders.providers.fal?.enabled && (
envStatus?.fal && !overrideActive.fal ? (
<div className="flex items-center gap-2">
<span className="text-xs text-green-400">Configured via .env</span>
<button
type="button"
onClick={() => setOverrideActive((prev) => ({ ...prev, fal: true }))}
className="px-2 py-1 text-xs text-neutral-400 hover:text-neutral-200 transition-colors"
>
Override
</button>
</div>
) : (
<div className="flex items-center gap-2">
<input
type={showApiKey.fal ? "text" : "password"}
value={localProviders.providers.fal?.apiKey || ""}
onChange={(e) => updateLocalProvider("fal", { apiKey: e.target.value || null })}
placeholder="..."
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"
/>
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, fal: !prev.fal }))}
className="text-xs text-neutral-400 hover:text-neutral-200"
>
{showApiKey.fal ? "Hide" : "Show"}
</button>
{envStatus?.fal && (
<button
type="button"
onClick={() => setShowApiKey((prev) => ({ ...prev, fal: !prev.fal }))}
className="text-xs text-neutral-400 hover:text-neutral-200"
onClick={() => {
setOverrideActive((prev) => ({ ...prev, fal: false }));
updateLocalProvider("fal", { apiKey: null });
}}
className="text-xs text-neutral-500 hover:text-neutral-300"
>
{showApiKey.fal ? "Hide" : "Show"}
Cancel
</button>
{envStatus?.fal && (
<button
type="button"
onClick={() => {
setOverrideActive((prev) => ({ ...prev, fal: false }));
updateLocalProvider("fal", { apiKey: null });
}}
className="text-xs text-neutral-500 hover:text-neutral-300"
>
Cancel
</button>
)}
</div>
)
)}
</div>
)}
</div>
</div>

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

Loading…
Cancel
Save