From 5073566c9e553f444b0b0d334c86114ae372eca2 Mon Sep 17 00:00:00 2001 From: shrimbly Date: Sat, 14 Feb 2026 23:31:04 +1300 Subject: [PATCH] feat: create generate3d executor Extracted from nanoBananaExecutor's 3D handling code. Handles 3D model generation via /api/generate with mediaType: "3d", cost tracking, and auto-save to generations folder. Co-Authored-By: Claude Opus 4.6 --- src/store/execution/generate3dExecutor.ts | 168 ++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 src/store/execution/generate3dExecutor.ts diff --git a/src/store/execution/generate3dExecutor.ts b/src/store/execution/generate3dExecutor.ts new file mode 100644 index 00000000..8d64df9a --- /dev/null +++ b/src/store/execution/generate3dExecutor.ts @@ -0,0 +1,168 @@ +/** + * Generate3D Executor + * + * Executor for generate3d (3D model generation) nodes. + * Extracted from nanoBananaExecutor's 3D handling code. + */ + +import type { Generate3DNodeData } from "@/types"; +import { buildGenerateHeaders } from "@/store/utils/buildApiHeaders"; +import type { NodeExecutionContext } from "./types"; + +export interface Generate3DOptions { + /** When true, falls back to stored inputImages/inputPrompt if no connections provide them. */ + useStoredFallback?: boolean; +} + +export async function executeGenerate3D( + ctx: NodeExecutionContext, + options: Generate3DOptions = {} +): Promise { + const { + node, + getConnectedInputs, + updateNodeData, + getFreshNode, + signal, + providerSettings, + addIncurredCost, + generationsPath, + trackSaveGeneration, + } = ctx; + + const { useStoredFallback = false } = options; + + const { images: connectedImages, text: connectedText, dynamicInputs } = getConnectedInputs(node.id); + + // Get fresh node data from store + const freshNode = getFreshNode(node.id); + const nodeData = (freshNode?.data || node.data) as Generate3DNodeData; + + // Determine images and text (with optional fallback to stored values) + let images: string[]; + let promptText: string | null; + + if (useStoredFallback) { + images = connectedImages.length > 0 ? connectedImages : nodeData.inputImages; + promptText = connectedText ?? nodeData.inputPrompt; + } else { + images = connectedImages; + const promptFromDynamic = Array.isArray(dynamicInputs.prompt) + ? dynamicInputs.prompt[0] + : dynamicInputs.prompt; + promptText = connectedText || promptFromDynamic || null; + } + + // 3D models may work with just images (image-to-3d) or just text (text-to-3d) + if (!promptText && images.length === 0) { + updateNodeData(node.id, { + status: "error", + error: "Missing text or image input", + }); + throw new Error("Missing text or image input"); + } + + updateNodeData(node.id, { + inputImages: images, + inputPrompt: promptText, + status: "loading", + error: null, + }); + + const provider = nodeData.selectedModel?.provider || "fal"; + const headers = buildGenerateHeaders(provider, providerSettings); + + const requestPayload = { + images, + prompt: promptText || "", + selectedModel: nodeData.selectedModel, + parameters: nodeData.parameters, + dynamicInputs, + mediaType: "3d" as const, + }; + + try { + const response = await fetch("/api/generate", { + method: "POST", + headers, + body: JSON.stringify(requestPayload), + ...(signal ? { signal } : {}), + }); + + if (!response.ok) { + const errorText = await response.text(); + let errorMessage = `HTTP ${response.status}`; + try { + const errorJson = JSON.parse(errorText); + errorMessage = errorJson.error || errorMessage; + } catch { + if (errorText) errorMessage += ` - ${errorText.substring(0, 200)}`; + } + + updateNodeData(node.id, { + status: "error", + error: errorMessage, + }); + throw new Error(errorMessage); + } + + const result = await response.json(); + + if (result.success && result.model3dUrl) { + updateNodeData(node.id, { + output3dUrl: result.model3dUrl, + status: "complete", + error: null, + }); + + // Track cost if applicable + if (nodeData.selectedModel?.pricing) { + addIncurredCost(nodeData.selectedModel.pricing.amount); + } + + // Auto-save 3D model to generations folder if configured + if (generationsPath) { + const savePromise = fetch("/api/save-generation", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + directoryPath: generationsPath, + model3d: result.model3dUrl, + prompt: promptText, + }), + }) + .then((res) => res.json()) + .catch((err) => { + console.error("Failed to save 3D model:", err); + }); + + trackSaveGeneration(`3d-${Date.now()}`, savePromise); + } + } else { + updateNodeData(node.id, { + status: "error", + error: result.error || "3D generation failed", + }); + throw new Error(result.error || "3D generation failed"); + } + } catch (error) { + if (error instanceof DOMException && error.name === "AbortError") { + throw error; + } + + let errorMessage = "3D generation failed"; + if (error instanceof TypeError && error.message.includes("NetworkError")) { + errorMessage = "Network error. Check your connection and try again."; + } else if (error instanceof TypeError) { + errorMessage = `Network error: ${error.message}`; + } else if (error instanceof Error) { + errorMessage = error.message; + } + + updateNodeData(node.id, { + status: "error", + error: errorMessage, + }); + throw new Error(errorMessage); + } +}