/** * Service for interacting with local ComfyUI instances */ /** * Converts a standard ComfyUI UI workflow (the one with 'nodes' array) * to the API format that the /prompt endpoint expects. */ const convertUiToApi = (uiWorkflow: any): any => { const apiPrompt: any = {}; if (!uiWorkflow.nodes || !Array.isArray(uiWorkflow.nodes)) { return uiWorkflow; // Already in API format or unknown } uiWorkflow.nodes.forEach((node: any) => { const nodeId = node.id.toString(); const inputs: any = {}; // Map inputs based on links if (uiWorkflow.links && node.inputs) { node.inputs.forEach((input: any) => { // Removed unused 'index' parameter const linkId = input.link; if (linkId) { const link = uiWorkflow.links.find((l: any) => l[0] === linkId); if (link) { // link format: [id, origin_id, origin_slot, target_id, target_slot, type] inputs[input.name] = [link[1].toString(), link[2]]; } } }); } // Map widgets to inputs // This is a simplified mapping; standard nodes usually have widgets in a specific order if (node.widgets_values && Array.isArray(node.widgets_values)) { // Note: This mapping is brittle as it depends on node implementation, // but for standard nodes it often works. // We'll primarily rely on the explicit injection logic later. node.widgets_values.forEach((val: any, idx: number) => { // We don't strictly know the keys here without the node definition, // but we store them to be safe. inputs[`_widget_${idx}`] = val; }); } apiPrompt[nodeId] = { class_type: node.type, inputs: inputs, _meta: { title: node.title || node.type } }; }); return apiPrompt; }; export const sendComfyPrompt = async ( serverUrl: string, workflow: any, promptText: string, seed: number, steps: number, useSecureBridge: boolean = false, signal?: AbortSignal ): Promise => { const baseUrl = serverUrl.replace(/\/+$/, ''); console.log(`[ComfyUI] Starting preview. Bridge: ${useSecureBridge}, Target: ${baseUrl}`); // 1. Prepare Workflow let apiPrompt: any = {}; const isUiFormat = workflow.nodes && Array.isArray(workflow.nodes); if (isUiFormat) { console.log("[ComfyUI] Standard UI format detected. Attempting internal mapping..."); // For standard UI format, we'll try to find the nodes by type and title apiPrompt = JSON.parse(JSON.stringify(workflow)); // Work on a copy let promptNode = apiPrompt.nodes.find((n: any) => n.type === 'CLIPTextEncode' && ((n.title || "").toLowerCase().includes("positive") || !(n.title || "").toLowerCase().includes("negative")) ); let samplerNode = apiPrompt.nodes.find((n: any) => n.type === 'KSampler' || n.type === 'KSamplerAdvanced'); if (promptNode) { // In UI format, prompt is usually the first widget if (promptNode.widgets_values) promptNode.widgets_values[0] = promptText; } if (samplerNode && samplerNode.widgets_values) { if (seed !== -1) samplerNode.widgets_values[0] = seed; if (steps !== -1) samplerNode.widgets_values[2] = steps; } // IMPORTANT: The /prompt endpoint REQUIRES API format. // If we have UI format, we MUST convert it or it will fail. apiPrompt = convertUiToApi(apiPrompt); } else { apiPrompt = JSON.parse(JSON.stringify(workflow)); // Identify nodes in API format let promptNodeId = ''; let samplerNodeId = ''; for (const id in apiPrompt) { const node = apiPrompt[id]; const type = node.class_type; const title = (node._meta?.title || "").toLowerCase(); if (!promptNodeId && type === 'CLIPTextEncode' && (title.includes('positive') || !title.includes('negative'))) promptNodeId = id; if (!samplerNodeId && (type === 'KSampler' || type === 'KSamplerAdvanced')) samplerNodeId = id; } if (promptNodeId) apiPrompt[promptNodeId].inputs.text = promptText; if (samplerNodeId) { if (seed !== -1) apiPrompt[samplerNodeId].inputs.seed = seed; if (steps !== -1) apiPrompt[samplerNodeId].inputs.steps = steps; } } // 2. Determine Endpoint let fetchUrl = `${baseUrl}/prompt`; let fetchHeaders: Record = { 'Content-Type': 'application/json' }; if (useSecureBridge) { fetchUrl = `${window.location.origin}/comfy-bridge/prompt`; fetchHeaders['x-bridge-target'] = baseUrl; } // 3. Send Request const response = await fetch(fetchUrl, { method: 'POST', headers: fetchHeaders, body: JSON.stringify({ prompt: apiPrompt }), signal }).catch(err => { if (err.name === 'AbortError') throw err; throw new Error(`Connection failed: ${err.message}. Ensure your ComfyUI server or Bridge is reachable.`); }); if (!response.ok) { const errText = await response.text(); throw new Error(`Server Error (${response.status}): ${errText.substring(0, 100)}...`); } const { prompt_id } = await response.json(); // 4. Poll const pollUrl = useSecureBridge ? `${window.location.origin}/comfy-bridge/history/${prompt_id}` : `${baseUrl}/history/${prompt_id}`; const pollHeaders: HeadersInit = useSecureBridge ? { 'x-bridge-target': baseUrl } : {}; // Added explicit HeadersInit type for (let i = 0; i < 60; i++) { if (signal?.aborted) throw new Error("Aborted"); const hRes = await fetch(pollUrl, { headers: pollHeaders, signal }); if (hRes.ok) { const history = await hRes.json(); if (history[prompt_id]) { const outputs = history[prompt_id].outputs; for (const nodeId in outputs) { if (outputs[nodeId].images?.length > 0) { const img = outputs[nodeId].images[0]; let finalUrl = useSecureBridge ? `${window.location.origin}/comfy-bridge/view?filename=${img.filename}&subfolder=${img.subfolder}&type=${img.type}&target_base=${encodeURIComponent(baseUrl)}` : `${baseUrl}/view?filename=${img.filename}&subfolder=${img.subfolder}&type=${img.type}`; return finalUrl; } } } } await new Promise(r => setTimeout(r, 3000)); } throw new Error("Preview generation timed out."); };