Browse Source

feat: improve GLB viewer — fix viewport sizing, add spot light, handle labels, scroll isolation

- Fix 3D viewport not filling node by adding resize={{ offsetSize: true }} for correct measurement in CSS-transformed React Flow nodes
- Replace grid/axis indicators with spot light for cleaner scene
- Add labeled input (3D) and output (Image) handles matching generate node style
- Add native wheel event listener to prevent graph zoom/pan while scrolling to zoom 3D model
- Remove redundant CSS workarounds (absolute positioning style, extra relative wrapper)
- Add edge-to-edge viewport via contentClassName (removes default padding when GLB loaded)
- Overlay controls bar on viewport with gradient background

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
handoff-20260429-1057
shrimbly 5 months ago
parent
commit
3ebaae82de
  1. 28
      src/app/api/generate/providers/fal.ts
  2. 16
      src/app/api/generate/providers/replicate.ts
  3. 16
      src/app/api/generate/providers/wavespeed.ts
  4. 40
      src/app/api/generate/route.ts
  5. 52
      src/app/api/models/route.ts
  6. 6
      src/app/globals.css
  7. 30
      src/components/ConnectionDropMenu.tsx
  8. 21
      src/components/WorkflowCanvas.tsx
  9. 8
      src/components/nodes/BaseNode.tsx
  10. 160
      src/components/nodes/GLBViewerNode.tsx
  11. 30
      src/components/nodes/GenerateImageNode.tsx
  12. 8
      src/lib/providers/types.ts
  13. 86
      src/store/execution/executeNode.ts
  14. 1
      src/store/execution/index.ts
  15. 21
      src/store/execution/nanoBananaExecutor.ts
  16. 28
      src/store/execution/simpleNodeExecutors.ts
  17. 16
      src/store/utils/connectedInputs.ts
  18. 1
      src/store/utils/nodeDefaults.ts
  19. 31
      src/store/workflowStore.ts
  20. 5
      src/types/api.ts
  21. 3
      src/types/nodes.ts
  22. 1
      src/types/providers.ts

28
src/app/api/generate/providers/fal.ts

@ -456,10 +456,17 @@ export async function generateWithFalQueue(
const result = await resultResponse.json();
// Extract video URL from result
// Extract media URL from result
let mediaUrl: string | null = null;
if (result.video && result.video.url) {
// Check for 3D model output (GLB mesh) — must check before images
if (result.model_mesh?.url) {
mediaUrl = result.model_mesh.url;
} else if (result.mesh?.url) {
mediaUrl = result.mesh.url;
} else if (result.glb?.url) {
mediaUrl = result.glb.url;
} else if (result.video && result.video.url) {
mediaUrl = result.video.url;
} else if (result.images && Array.isArray(result.images) && result.images.length > 0) {
mediaUrl = result.images[0].url;
@ -494,7 +501,24 @@ export async function generateWithFalQueue(
};
}
const is3DModel = input.model.capabilities.some(c => c.includes("3d"));
const isVideoModel = input.model.capabilities.some(c => c.includes("video"));
// For 3D models, return URL directly (GLB files are binary — don't base64 encode)
if (is3DModel) {
console.log(`[API:${requestId}] SUCCESS - Returning 3D model URL`);
return {
success: true,
outputs: [
{
type: "3d",
data: "",
url: mediaUrl,
},
],
};
}
const contentType = mediaResponse.headers.get("content-type") || (isVideoModel ? "video/mp4" : "image/png");
const isVideo = contentType.startsWith("video/");

16
src/app/api/generate/providers/replicate.ts

@ -259,6 +259,22 @@ export async function generateWithReplicate(
};
}
// Check if this is a 3D model — return URL directly (GLB files are binary)
const is3DModel = input.model.capabilities.some(c => c.includes("3d"));
if (is3DModel) {
console.log(`[API:${requestId}] SUCCESS - Returning 3D model URL`);
return {
success: true,
outputs: [
{
type: "3d",
data: "",
url: mediaUrl,
},
],
};
}
// Determine MIME type from response
const contentType = mediaResponse.headers.get("content-type") || "image/png";
const isVideo = contentType.startsWith("video/");

16
src/app/api/generate/providers/wavespeed.ts

@ -88,6 +88,7 @@ export async function generateWithWaveSpeed(
console.log(`[API:${requestId}] Dynamic inputs: ${hasDynamicInputs ? Object.keys(input.dynamicInputs!).join(", ") : "none"}`);
// Determine output type from model capabilities
const is3DModel = input.model.capabilities.some(c => c.includes("3d"));
const isVideoModel = input.model.capabilities.includes("text-to-video") ||
input.model.capabilities.includes("image-to-video");
@ -344,6 +345,21 @@ export async function generateWithWaveSpeed(
}
const outputSizeMB = outputArrayBuffer.byteLength / (1024 * 1024);
// For 3D models, return URL directly (GLB files are binary — don't base64 encode)
if (is3DModel) {
console.log(`[API:${requestId}] SUCCESS - Returning 3D model URL`);
return {
success: true,
outputs: [
{
type: "3d",
data: "",
url: outputUrl,
},
],
};
}
const rawContentType = outputResponse.headers.get("content-type");
const contentType =
(rawContentType && (rawContentType.startsWith("video/") || rawContentType.startsWith("image/")))

40
src/app/api/generate/route.ts

@ -125,7 +125,7 @@ export async function POST(request: NextRequest) {
id: selectedModel!.modelId,
name: selectedModel!.displayName,
provider: "replicate",
capabilities: mediaType === "video" ? ["text-to-video"] : ["text-to-image"],
capabilities: mediaType === "video" ? ["text-to-video"] : mediaType === "3d" ? ["text-to-3d"] : ["text-to-image"],
description: null,
},
prompt: prompt || "",
@ -159,6 +159,14 @@ export async function POST(request: NextRequest) {
}
// Return appropriate fields based on output type
if (output.type === "3d") {
return NextResponse.json<GenerateResponse>({
success: true,
model3dUrl: output.url,
contentType: "3d",
});
}
if (output.type === "video") {
// Large videos have data="" with url set; normal videos have base64 data
const isLargeVideo = !output.data && output.url;
@ -212,7 +220,7 @@ export async function POST(request: NextRequest) {
id: selectedModel!.modelId,
name: selectedModel!.displayName,
provider: "fal",
capabilities: mediaType === "video" ? ["text-to-video"] : ["text-to-image"],
capabilities: mediaType === "video" ? ["text-to-video"] : mediaType === "3d" ? ["text-to-3d"] : ["text-to-image"],
description: null,
},
prompt: prompt || "",
@ -246,6 +254,14 @@ export async function POST(request: NextRequest) {
}
// Return appropriate fields based on output type
if (output.type === "3d") {
return NextResponse.json<GenerateResponse>({
success: true,
model3dUrl: output.url,
contentType: "3d",
});
}
if (output.type === "video") {
// Large videos have data="" with url set; normal videos have base64 data
const isLargeVideo = !output.data && output.url;
@ -303,7 +319,7 @@ export async function POST(request: NextRequest) {
id: selectedModel!.modelId,
name: selectedModel!.displayName,
provider: "kie",
capabilities: mediaType === "video" ? ["text-to-video"] : ["text-to-image"],
capabilities: mediaType === "video" ? ["text-to-video"] : mediaType === "3d" ? ["text-to-3d"] : ["text-to-image"],
description: null,
},
prompt: prompt || "",
@ -337,6 +353,14 @@ export async function POST(request: NextRequest) {
}
// Return appropriate fields based on output type
if (output.type === "3d") {
return NextResponse.json<GenerateResponse>({
success: true,
model3dUrl: output.url,
contentType: "3d",
});
}
if (output.type === "video") {
// Large videos have data="" with url set; normal videos have base64 data
const isLargeVideo = !output.data && output.url;
@ -394,7 +418,7 @@ export async function POST(request: NextRequest) {
id: selectedModel!.modelId,
name: selectedModel!.displayName,
provider: "wavespeed",
capabilities: mediaType === "video" ? ["text-to-video"] : ["text-to-image"],
capabilities: mediaType === "video" ? ["text-to-video"] : mediaType === "3d" ? ["text-to-3d"] : ["text-to-image"],
description: null,
},
prompt: prompt || "",
@ -428,6 +452,14 @@ export async function POST(request: NextRequest) {
}
// Return appropriate fields based on output type
if (output.type === "3d") {
return NextResponse.json<GenerateResponse>({
success: true,
model3dUrl: output.url,
contentType: "3d",
});
}
if (output.type === "video") {
// Large videos have data="" with url set; normal videos have base64 data
const isLargeVideo = !output.data && output.url;

52
src/app/api/models/route.ts

@ -43,12 +43,14 @@ const REPLICATE_API_BASE = "https://api.replicate.com/v1";
const FAL_API_BASE = "https://api.fal.ai/v1";
const WAVESPEED_API_BASE = "https://api.wavespeed.ai/api/v3";
// Categories we care about for image/video generation (fal.ai)
// Categories we care about for image/video/3D generation (fal.ai)
const RELEVANT_CATEGORIES = [
"text-to-image",
"image-to-image",
"text-to-video",
"image-to-video",
"text-to-3d",
"image-to-3d",
];
// Kie.ai models (hardcoded - no discovery API available)
@ -420,7 +422,32 @@ function inferReplicateCapabilities(model: ReplicateModel): ModelCapability[] {
const capabilities: ModelCapability[] = [];
const searchText = `${model.name} ${model.description ?? ""}`.toLowerCase();
// Check for video-related keywords first
// Check for 3D-related keywords first
const is3DModel =
searchText.includes("3d") ||
searchText.includes("mesh") ||
searchText.includes("triposr") ||
searchText.includes("tripo") ||
searchText.includes("hunyuan3d") ||
searchText.includes("instant-mesh") ||
searchText.includes("point-e") ||
searchText.includes("shap-e");
if (is3DModel) {
// 3D model - determine if image-to-3d or text-to-3d
const hasImageInput =
searchText.includes("image") ||
searchText.includes("img") ||
searchText.includes("photo");
if (hasImageInput) {
capabilities.push("image-to-3d");
} else {
capabilities.push("text-to-3d");
}
return capabilities;
}
// Check for video-related keywords
const isVideoModel =
searchText.includes("video") ||
searchText.includes("animate") ||
@ -559,6 +586,27 @@ function inferWaveSpeedCapabilities(model: WaveSpeedModel): ModelCapability[] {
const category = (model.category || model.type || "").toLowerCase();
const searchText = `${modelId} ${name} ${description} ${category}`;
// Check for 3D-related keywords first
const is3DModel =
searchText.includes("3d") ||
searchText.includes("mesh") ||
searchText.includes("tripo") ||
searchText.includes("hunyuan3d") ||
category.includes("3d");
if (is3DModel) {
const hasImageInput =
searchText.includes("image") ||
searchText.includes("img") ||
searchText.includes("photo");
if (hasImageInput) {
capabilities.push("image-to-3d");
} else {
capabilities.push("text-to-3d");
}
return capabilities;
}
// Check for video-related keywords
const isVideoModel =
searchText.includes("video") ||

6
src/app/globals.css

@ -10,6 +10,7 @@
/* Handle colors (used by labels) */
--handle-color-image: #10b981;
--handle-color-text: #3b82f6;
--handle-color-3d: #f97316;
}
html {
@ -87,6 +88,11 @@ body {
background: #3b82f6;
}
/* 3D handles - orange */
.react-flow__handle[data-handletype="3d"] {
background: #f97316;
}
.react-flow__edge-path {
stroke: #94a3b8;
stroke-width: 3;

30
src/components/ConnectionDropMenu.tsx

@ -305,9 +305,35 @@ const AUDIO_SOURCE_OPTIONS: MenuOption[] = [
},
];
// 3D target options (nodes that accept 3D input)
const THREE_D_TARGET_OPTIONS: MenuOption[] = [
{
type: "glbViewer",
label: "3D Viewer",
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="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
),
},
];
// 3D source options (nodes that produce 3D output)
const THREE_D_SOURCE_OPTIONS: MenuOption[] = [
{
type: "nanoBanana",
label: "Generate Image",
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="M2.25 15.75l5.159-5.159a2.25 2.25 0 013.182 0l5.159 5.159m-1.5-1.5l1.409-1.409a2.25 2.25 0 013.182 0l2.909 2.909m-18 3.75h16.5a1.5 1.5 0 001.5-1.5V6a1.5 1.5 0 00-1.5-1.5H3.75A1.5 1.5 0 002.25 6v12a1.5 1.5 0 001.5 1.5zm10.5-11.25h.008v.008h-.008V8.25zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
),
},
];
interface ConnectionDropMenuProps {
position: { x: number; y: number };
handleType: "image" | "text" | "video" | "audio" | "easeCurve" | null;
handleType: "image" | "text" | "video" | "audio" | "3d" | "easeCurve" | null;
connectionType: "source" | "target"; // source = dragging from output, target = dragging from input
onSelect: (selection: { type: NodeType | MenuAction; isAction: boolean }) => void;
onClose: () => void;
@ -331,11 +357,13 @@ export function ConnectionDropMenu({
// Dragging from a source handle (output), need nodes with target handles (inputs)
if (handleType === "video") return VIDEO_TARGET_OPTIONS;
if (handleType === "audio") return AUDIO_TARGET_OPTIONS;
if (handleType === "3d") return THREE_D_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;
if (handleType === "audio") return AUDIO_SOURCE_OPTIONS;
if (handleType === "3d") return THREE_D_SOURCE_OPTIONS;
return handleType === "image" ? IMAGE_SOURCE_OPTIONS : TEXT_SOURCE_OPTIONS;
}
}, [handleType, connectionType]);

21
src/components/WorkflowCanvas.tsx

@ -84,10 +84,12 @@ 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" | "easeCurve" | null => {
const getHandleType = (handleId: string | null | undefined): "image" | "text" | "video" | "audio" | "3d" | "easeCurve" | null => {
if (!handleId) return null;
// EaseCurve handles (must check before other types)
if (handleId === "easeCurve") return "easeCurve";
// 3D handles
if (handleId === "3d") return "3d";
// Standard handles
if (handleId === "video") return "video";
if (handleId === "audio" || handleId.startsWith("audio")) return "audio";
@ -131,7 +133,7 @@ const getNodeHandles = (nodeType: string): { inputs: string[]; outputs: string[]
case "easeCurve":
return { inputs: ["video", "easeCurve"], outputs: ["video", "easeCurve"] };
case "glbViewer":
return { inputs: [], outputs: ["image"] };
return { inputs: ["3d"], outputs: ["image"] };
default:
return { inputs: [], outputs: [] };
}
@ -140,7 +142,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" | "easeCurve" | null;
handleType: "image" | "text" | "video" | "audio" | "3d" | "easeCurve" | null;
connectionType: "source" | "target";
sourceNodeId: string | null;
sourceHandleId: string | null;
@ -331,6 +333,11 @@ export function WorkflowCanvas() {
return false;
}
// 3D connections: 3d handles can only connect to matching 3d handles
if (sourceType === "3d" || targetType === "3d") {
return sourceType === "3d" && targetType === "3d";
}
// Audio connections: audio handles can only connect to audio handles
if (sourceType === "audio" || targetType === "audio") {
return sourceType === "audio" && targetType === "audio";
@ -455,7 +462,7 @@ export function WorkflowCanvas() {
// Helper to find a compatible handle on a node by type
const findCompatibleHandle = (
node: Node,
handleType: "image" | "text" | "video" | "audio" | "easeCurve",
handleType: "image" | "text" | "video" | "audio" | "3d" | "easeCurve",
needInput: boolean,
batchUsed?: Set<string>
): string | null => {
@ -831,6 +838,12 @@ export function WorkflowCanvas() {
// VideoStitch accepts audio
targetHandleId = "audio";
}
} else if (handleType === "3d") {
if (nodeType === "glbViewer") {
targetHandleId = "3d";
} else if (nodeType === "nanoBanana") {
sourceHandleIdForNewNode = "3d";
}
} else if (handleType === "easeCurve") {
if (nodeType === "easeCurve") {
targetHandleId = "easeCurve";

8
src/components/nodes/BaseNode.tsx

@ -26,6 +26,7 @@ interface BaseNodeProps {
isExecuting?: boolean;
hasError?: boolean;
className?: string;
contentClassName?: string;
minWidth?: number;
minHeight?: number;
headerAction?: ReactNode;
@ -48,6 +49,7 @@ export function BaseNode({
isExecuting = false,
hasError = false,
className = "",
contentClassName,
minWidth = 180,
minHeight = 100,
headerAction,
@ -251,14 +253,14 @@ export function BaseNode({
/>
<div
className={`
bg-neutral-800 rounded-md shadow-lg border h-full w-full
bg-neutral-800 rounded-md shadow-lg border h-full w-full flex flex-col
${isCurrentlyExecuting || isExecuting ? "border-blue-500 ring-1 ring-blue-500/20" : "border-neutral-700"}
${hasError ? "border-red-500" : ""}
${selected ? "border-blue-500 ring-2 ring-blue-500/40 shadow-lg shadow-blue-500/25" : ""}
${className}
`}
>
<div className="px-3 pt-2 pb-1 flex items-center justify-between">
<div className="px-3 pt-2 pb-1 flex items-center justify-between shrink-0">
{/* Title Section */}
<div className="flex-1 min-w-0 flex items-center gap-1.5">
{titlePrefix}
@ -452,7 +454,7 @@ export function BaseNode({
</div>
)}
</div>
<div className="px-3 pb-4 h-[calc(100%-28px)] overflow-hidden flex flex-col">{children}</div>
<div className={contentClassName ?? "px-3 pb-4 flex-1 min-h-0 overflow-hidden flex flex-col"}>{children}</div>
</div>
</>
);

160
src/components/nodes/GLBViewerNode.tsx

@ -110,25 +110,13 @@ function Model({ url, onError }: { url: string; onError?: () => void }) {
function SceneEnvironment({ groupRef }: { groupRef: React.RefObject<THREE.Group | null> }) {
return (
<group ref={groupRef}>
{/* Ground grid */}
<gridHelper
args={[10, 20, "#555555", "#333333"]}
position={[0, -1, 0]}
<spotLight
position={[5, 8, 5]}
angle={0.4}
penumbra={0.8}
intensity={1.5}
castShadow
/>
{/* Subtle axis indicator at origin */}
<group position={[0, -1, 0]}>
{/* X axis - red */}
<mesh position={[0.75, 0.005, 0]}>
<boxGeometry args={[1.5, 0.01, 0.01]} />
<meshBasicMaterial color="#ef4444" transparent opacity={0.5} />
</mesh>
{/* Z axis - blue */}
<mesh position={[0, 0.005, 0.75]}>
<boxGeometry args={[0.01, 0.01, 1.5]} />
<meshBasicMaterial color="#3b82f6" transparent opacity={0.5} />
</mesh>
</group>
</group>
);
}
@ -278,6 +266,15 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
};
}, [nodeData.glbUrl]);
// Prevent wheel events from reaching React Flow (stop graph zoom/pan while scrolling to zoom 3D)
useEffect(() => {
const el = viewportRef.current;
if (!el) return;
const stopWheel = (e: WheelEvent) => e.stopPropagation();
el.addEventListener("wheel", stopWheel, { passive: false });
return () => el.removeEventListener("wheel", stopWheel);
}, [nodeData.glbUrl]);
// Shared file processing logic for both click-to-upload and drag-and-drop
const processFile = useCallback(
(file: File) => {
@ -369,6 +366,7 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
onCommentChange={(comment) => updateNodeData(id, { comment: comment || undefined })}
selected={selected}
commentNavigation={commentNavigation ?? undefined}
contentClassName={nodeData.glbUrl ? "flex-1 min-h-0 overflow-hidden flex flex-col" : undefined}
>
<input
ref={fileInputRef}
@ -379,18 +377,18 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
/>
{nodeData.glbUrl ? (
<div className="relative flex-1 flex flex-col min-h-0">
{/* 3D Viewport — fills available space, responsive to node resize */}
<div className="flex-1 flex flex-col min-h-0">
{/* 3D Viewport — fills node edge-to-edge */}
<div
ref={viewportRef}
className="nodrag nopan nowheel relative w-full flex-1 min-h-[200px] rounded overflow-hidden bg-neutral-900 border border-neutral-700"
className={`nodrag nopan nowheel relative w-full flex-1 min-h-[200px] overflow-hidden bg-neutral-900 ${nodeData.capturedImage ? "" : "rounded-b-[5px]"}`}
onPointerDown={() => setIsInteracting(true)}
onPointerUp={() => setIsInteracting(false)}
>
<Canvas
resize={{ offsetSize: true }}
gl={{ preserveDrawingBuffer: true, antialias: true, alpha: false }}
camera={{ position: [3.5, 2.1, 3.5], fov: 45, near: 0.01, far: 100 }}
style={{ position: "absolute", top: 0, left: 0, width: "100%", height: "100%" }}
onCreated={({ gl }) => {
gl.setClearColor(new THREE.Color("#1a1a1a"));
gl.toneMapping = THREE.ACESFilmicToneMapping;
@ -423,56 +421,56 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
<AutoRotate enabled={autoRotate && !isInteracting} />
<CaptureHelper captureRef={captureRef} envGroupRef={envGroupRef} />
</Canvas>
</div>
{/* Controls bar */}
<div className="mt-1.5 flex items-center justify-between shrink-0 gap-1">
<div className="flex items-center gap-1.5 min-w-0">
<span className="text-[10px] text-neutral-400 truncate max-w-[100px]">
{nodeData.filename}
</span>
<button
onClick={() => setAutoRotate(!autoRotate)}
title={autoRotate ? "Stop auto-rotate" : "Auto-rotate"}
className={`p-0.5 rounded transition-colors ${
autoRotate
? "text-cyan-400 bg-cyan-400/10"
: "text-neutral-500 hover:text-neutral-300"
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
</button>
</div>
{/* Controls bar — overlaid on viewport */}
<div className="absolute bottom-0 left-0 right-0 z-10 px-3 py-1.5 flex items-center justify-between gap-1 pointer-events-none bg-gradient-to-t from-black/60 to-transparent">
<div className="flex items-center gap-1.5 min-w-0 pointer-events-auto">
<span className="text-[10px] text-neutral-400 truncate max-w-[100px]">
{nodeData.filename}
</span>
<button
onClick={() => setAutoRotate(!autoRotate)}
title={autoRotate ? "Stop auto-rotate" : "Auto-rotate"}
className={`p-0.5 rounded transition-colors ${
autoRotate
? "text-cyan-400 bg-cyan-400/10"
: "text-neutral-500 hover:text-neutral-300"
}`}
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0l3.181 3.183a8.25 8.25 0 0013.803-3.7M4.031 9.865a8.25 8.25 0 0113.803-3.7l3.181 3.182" />
</svg>
</button>
</div>
<div className="flex items-center gap-1 shrink-0">
<button
onClick={handleCapture}
title="Capture current view as image"
className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium text-neutral-300 hover:text-neutral-100 bg-neutral-700 hover:bg-neutral-600 rounded transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
</svg>
Capture
</button>
<button
onClick={handleRemove}
title="Remove model"
className="p-0.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<div className="flex items-center gap-1 shrink-0 pointer-events-auto">
<button
onClick={handleCapture}
title="Capture current view as image"
className="flex items-center gap-1 px-1.5 py-0.5 text-[10px] font-medium text-neutral-300 hover:text-neutral-100 bg-neutral-700 hover:bg-neutral-600 rounded transition-colors"
>
<svg className="w-3 h-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6.827 6.175A2.31 2.31 0 015.186 7.23c-.38.054-.757.112-1.134.175C2.999 7.58 2.25 8.507 2.25 9.574V18a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9.574c0-1.067-.75-1.994-1.802-2.169a47.865 47.865 0 00-1.134-.175 2.31 2.31 0 01-1.64-1.055l-.822-1.316a2.192 2.192 0 00-1.736-1.039 48.774 48.774 0 00-5.232 0 2.192 2.192 0 00-1.736 1.039l-.821 1.316z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M16.5 12.75a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0z" />
</svg>
Capture
</button>
<button
onClick={handleRemove}
title="Remove model"
className="p-0.5 text-neutral-500 hover:text-red-400 rounded transition-colors"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
</div>
{/* Captured image preview */}
{nodeData.capturedImage && (
<div className="mt-1.5 shrink-0">
<div className="px-3 py-1.5 shrink-0">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] text-green-400 flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full bg-green-500" />
@ -509,13 +507,43 @@ export function GLBViewerNode({ id, data, selected }: NodeProps<GLBViewerNodeTyp
</div>
)}
{/* Output handle - image */}
{/* 3D input handle - accepts generated 3D models */}
<Handle
type="target"
position={Position.Left}
id="3d"
style={{ top: "50%" }}
data-handletype="3d"
/>
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none text-right"
style={{
right: `calc(100% + 8px)`,
top: "calc(50% - 18px)",
color: "var(--handle-color-3d)",
}}
>
3D
</div>
{/* Output handle - image (captured viewport) */}
<Handle
type="source"
position={Position.Right}
id="image"
style={{ top: "50%" }}
data-handletype="image"
/>
<div
className="absolute text-[10px] font-medium whitespace-nowrap pointer-events-none"
style={{
left: `calc(100% + 8px)`,
top: "calc(50% - 18px)",
color: "var(--handle-color-image)",
}}
>
Image
</div>
</BaseNode>
);
}

30
src/components/nodes/GenerateImageNode.tsx

@ -61,7 +61,7 @@ const GEMINI_IMAGE_MODELS: { value: ModelType; label: string }[] = [
];
// Image generation capabilities
const IMAGE_CAPABILITIES: ModelCapability[] = ["text-to-image", "image-to-image"];
const IMAGE_CAPABILITIES: ModelCapability[] = ["text-to-image", "image-to-image", "text-to-3d", "image-to-3d"];
type NanoBananaNodeType = Node<NanoBananaNodeData, "nanoBanana">;
@ -210,9 +210,10 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
provider: currentProvider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
};
// Clear parameters when changing models (different models have different schemas)
updateNodeData(id, { selectedModel: newSelectedModel, parameters: {} });
// Clear parameters and 3D output when changing models (different models have different schemas)
updateNodeData(id, { selectedModel: newSelectedModel, parameters: {}, output3dUrl: null });
}
},
[id, currentProvider, externalModels, updateNodeData]
@ -383,8 +384,9 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
provider: model.provider,
modelId: model.id,
displayName: model.name,
capabilities: model.capabilities,
};
updateNodeData(id, { selectedModel: newSelectedModel, parameters: {} });
updateNodeData(id, { selectedModel: newSelectedModel, parameters: {}, output3dUrl: null });
setIsBrowseDialogOpen(false);
}, [id, updateNodeData]);
@ -530,13 +532,13 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
>
Prompt
</div>
{/* Image output */}
{/* Output handle — switches to 3d when a 3D model is selected */}
<Handle
type="source"
position={Position.Right}
id="image"
id={nodeData.selectedModel?.capabilities?.some((c: string) => c.includes("3d")) ? "3d" : "image"}
style={{ top: "50%" }}
data-handletype="image"
data-handletype={nodeData.selectedModel?.capabilities?.some((c: string) => c.includes("3d")) ? "3d" : "image"}
/>
{/* Output label */}
<div
@ -544,15 +546,23 @@ export function GenerateImageNode({ id, data, selected }: NodeProps<NanoBananaNo
style={{
left: `calc(100% + 8px)`,
top: "calc(50% - 18px)",
color: "var(--handle-color-image)",
color: nodeData.selectedModel?.capabilities?.some((c: string) => c.includes("3d")) ? "var(--handle-color-3d)" : "var(--handle-color-image)",
}}
>
Image
{nodeData.selectedModel?.capabilities?.some((c: string) => c.includes("3d")) ? "3D" : "Image"}
</div>
<div className="flex-1 flex flex-col min-h-0 gap-2">
{/* Preview area */}
{nodeData.outputImage ? (
{nodeData.output3dUrl ? (
<div className="w-full flex-1 min-h-[80px] flex flex-col items-center justify-center gap-2 bg-neutral-800 rounded border border-neutral-700 p-3">
<svg className="w-8 h-8 text-orange-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M21 7.5l-2.25-1.313M21 7.5v2.25m0-2.25l-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3l2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75l2.25-1.313M12 21.75V19.5m0 2.25l-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25" />
</svg>
<span className="text-[11px] text-orange-400 font-medium">3D Model Generated</span>
<span className="text-[10px] text-neutral-500 truncate max-w-full">Connect to 3D Viewer</span>
</div>
) : nodeData.outputImage ? (
<>
<div className="relative w-full flex-1 min-h-0">
<img

8
src/lib/providers/types.ts

@ -15,7 +15,9 @@ export type ModelCapability =
| "text-to-image"
| "image-to-image"
| "text-to-video"
| "image-to-video";
| "image-to-video"
| "text-to-3d"
| "image-to-3d";
/**
* Model parameter schema for dynamic UI generation
@ -101,8 +103,8 @@ export interface GenerationOutput {
/** Generated outputs (images or videos) */
outputs?: Array<{
/** Type of output */
type: "image" | "video";
/** Base64 data URL of the output */
type: "image" | "video" | "3d";
/** Base64 data URL of the output (empty string for 3D/large video URL-only responses) */
data: string;
/** Original URL if applicable (e.g., from provider CDN) */
url?: string;

86
src/store/execution/executeNode.ts

@ -0,0 +1,86 @@
/**
* Central node dispatcher.
*
* Maps a node's type to the correct executor function, eliminating the
* duplicated switch/if-else chains that previously existed in
* executeWorkflow, regenerateNode, and executeSelectedNodes.
*/
import type { NodeExecutionContext } from "./types";
import {
executeAnnotation,
executePrompt,
executePromptConstructor,
executeOutput,
executeOutputGallery,
executeImageCompare,
executeGlbViewer,
} from "./simpleNodeExecutors";
import { executeNanoBanana } from "./nanoBananaExecutor";
import { executeGenerateVideo } from "./generateVideoExecutor";
import { executeLlmGenerate } from "./llmGenerateExecutor";
import { executeSplitGrid } from "./splitGridExecutor";
import { executeVideoStitch, executeEaseCurve } from "./videoProcessingExecutors";
export interface ExecuteNodeOptions {
/** When true, executors that support it will fall back to stored inputs. */
useStoredFallback?: boolean;
}
/**
* Execute a single node by dispatching to the appropriate executor.
*
* Data-source node types (`imageInput`, `audioInput`) are no-ops.
*/
export async function executeNode(
ctx: NodeExecutionContext,
options?: ExecuteNodeOptions,
): Promise<void> {
const regenOpts = options?.useStoredFallback ? { useStoredFallback: true } : undefined;
switch (ctx.node.type) {
case "imageInput":
case "audioInput":
// Data source nodes — no execution needed
break;
case "annotation":
await executeAnnotation(ctx);
break;
case "prompt":
await executePrompt(ctx);
break;
case "promptConstructor":
await executePromptConstructor(ctx);
break;
case "nanoBanana":
await executeNanoBanana(ctx, regenOpts);
break;
case "generateVideo":
await executeGenerateVideo(ctx, regenOpts);
break;
case "llmGenerate":
await executeLlmGenerate(ctx, regenOpts);
break;
case "splitGrid":
await executeSplitGrid(ctx);
break;
case "output":
await executeOutput(ctx);
break;
case "outputGallery":
await executeOutputGallery(ctx);
break;
case "imageCompare":
await executeImageCompare(ctx);
break;
case "videoStitch":
await executeVideoStitch(ctx);
break;
case "easeCurve":
await executeEaseCurve(ctx);
break;
case "glbViewer":
await executeGlbViewer(ctx);
break;
}
}

1
src/store/execution/index.ts

@ -15,6 +15,7 @@ export {
executeOutput,
executeOutputGallery,
executeImageCompare,
executeGlbViewer,
} from "./simpleNodeExecutors";
export { executeNanoBanana } from "./nanoBananaExecutor";

21
src/store/execution/nanoBananaExecutor.ts

@ -80,6 +80,9 @@ export async function executeNanoBanana(
const provider = nodeData.selectedModel?.provider || "gemini";
const headers = buildGenerateHeaders(provider, providerSettings);
// Determine mediaType from model capabilities
const is3DModel = nodeData.selectedModel?.capabilities?.some((c: string) => c.includes("3d"));
const requestPayload = {
images,
prompt: promptText,
@ -90,6 +93,7 @@ export async function executeNanoBanana(
selectedModel: nodeData.selectedModel,
parameters: nodeData.parameters,
dynamicInputs,
...(is3DModel ? { mediaType: "3d" as const } : {}),
};
try {
@ -119,6 +123,22 @@ export async function executeNanoBanana(
const result = await response.json();
// Handle 3D model response
if (result.success && result.model3dUrl) {
updateNodeData(node.id, {
output3dUrl: result.model3dUrl,
outputImage: null,
status: "complete",
error: null,
});
// Track cost if applicable
if (nodeData.selectedModel?.provider === "fal" && nodeData.selectedModel?.pricing) {
addIncurredCost(nodeData.selectedModel.pricing.amount);
}
return;
}
if (result.success && result.image) {
const timestamp = Date.now();
const imageId = `${timestamp}`;
@ -144,6 +164,7 @@ export async function executeNanoBanana(
updateNodeData(node.id, {
outputImage: result.image,
output3dUrl: null,
status: "complete",
error: null,
imageHistory: updatedHistory,

28
src/store/execution/simpleNodeExecutors.ts

@ -221,3 +221,31 @@ export async function executeImageCompare(ctx: NodeExecutionContext): Promise<vo
imageB: images[1] || null,
});
}
/**
* GLB Viewer node: receives 3D model URL from upstream, fetches and loads it.
*/
export async function executeGlbViewer(ctx: NodeExecutionContext): Promise<void> {
const { node, getConnectedInputs, updateNodeData } = ctx;
const { model3d } = getConnectedInputs(node.id);
if (model3d) {
// Fetch the GLB URL and create a blob URL for the viewer
try {
const response = await fetch(model3d);
if (!response.ok) {
throw new Error(`Failed to fetch 3D model: ${response.status}`);
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
updateNodeData(node.id, {
glbUrl: blobUrl,
filename: "generated.glb",
capturedImage: null,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.error(`[Workflow] GLB Viewer node ${node.id} failed:`, message);
updateNodeData(node.id, { error: message });
}
}
}

16
src/store/utils/connectedInputs.ts

@ -28,6 +28,7 @@ export interface ConnectedInputs {
images: string[];
videos: string[];
audio: string[];
model3d: string | null;
text: string | null;
dynamicInputs: Record<string, string | string[]>;
easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null } | null;
@ -52,7 +53,7 @@ function isTextHandle(handleId: string | null | undefined): boolean {
/**
* Extract output data and type from a source node
*/
function getSourceOutput(sourceNode: WorkflowNode): { type: "image" | "text" | "video" | "audio"; value: string | null } {
function getSourceOutput(sourceNode: WorkflowNode): { type: "image" | "text" | "video" | "audio" | "3d"; value: string | null } {
if (sourceNode.type === "imageInput") {
return { type: "image", value: (sourceNode.data as ImageInputNodeData).image };
} else if (sourceNode.type === "audioInput") {
@ -60,7 +61,11 @@ function getSourceOutput(sourceNode: WorkflowNode): { type: "image" | "text" | "
} else if (sourceNode.type === "annotation") {
return { type: "image", value: (sourceNode.data as AnnotationNodeData).outputImage };
} else if (sourceNode.type === "nanoBanana") {
return { type: "image", value: (sourceNode.data as NanoBananaNodeData).outputImage };
const nbData = sourceNode.data as NanoBananaNodeData;
if (nbData.output3dUrl) {
return { type: "3d", value: nbData.output3dUrl };
}
return { type: "image", value: nbData.outputImage };
} else if (sourceNode.type === "generateVideo") {
return { type: "video", value: (sourceNode.data as GenerateVideoNodeData).outputVideo };
} else if (sourceNode.type === "videoStitch") {
@ -92,6 +97,7 @@ export function getConnectedInputsPure(
const images: string[] = [];
const videos: string[] = [];
const audio: string[] = [];
let model3d: string | null = null;
let text: string | null = null;
const dynamicInputs: Record<string, string | string[]> = {};
@ -145,7 +151,9 @@ export function getConnectedInputsPure(
}
// Route to typed arrays based on source output type
if (type === "video") {
if (type === "3d") {
model3d = value;
} else if (type === "video") {
videos.push(value);
} else if (type === "audio") {
audio.push(value);
@ -172,7 +180,7 @@ export function getConnectedInputsPure(
}
}
return { images, videos, audio, text, dynamicInputs, easeCurve };
return { images, videos, audio, model3d, text, dynamicInputs, easeCurve };
}
/**

1
src/store/utils/nodeDefaults.ts

@ -121,6 +121,7 @@ export const createDefaultNodeData = (type: NodeType): WorkflowNodeData => {
inputImages: [],
inputPrompt: null,
outputImage: null,
output3dUrl: null,
aspectRatio,
resolution,
model: legacyDefaults.model, // Keep legacy model field for backward compat

31
src/store/workflowStore.ts

@ -68,6 +68,7 @@ import {
executeSplitGrid,
executeVideoStitch,
executeEaseCurve,
executeGlbViewer,
} from "./execution";
import type { NodeExecutionContext } from "./execution";
export type { LevelGroup } from "./utils/executionUtils";
@ -157,7 +158,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[]>; easeCurve: { bezierHandles: [number, number, number, number]; easingPreset: string | null } | null };
getConnectedInputs: (nodeId: string) => { images: string[]; videos: string[]; audio: string[]; model3d: string | null; 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
@ -841,9 +842,11 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
switch (node.type) {
case "imageInput":
case "audioInput":
case "glbViewer":
// Data source nodes - no execution needed
break;
case "glbViewer":
await executeGlbViewer(executionCtx);
break;
case "annotation":
await executeAnnotation(executionCtx);
break;
@ -1050,6 +1053,30 @@ export const useWorkflowStore = create<WorkflowStore>((set, get) => ({
return;
}
// After regeneration, execute directly connected downstream consumer nodes
// (e.g. glbViewer needs to fetch+load 3D model from upstream nanoBanana)
const { edges: currentEdges } = get();
const downstreamEdges = currentEdges.filter(e => e.source === nodeId);
for (const edge of downstreamEdges) {
const targetNode = get().nodes.find(n => n.id === edge.target);
if (!targetNode) continue;
const targetCtx = get()._buildExecutionContext(targetNode);
switch (targetNode.type) {
case "glbViewer":
await executeGlbViewer(targetCtx);
break;
case "output":
await executeOutput(targetCtx);
break;
case "outputGallery":
await executeOutputGallery(targetCtx);
break;
case "imageCompare":
await executeImageCompare(targetCtx);
break;
}
}
logger.info('node.execution', 'Node regeneration completed successfully', { nodeId });
set({ isRunning: false, currentNodeIds: [] });

5
src/types/api.ts

@ -16,7 +16,7 @@ export interface GenerateRequest {
resolution?: Resolution; // Only for Nano Banana Pro
model?: ModelType;
useGoogleSearch?: boolean; // Only for Nano Banana Pro
mediaType?: "image" | "video"; // Indicates expected output type for provider routing
mediaType?: "image" | "video" | "3d"; // Indicates expected output type for provider routing
}
export interface GenerateResponse {
@ -24,7 +24,8 @@ export interface GenerateResponse {
image?: string;
video?: string;
videoUrl?: string; // For large videos, return URL directly
contentType?: "image" | "video";
model3dUrl?: string; // For 3D models, return GLB URL directly
contentType?: "image" | "video" | "3d";
error?: string;
}

3
src/types/nodes.ts

@ -150,6 +150,7 @@ export interface NanoBananaNodeData extends BaseNodeData {
useGoogleSearch: boolean; // Only available for Nano Banana Pro
parameters?: Record<string, unknown>; // Model-specific parameters for external providers
inputSchema?: ModelInputDef[]; // Model's input schema for dynamic handles
output3dUrl?: string | null; // URL to generated 3D GLB model
status: NodeStatus;
error: string | null;
imageHistory: CarouselImageItem[]; // Carousel history (IDs only)
@ -321,7 +322,7 @@ export type WorkflowNode = Node<WorkflowNodeData, NodeType> & {
/**
* Handle types for node connections
*/
export type HandleType = "image" | "text" | "audio" | "video" | "easeCurve";
export type HandleType = "image" | "text" | "audio" | "video" | "3d" | "easeCurve";
/**
* Default settings for node types - stored in localStorage

1
src/types/providers.ts

@ -20,6 +20,7 @@ export interface SelectedModel {
modelId: string;
displayName: string;
pricing?: SelectedModelPricing; // Optional pricing info from provider API
capabilities?: string[]; // Model capabilities (e.g., "text-to-image", "image-to-3d")
}
export interface ProviderConfig {

Loading…
Cancel
Save