@hypercli.com/sdk 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +629 -0
- package/dist/acp-driver.d.ts +72 -0
- package/dist/acp-driver.d.ts.map +1 -0
- package/dist/acp-driver.js +147 -0
- package/dist/acp-driver.js.map +1 -0
- package/dist/acp-pool.d.ts +70 -0
- package/dist/acp-pool.d.ts.map +1 -0
- package/dist/acp-pool.js +181 -0
- package/dist/acp-pool.js.map +1 -0
- package/dist/acp.d.ts +179 -0
- package/dist/acp.d.ts.map +1 -0
- package/dist/acp.js +487 -0
- package/dist/acp.js.map +1 -0
- package/dist/agent-persona.d.ts +68 -0
- package/dist/agent-persona.d.ts.map +1 -0
- package/dist/agent-persona.js +78 -0
- package/dist/agent-persona.js.map +1 -0
- package/dist/agent-slots.d.ts +18 -0
- package/dist/agent-slots.d.ts.map +1 -0
- package/dist/agent-slots.js +24 -0
- package/dist/agent-slots.js.map +1 -0
- package/dist/agent-urls.d.ts +47 -0
- package/dist/agent-urls.d.ts.map +1 -0
- package/dist/agent-urls.js +178 -0
- package/dist/agent-urls.js.map +1 -0
- package/dist/agent.d.ts +482 -0
- package/dist/agent.d.ts.map +1 -0
- package/dist/agent.js +926 -0
- package/dist/agent.js.map +1 -0
- package/dist/agents.d.ts +1452 -0
- package/dist/agents.d.ts.map +1 -0
- package/dist/agents.js +4022 -0
- package/dist/agents.js.map +1 -0
- package/dist/billing.d.ts +72 -0
- package/dist/billing.d.ts.map +1 -0
- package/dist/billing.js +93 -0
- package/dist/billing.js.map +1 -0
- package/dist/browser-jobs.d.ts +24 -0
- package/dist/browser-jobs.d.ts.map +1 -0
- package/dist/browser-jobs.js +209 -0
- package/dist/browser-jobs.js.map +1 -0
- package/dist/browser.d.ts +41 -0
- package/dist/browser.d.ts.map +1 -0
- package/dist/browser.js +57 -0
- package/dist/browser.js.map +1 -0
- package/dist/buzz-activity.d.ts +169 -0
- package/dist/buzz-activity.d.ts.map +1 -0
- package/dist/buzz-activity.js +844 -0
- package/dist/buzz-activity.js.map +1 -0
- package/dist/channels.d.ts +99 -0
- package/dist/channels.d.ts.map +1 -0
- package/dist/channels.js +27 -0
- package/dist/channels.js.map +1 -0
- package/dist/chat-integrations.d.ts +44 -0
- package/dist/chat-integrations.d.ts.map +1 -0
- package/dist/chat-integrations.js +138 -0
- package/dist/chat-integrations.js.map +1 -0
- package/dist/client.d.ts +88 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +144 -0
- package/dist/client.js.map +1 -0
- package/dist/config.d.ts +44 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +192 -0
- package/dist/config.js.map +1 -0
- package/dist/connectors.d.ts +80 -0
- package/dist/connectors.d.ts.map +1 -0
- package/dist/connectors.js +2 -0
- package/dist/connectors.js.map +1 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +26 -0
- package/dist/errors.js.map +1 -0
- package/dist/files.d.ts +60 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +128 -0
- package/dist/files.js.map +1 -0
- package/dist/http.d.ts +56 -0
- package/dist/http.d.ts.map +1 -0
- package/dist/http.js +362 -0
- package/dist/http.js.map +1 -0
- package/dist/index.d.ts +49 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +64 -0
- package/dist/index.js.map +1 -0
- package/dist/instances.d.ts +94 -0
- package/dist/instances.d.ts.map +1 -0
- package/dist/instances.js +197 -0
- package/dist/instances.js.map +1 -0
- package/dist/integrations.d.ts +111 -0
- package/dist/integrations.d.ts.map +1 -0
- package/dist/integrations.js +206 -0
- package/dist/integrations.js.map +1 -0
- package/dist/job/base.d.ts +86 -0
- package/dist/job/base.d.ts.map +1 -0
- package/dist/job/base.js +206 -0
- package/dist/job/base.js.map +1 -0
- package/dist/job/comfyui.d.ts +259 -0
- package/dist/job/comfyui.d.ts.map +1 -0
- package/dist/job/comfyui.js +1563 -0
- package/dist/job/comfyui.js.map +1 -0
- package/dist/job/gradio.d.ts +48 -0
- package/dist/job/gradio.d.ts.map +1 -0
- package/dist/job/gradio.js +90 -0
- package/dist/job/gradio.js.map +1 -0
- package/dist/job-codecs.d.ts +66 -0
- package/dist/job-codecs.d.ts.map +1 -0
- package/dist/job-codecs.js +132 -0
- package/dist/job-codecs.js.map +1 -0
- package/dist/jobs.d.ts +148 -0
- package/dist/jobs.d.ts.map +1 -0
- package/dist/jobs.js +480 -0
- package/dist/jobs.js.map +1 -0
- package/dist/keys.d.ts +99 -0
- package/dist/keys.d.ts.map +1 -0
- package/dist/keys.js +145 -0
- package/dist/keys.js.map +1 -0
- package/dist/logs.d.ts +54 -0
- package/dist/logs.d.ts.map +1 -0
- package/dist/logs.js +256 -0
- package/dist/logs.js.map +1 -0
- package/dist/managed-context.d.ts +20 -0
- package/dist/managed-context.d.ts.map +1 -0
- package/dist/managed-context.js +95 -0
- package/dist/managed-context.js.map +1 -0
- package/dist/mcp.d.ts +7 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +8 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory.d.ts +87 -0
- package/dist/memory.d.ts.map +1 -0
- package/dist/memory.js +79 -0
- package/dist/memory.js.map +1 -0
- package/dist/models.d.ts +15 -0
- package/dist/models.d.ts.map +1 -0
- package/dist/models.js +19 -0
- package/dist/models.js.map +1 -0
- package/dist/openclaw-control-ui-origin.d.ts +23 -0
- package/dist/openclaw-control-ui-origin.d.ts.map +1 -0
- package/dist/openclaw-control-ui-origin.js +53 -0
- package/dist/openclaw-control-ui-origin.js.map +1 -0
- package/dist/renders.d.ts +187 -0
- package/dist/renders.d.ts.map +1 -0
- package/dist/renders.js +360 -0
- package/dist/renders.js.map +1 -0
- package/dist/routines.d.ts +59 -0
- package/dist/routines.d.ts.map +1 -0
- package/dist/routines.js +135 -0
- package/dist/routines.js.map +1 -0
- package/dist/runners.d.ts +61 -0
- package/dist/runners.d.ts.map +1 -0
- package/dist/runners.js +127 -0
- package/dist/runners.js.map +1 -0
- package/dist/sessions.d.ts +280 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +292 -0
- package/dist/sessions.js.map +1 -0
- package/dist/skills.d.ts +170 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +2 -0
- package/dist/skills.js.map +1 -0
- package/dist/user.d.ts +78 -0
- package/dist/user.d.ts.map +1 -0
- package/dist/user.js +143 -0
- package/dist/user.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/dist/voice-session.d.ts +66 -0
- package/dist/voice-session.d.ts.map +1 -0
- package/dist/voice-session.js +366 -0
- package/dist/voice-session.js.map +1 -0
- package/dist/voice-transcription-session.d.ts +64 -0
- package/dist/voice-transcription-session.d.ts.map +1 -0
- package/dist/voice-transcription-session.js +228 -0
- package/dist/voice-transcription-session.js.map +1 -0
- package/dist/voice.d.ts +62 -0
- package/dist/voice.d.ts.map +1 -0
- package/dist/voice.js +134 -0
- package/dist/voice.js.map +1 -0
- package/dist/workspace-tomd.d.ts +61 -0
- package/dist/workspace-tomd.d.ts.map +1 -0
- package/dist/workspace-tomd.js +177 -0
- package/dist/workspace-tomd.js.map +1 -0
- package/dist/workspaces.d.ts +255 -0
- package/dist/workspaces.d.ts.map +1 -0
- package/dist/workspaces.js +653 -0
- package/dist/workspaces.js.map +1 -0
- package/package.json +115 -0
- package/runtime/config-require.browser.js +4 -0
- package/runtime/config-require.d.ts +2 -0
- package/runtime/config-require.node.js +8 -0
|
@@ -0,0 +1,1563 @@
|
|
|
1
|
+
import { BaseJob } from './base.js';
|
|
2
|
+
import { COMFYUI_IMAGE } from '../config.js';
|
|
3
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
4
|
+
import { basename, join } from 'path';
|
|
5
|
+
import { requestWithRetry } from '../http.js';
|
|
6
|
+
function sleep(ms) {
|
|
7
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Load a workflow template JSON in graph format (nodes array, links array).
|
|
11
|
+
*
|
|
12
|
+
* The Python SDK resolves IDs via the comfyui-workflow-templates pip package;
|
|
13
|
+
* Node has no equivalent package, so `templateId` must resolve to a JSON file:
|
|
14
|
+
* an explicit path, `<templateId>.json`, or — when `templatesDir` is given —
|
|
15
|
+
* `<templatesDir>/<templateId>.json` or `<templatesDir>/<templateId>/<templateId>.json`.
|
|
16
|
+
*/
|
|
17
|
+
export function loadTemplate(templateId, options = {}) {
|
|
18
|
+
const candidates = [templateId, `${templateId}.json`];
|
|
19
|
+
if (options.templatesDir) {
|
|
20
|
+
candidates.push(join(options.templatesDir, `${templateId}.json`), join(options.templatesDir, templateId, `${templateId}.json`));
|
|
21
|
+
}
|
|
22
|
+
for (const candidate of candidates) {
|
|
23
|
+
if (existsSync(candidate)) {
|
|
24
|
+
return JSON.parse(readFileSync(candidate, 'utf8'));
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error(`Workflow template not found for "${templateId}". ` +
|
|
28
|
+
'Provide a path to the template JSON, name the file <templateId>.json, ' +
|
|
29
|
+
'or pass templatesDir containing <templateId>.json.');
|
|
30
|
+
}
|
|
31
|
+
/** Check whether a widget value matches the expected input type. */
|
|
32
|
+
export function valueMatchesType(value, inputSpec) {
|
|
33
|
+
if (inputSpec === undefined || inputSpec === null) {
|
|
34
|
+
return true;
|
|
35
|
+
}
|
|
36
|
+
// inputSpec is typically [type, config] or just [type]
|
|
37
|
+
if (Array.isArray(inputSpec) && inputSpec.length > 0) {
|
|
38
|
+
const typeInfo = inputSpec[0];
|
|
39
|
+
const config = inputSpec.length > 1 ? inputSpec[1] : {};
|
|
40
|
+
// List of allowed values (enum/combo) - older format
|
|
41
|
+
if (Array.isArray(typeInfo)) {
|
|
42
|
+
// Accept if value is in list, or if value is a string (enum values may differ between versions)
|
|
43
|
+
if (typeInfo.includes(value)) {
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
if (typeof value === 'string' && typeInfo.some((v) => typeof v === 'string')) {
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
// Type string
|
|
52
|
+
if (typeof typeInfo === 'string') {
|
|
53
|
+
// COMBO type - options are in config["options"] (newer ComfyUI format)
|
|
54
|
+
if (typeInfo === 'COMBO') {
|
|
55
|
+
const options = config && typeof config === 'object' && Array.isArray(config.options) ? config.options : [];
|
|
56
|
+
if (options.includes(value)) {
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
// Accept any string for combo - versions may have different allowed values
|
|
60
|
+
if (typeof value === 'string' && options.length > 0 && options.some((v) => typeof v === 'string')) {
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
if (typeInfo === 'INT' || typeInfo === 'FLOAT') {
|
|
66
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
67
|
+
}
|
|
68
|
+
if (typeInfo === 'STRING') {
|
|
69
|
+
return typeof value === 'string';
|
|
70
|
+
}
|
|
71
|
+
if (typeInfo === 'BOOLEAN') {
|
|
72
|
+
return typeof value === 'boolean';
|
|
73
|
+
}
|
|
74
|
+
// Connection types (MODEL, CLIP, VIDEO, etc.) are handled via links, not widgets
|
|
75
|
+
if (/^[A-Z0-9_]+$/.test(typeInfo)) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Expand subgraph/group nodes into their constituent nodes.
|
|
84
|
+
*
|
|
85
|
+
* ComfyUI supports "workflow components" (group nodes) where a subgraph is
|
|
86
|
+
* collapsed into a single node with a UUID type. These must be expanded before
|
|
87
|
+
* the workflow can be executed. Returns a new graph; the input is not modified.
|
|
88
|
+
*/
|
|
89
|
+
export function expandSubgraphs(graph, debug = false) {
|
|
90
|
+
const log = debug ? console.debug.bind(console) : () => undefined;
|
|
91
|
+
// Check whether there are subgraph definitions
|
|
92
|
+
const subgraphs = {};
|
|
93
|
+
for (const sg of graph?.definitions?.subgraphs ?? []) {
|
|
94
|
+
subgraphs[sg.id] = sg;
|
|
95
|
+
}
|
|
96
|
+
if (Object.keys(subgraphs).length === 0) {
|
|
97
|
+
return graph; // No subgraphs, return as-is
|
|
98
|
+
}
|
|
99
|
+
graph = JSON.parse(JSON.stringify(graph));
|
|
100
|
+
const nodes = graph.nodes ?? [];
|
|
101
|
+
const links = graph.links ?? [];
|
|
102
|
+
const newNodes = [];
|
|
103
|
+
const newLinks = [];
|
|
104
|
+
const nodesToRemove = new Set();
|
|
105
|
+
const linksToRemove = new Set();
|
|
106
|
+
let nextNodeId = (graph.last_node_id ?? 0) + 1;
|
|
107
|
+
let nextLinkId = (graph.last_link_id ?? 0) + 1;
|
|
108
|
+
const getLinkFields = (lnk) => {
|
|
109
|
+
if (lnk && typeof lnk === 'object' && !Array.isArray(lnk)) {
|
|
110
|
+
return [lnk.id, lnk.origin_id, lnk.origin_slot ?? 0, lnk.target_id, lnk.target_slot ?? 0, lnk.type ?? ''];
|
|
111
|
+
}
|
|
112
|
+
return [lnk[0], lnk[1], lnk[2], lnk[3], lnk[4], lnk[5] ?? ''];
|
|
113
|
+
};
|
|
114
|
+
for (const node of nodes) {
|
|
115
|
+
const nodeType = String(node?.type ?? '');
|
|
116
|
+
const nodeId = node?.id;
|
|
117
|
+
const mode = node?.mode ?? 0;
|
|
118
|
+
if (!(nodeType in subgraphs)) {
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Skip bypassed/muted group nodes
|
|
122
|
+
if (mode === 2 || mode === 4) {
|
|
123
|
+
log(`Skipping bypassed group node ${nodeId}`);
|
|
124
|
+
nodesToRemove.add(nodeId);
|
|
125
|
+
for (const link of links) {
|
|
126
|
+
if (link[1] === nodeId || link[3] === nodeId) {
|
|
127
|
+
linksToRemove.add(link[0]);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
log(`Expanding group node ${nodeId} -> subgraph ${nodeType.slice(0, 20)}...`);
|
|
133
|
+
const sg = subgraphs[nodeType];
|
|
134
|
+
const sgNodes = sg.nodes ?? [];
|
|
135
|
+
const sgLinks = sg.links ?? [];
|
|
136
|
+
const idMap = {};
|
|
137
|
+
for (const sgNode of sgNodes) {
|
|
138
|
+
idMap[String(sgNode.id)] = nextNodeId;
|
|
139
|
+
nextNodeId += 1;
|
|
140
|
+
}
|
|
141
|
+
// Apply widget values from proxyWidgets
|
|
142
|
+
const proxyWidgets = node?.properties?.proxyWidgets ?? [];
|
|
143
|
+
const widgetValues = node?.widgets_values ?? [];
|
|
144
|
+
const widgetOverrides = {};
|
|
145
|
+
for (let index = 0; index < proxyWidgets.length && index < widgetValues.length; index += 1) {
|
|
146
|
+
const [targetId, widgetName] = proxyWidgets[index];
|
|
147
|
+
const key = String(targetId);
|
|
148
|
+
if (!widgetOverrides[key]) {
|
|
149
|
+
widgetOverrides[key] = {};
|
|
150
|
+
}
|
|
151
|
+
widgetOverrides[key][widgetName] = widgetValues[index];
|
|
152
|
+
}
|
|
153
|
+
for (const sgNode of sgNodes) {
|
|
154
|
+
const oldId = sgNode.id;
|
|
155
|
+
const newNode = JSON.parse(JSON.stringify(sgNode));
|
|
156
|
+
newNode.id = idMap[String(oldId)];
|
|
157
|
+
const overrides = widgetOverrides[String(oldId)];
|
|
158
|
+
if (overrides) {
|
|
159
|
+
const sgWidgets = newNode.widgets_values ?? [];
|
|
160
|
+
if (!Array.isArray(sgWidgets) && typeof sgWidgets === 'object') {
|
|
161
|
+
Object.assign(sgWidgets, overrides);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const nodeTypeInner = newNode.type ?? '';
|
|
165
|
+
if ('text' in overrides && (nodeTypeInner === 'CLIPTextEncode' || nodeTypeInner === 'CLIPTextEncodeFlux')) {
|
|
166
|
+
if (sgWidgets.length) {
|
|
167
|
+
sgWidgets[0] = overrides.text;
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
newNode.widgets_values = [overrides.text];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
newNodes.push(newNode);
|
|
176
|
+
}
|
|
177
|
+
// Copy subgraph links with remapped IDs
|
|
178
|
+
const linkIdMap = {};
|
|
179
|
+
for (const sgLink of sgLinks) {
|
|
180
|
+
const [oldLinkId, fromNode, fromSlot, toNode, toSlot, linkType] = getLinkFields(sgLink);
|
|
181
|
+
const newFrom = idMap[String(fromNode)] ?? fromNode;
|
|
182
|
+
const newTo = idMap[String(toNode)] ?? toNode;
|
|
183
|
+
newLinks.push([nextLinkId, newFrom, fromSlot, newTo, toSlot, linkType]);
|
|
184
|
+
linkIdMap[String(oldLinkId)] = nextLinkId;
|
|
185
|
+
nextLinkId += 1;
|
|
186
|
+
}
|
|
187
|
+
// Update node input link references using the link ID mapping
|
|
188
|
+
for (const newNode of newNodes) {
|
|
189
|
+
for (const input of newNode.inputs ?? []) {
|
|
190
|
+
const oldLink = input.link;
|
|
191
|
+
if (oldLink !== null && oldLink !== undefined && String(oldLink) in linkIdMap) {
|
|
192
|
+
input.link = linkIdMap[String(oldLink)];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
// Rewire external connections: group node inputs -> subgraph input nodes
|
|
197
|
+
const sgInputs = sg.inputs ?? [];
|
|
198
|
+
for (const input of node.inputs ?? []) {
|
|
199
|
+
const linkId = input.link;
|
|
200
|
+
if (linkId === null || linkId === undefined)
|
|
201
|
+
continue;
|
|
202
|
+
const inputName = input.name;
|
|
203
|
+
for (const sgInput of sgInputs) {
|
|
204
|
+
if (sgInput.name === inputName || sgInput.label === input.label) {
|
|
205
|
+
for (const sgLinkId of sgInput.linkIds ?? []) {
|
|
206
|
+
for (const sgLink of sgLinks) {
|
|
207
|
+
const fields = getLinkFields(sgLink);
|
|
208
|
+
if (String(fields[0]) === String(sgLinkId)) {
|
|
209
|
+
const targetNode = idMap[String(fields[3])] ?? fields[3];
|
|
210
|
+
const targetSlot = fields[4];
|
|
211
|
+
for (const link of links) {
|
|
212
|
+
if (link[0] === linkId) {
|
|
213
|
+
link[3] = targetNode;
|
|
214
|
+
link[4] = targetSlot;
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
break;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
// Group node outputs -> subgraph output nodes
|
|
227
|
+
const sgOutputs = sg.outputs ?? [];
|
|
228
|
+
for (const output of node.outputs ?? []) {
|
|
229
|
+
const outLinks = output.links ?? [];
|
|
230
|
+
if (!outLinks.length)
|
|
231
|
+
continue;
|
|
232
|
+
for (const sgOutput of sgOutputs) {
|
|
233
|
+
let done = false;
|
|
234
|
+
for (const sgLinkId of sgOutput.linkIds ?? []) {
|
|
235
|
+
for (const sgLink of sgLinks) {
|
|
236
|
+
const fields = getLinkFields(sgLink);
|
|
237
|
+
if (String(fields[0]) === String(sgLinkId)) {
|
|
238
|
+
const sourceNode = idMap[String(fields[1])] ?? fields[1];
|
|
239
|
+
const sourceSlot = fields[2];
|
|
240
|
+
for (const extLinkId of outLinks) {
|
|
241
|
+
for (const link of links) {
|
|
242
|
+
if (link[0] === extLinkId) {
|
|
243
|
+
link[1] = sourceNode;
|
|
244
|
+
link[2] = sourceSlot;
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
done = true;
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (done)
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
if (done)
|
|
257
|
+
break;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
nodesToRemove.add(nodeId);
|
|
261
|
+
}
|
|
262
|
+
graph.nodes = nodes.filter((node) => !nodesToRemove.has(node.id)).concat(newNodes);
|
|
263
|
+
graph.links = links.filter((link) => !linksToRemove.has(link[0])).concat(newLinks);
|
|
264
|
+
graph.last_node_id = nextNodeId;
|
|
265
|
+
graph.last_link_id = nextLinkId;
|
|
266
|
+
log(`Expanded ${nodesToRemove.size} group nodes, added ${newNodes.length} nodes`);
|
|
267
|
+
return graph;
|
|
268
|
+
}
|
|
269
|
+
// Default object_info for offline workflow conversion (no running instance needed)
|
|
270
|
+
// This covers common node types - extend as needed for new workflows
|
|
271
|
+
export const DEFAULT_OBJECT_INFO = {
|
|
272
|
+
// Text encoders
|
|
273
|
+
CLIPTextEncode: {
|
|
274
|
+
input: { required: { clip: ['CLIP'], text: ['STRING', { multiline: true }] }, optional: {} },
|
|
275
|
+
input_order: { required: ['clip', 'text'], optional: [] },
|
|
276
|
+
},
|
|
277
|
+
CLIPLoader: {
|
|
278
|
+
input: { required: { clip_name: [['model.safetensors'], {}], type: [['stable_diffusion', 'wan'], {}], device: [['default', 'cpu'], {}] }, optional: {} },
|
|
279
|
+
input_order: { required: ['clip_name', 'type', 'device'], optional: [] },
|
|
280
|
+
},
|
|
281
|
+
QuadrupleCLIPLoader: {
|
|
282
|
+
input: { required: { clip_name1: [['clip.safetensors'], {}], clip_name2: [['clip.safetensors'], {}], clip_name3: [['clip.safetensors'], {}], clip_name4: [['clip.safetensors'], {}] }, optional: {} },
|
|
283
|
+
input_order: { required: ['clip_name1', 'clip_name2', 'clip_name3', 'clip_name4'], optional: [] },
|
|
284
|
+
},
|
|
285
|
+
AudioEncoderLoader: {
|
|
286
|
+
input: { required: { audio_encoder_name: [['whisper_large_v3_fp16.safetensors'], {}] }, optional: {} },
|
|
287
|
+
input_order: { required: ['audio_encoder_name'], optional: [] },
|
|
288
|
+
},
|
|
289
|
+
WanHuMoImageToVideo: {
|
|
290
|
+
input: { required: { positive: ['CONDITIONING'], negative: ['CONDITIONING'], vae: ['VAE'], width: ['INT', { default: 640 }], height: ['INT', { default: 640 }], length: ['INT', { default: 97 }], batch_size: ['INT', { default: 1 }] }, optional: { audio_encoder_output: ['AUDIO_ENCODER_OUTPUT'], ref_image: ['IMAGE'] } },
|
|
291
|
+
input_order: { required: ['positive', 'negative', 'vae', 'width', 'height', 'length', 'batch_size'], optional: ['audio_encoder_output', 'ref_image'] },
|
|
292
|
+
},
|
|
293
|
+
TextEncodeQwenImageEditPlus: {
|
|
294
|
+
input: {
|
|
295
|
+
required: { clip: ['CLIP'], vae: ['VAE'], prompt: ['STRING', { multiline: true }] },
|
|
296
|
+
optional: { image1: ['IMAGE'], image2: ['IMAGE'], image3: ['IMAGE'] },
|
|
297
|
+
},
|
|
298
|
+
input_order: { required: ['clip', 'vae', 'prompt'], optional: ['image1', 'image2', 'image3'] },
|
|
299
|
+
},
|
|
300
|
+
ModelSamplingAuraFlow: {
|
|
301
|
+
input: { required: { model: ['MODEL'], shift: ['FLOAT', { default: 1.73 }] }, optional: {} },
|
|
302
|
+
input_order: { required: ['model', 'shift'], optional: [] },
|
|
303
|
+
},
|
|
304
|
+
CFGNorm: {
|
|
305
|
+
input: { required: { model: ['MODEL'], strength: ['FLOAT', { default: 1.0 }] }, optional: {} },
|
|
306
|
+
input_order: { required: ['model', 'strength'], optional: [] },
|
|
307
|
+
},
|
|
308
|
+
FluxKontextImageScale: {
|
|
309
|
+
input: { required: { image: ['IMAGE'], max_pixels: ['INT', { default: 1048576 }] }, optional: {} },
|
|
310
|
+
input_order: { required: ['image', 'max_pixels'], optional: [] },
|
|
311
|
+
},
|
|
312
|
+
ReferenceLatent: {
|
|
313
|
+
input: { required: { conditioning: ['CONDITIONING'], latent: ['LATENT'] }, optional: {} },
|
|
314
|
+
input_order: { required: ['conditioning', 'latent'], optional: [] },
|
|
315
|
+
},
|
|
316
|
+
KSamplerAdvanced: {
|
|
317
|
+
input: {
|
|
318
|
+
required: {
|
|
319
|
+
model: ['MODEL'], positive: ['CONDITIONING'], negative: ['CONDITIONING'], latent_image: ['LATENT'],
|
|
320
|
+
add_noise: [['enable', 'disable'], {}], noise_seed: ['INT', { default: 0 }],
|
|
321
|
+
steps: ['INT', { default: 20 }], cfg: ['FLOAT', { default: 8.0 }],
|
|
322
|
+
sampler_name: [['euler', 'euler_ancestral'], {}], scheduler: [['normal', 'simple'], {}],
|
|
323
|
+
start_at_step: ['INT', { default: 0 }], end_at_step: ['INT', { default: 10000 }],
|
|
324
|
+
return_with_leftover_noise: [['disable', 'enable'], {}],
|
|
325
|
+
},
|
|
326
|
+
optional: {},
|
|
327
|
+
},
|
|
328
|
+
input_order: {
|
|
329
|
+
required: ['model', 'positive', 'negative', 'latent_image', 'add_noise', 'noise_seed', 'steps', 'cfg', 'sampler_name', 'scheduler', 'start_at_step', 'end_at_step', 'return_with_leftover_noise'],
|
|
330
|
+
optional: [],
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
EmptySD3LatentImage: {
|
|
334
|
+
input: { required: { width: ['INT', {}], height: ['INT', {}], batch_size: ['INT', {}] }, optional: {} },
|
|
335
|
+
input_order: { required: ['width', 'height', 'batch_size'], optional: [] },
|
|
336
|
+
},
|
|
337
|
+
EmptyHunyuanLatentVideo: {
|
|
338
|
+
input: { required: { width: ['INT', {}], height: ['INT', {}], length: ['INT', {}], batch_size: ['INT', {}] }, optional: {} },
|
|
339
|
+
input_order: { required: ['width', 'height', 'length', 'batch_size'], optional: [] },
|
|
340
|
+
},
|
|
341
|
+
WanImageToVideo: {
|
|
342
|
+
input: { required: { width: ['INT', {}], height: ['INT', {}], length: ['INT', {}], batch_size: ['INT', {}] }, optional: {} },
|
|
343
|
+
input_order: { required: ['width', 'height', 'length', 'batch_size'], optional: [] },
|
|
344
|
+
},
|
|
345
|
+
WanStartEndFrames: {
|
|
346
|
+
input: { required: { width: ['INT', {}], height: ['INT', {}], length: ['INT', {}], batch_size: ['INT', {}] }, optional: {} },
|
|
347
|
+
input_order: { required: ['width', 'height', 'length', 'batch_size'], optional: [] },
|
|
348
|
+
},
|
|
349
|
+
WanFirstLastFrameToVideo: {
|
|
350
|
+
input: { required: { width: ['INT', {}], height: ['INT', {}], length: ['INT', {}], batch_size: ['INT', {}], positive: ['CONDITIONING'], negative: ['CONDITIONING'], vae: ['VAE'], start_image: ['IMAGE'], end_image: ['IMAGE'] }, optional: {} },
|
|
351
|
+
input_order: { required: ['width', 'height', 'length', 'batch_size', 'positive', 'negative', 'vae', 'start_image', 'end_image'], optional: [] },
|
|
352
|
+
},
|
|
353
|
+
// Samplers
|
|
354
|
+
KSampler: {
|
|
355
|
+
input: {
|
|
356
|
+
required: {
|
|
357
|
+
model: ['MODEL'], positive: ['CONDITIONING'], negative: ['CONDITIONING'], latent_image: ['LATENT'],
|
|
358
|
+
seed: ['INT', { default: 0 }], steps: ['INT', { default: 20 }], cfg: ['FLOAT', { default: 8.0 }],
|
|
359
|
+
sampler_name: [['euler', 'euler_ancestral', 'dpm_2'], {}], scheduler: [['normal', 'karras', 'simple'], {}],
|
|
360
|
+
denoise: ['FLOAT', { default: 1.0 }],
|
|
361
|
+
},
|
|
362
|
+
optional: {},
|
|
363
|
+
},
|
|
364
|
+
input_order: { required: ['model', 'positive', 'negative', 'latent_image', 'seed', 'steps', 'cfg', 'sampler_name', 'scheduler', 'denoise'], optional: [] },
|
|
365
|
+
},
|
|
366
|
+
// Latent generators
|
|
367
|
+
EmptyLatentImage: {
|
|
368
|
+
input: { required: { width: ['INT', {}], height: ['INT', {}], batch_size: ['INT', {}] }, optional: {} },
|
|
369
|
+
input_order: { required: ['width', 'height', 'batch_size'], optional: [] },
|
|
370
|
+
},
|
|
371
|
+
// Model loaders
|
|
372
|
+
UNETLoader: {
|
|
373
|
+
input: { required: { unet_name: [['model.safetensors'], {}], weight_dtype: [['default', 'fp8_e4m3fn'], {}] }, optional: {} },
|
|
374
|
+
input_order: { required: ['unet_name', 'weight_dtype'], optional: [] },
|
|
375
|
+
},
|
|
376
|
+
VAELoader: {
|
|
377
|
+
input: { required: { vae_name: [['vae.safetensors'], {}] }, optional: {} },
|
|
378
|
+
input_order: { required: ['vae_name'], optional: [] },
|
|
379
|
+
},
|
|
380
|
+
CheckpointLoaderSimple: {
|
|
381
|
+
input: { required: { ckpt_name: [['model.safetensors'], {}] }, optional: {} },
|
|
382
|
+
input_order: { required: ['ckpt_name'], optional: [] },
|
|
383
|
+
},
|
|
384
|
+
// Video/Image processing
|
|
385
|
+
VAEDecode: {
|
|
386
|
+
input: { required: { samples: ['LATENT'], vae: ['VAE'] }, optional: {} },
|
|
387
|
+
input_order: { required: ['samples', 'vae'], optional: [] },
|
|
388
|
+
},
|
|
389
|
+
// Save nodes
|
|
390
|
+
SaveImage: {
|
|
391
|
+
input: { required: { images: ['IMAGE'], filename_prefix: ['STRING', {}] }, optional: {} },
|
|
392
|
+
input_order: { required: ['images', 'filename_prefix'], optional: [] },
|
|
393
|
+
},
|
|
394
|
+
// Input loaders
|
|
395
|
+
LoadImage: {
|
|
396
|
+
input: { required: { image: ['STRING', {}] }, optional: {} },
|
|
397
|
+
input_order: { required: ['image'], optional: [] },
|
|
398
|
+
},
|
|
399
|
+
LoadAudio: {
|
|
400
|
+
input: { required: { audio: ['STRING', {}] }, optional: {} },
|
|
401
|
+
input_order: { required: ['audio'], optional: [] },
|
|
402
|
+
},
|
|
403
|
+
LoraLoaderModelOnly: {
|
|
404
|
+
input: { required: { model: ['MODEL'], lora_name: [['lora.safetensors'], {}], strength_model: ['FLOAT', {}] }, optional: {} },
|
|
405
|
+
input_order: { required: ['model', 'lora_name', 'strength_model'], optional: [] },
|
|
406
|
+
},
|
|
407
|
+
ModelSamplingSD3: {
|
|
408
|
+
input: { required: { model: ['MODEL'], shift: ['FLOAT', {}] }, optional: {} },
|
|
409
|
+
input_order: { required: ['model', 'shift'], optional: [] },
|
|
410
|
+
},
|
|
411
|
+
// Video/Image processing
|
|
412
|
+
VAEEncode: {
|
|
413
|
+
input: { required: { pixels: ['IMAGE'], vae: ['VAE'] }, optional: {} },
|
|
414
|
+
input_order: { required: ['pixels', 'vae'], optional: [] },
|
|
415
|
+
},
|
|
416
|
+
CreateVideo: {
|
|
417
|
+
input: { required: { images: ['IMAGE'], fps: ['FLOAT', { default: 16 }] }, optional: { audio: ['AUDIO'] } },
|
|
418
|
+
input_order: { required: ['images', 'fps'], optional: ['audio'] },
|
|
419
|
+
},
|
|
420
|
+
SaveVideo: {
|
|
421
|
+
input: {
|
|
422
|
+
required: {
|
|
423
|
+
video: ['VIDEO'],
|
|
424
|
+
filename_prefix: ['STRING', { default: 'video/ComfyUI' }],
|
|
425
|
+
format: ['COMBO', { default: 'auto', options: ['auto', 'mp4'] }],
|
|
426
|
+
codec: ['COMBO', { default: 'auto', options: ['auto', 'h264'] }],
|
|
427
|
+
},
|
|
428
|
+
optional: {},
|
|
429
|
+
},
|
|
430
|
+
input_order: { required: ['video', 'filename_prefix', 'format', 'codec'], optional: [] },
|
|
431
|
+
},
|
|
432
|
+
SaveAnimatedWEBP: {
|
|
433
|
+
input: { required: { images: ['IMAGE'], filename_prefix: ['STRING', {}], fps: ['FLOAT', {}], lossless: ['BOOLEAN', {}], quality: ['INT', {}], method: [['default'], {}] }, optional: {} },
|
|
434
|
+
input_order: { required: ['images', 'filename_prefix', 'fps', 'lossless', 'quality', 'method'], optional: [] },
|
|
435
|
+
},
|
|
436
|
+
};
|
|
437
|
+
/**
|
|
438
|
+
* Find nodes in API-format workflow by class_type and optional title pattern
|
|
439
|
+
*/
|
|
440
|
+
export function findNodes(workflow, classType, titleContains) {
|
|
441
|
+
const results = [];
|
|
442
|
+
for (const [nodeId, node] of Object.entries(workflow)) {
|
|
443
|
+
if (node.class_type === classType) {
|
|
444
|
+
if (!titleContains) {
|
|
445
|
+
results.push([nodeId, node]);
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
const title = node._meta?.title || '';
|
|
449
|
+
if (title.toLowerCase().includes(titleContains.toLowerCase())) {
|
|
450
|
+
results.push([nodeId, node]);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
return results;
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Find first node matching class_type and optional title pattern
|
|
459
|
+
*/
|
|
460
|
+
export function findNode(workflow, classType, titleContains) {
|
|
461
|
+
const nodes = findNodes(workflow, classType, titleContains);
|
|
462
|
+
return nodes.length > 0 ? nodes[0] : [null, null];
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Apply parameters to workflow nodes
|
|
466
|
+
*/
|
|
467
|
+
export function applyParams(workflow, params) {
|
|
468
|
+
const clipTypes = ['CLIPTextEncode', 'CLIPTextEncodeFlux', 'CLIPTextEncodeSD3', 'TextEncodeQwenImageEditPlus'];
|
|
469
|
+
// Helper to find first matching node from a list of types
|
|
470
|
+
const findFirst = (types, title) => {
|
|
471
|
+
for (const t of types) {
|
|
472
|
+
const result = findNode(workflow, t, title);
|
|
473
|
+
if (result[0])
|
|
474
|
+
return result;
|
|
475
|
+
}
|
|
476
|
+
return [null, null];
|
|
477
|
+
};
|
|
478
|
+
// Positive prompt
|
|
479
|
+
if (params.prompt) {
|
|
480
|
+
let [_nodeId, node] = findNode(workflow, 'TextEncodeQwenImageEditPlus', 'Positive');
|
|
481
|
+
if (node) {
|
|
482
|
+
node.inputs.prompt = params.prompt;
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
[_nodeId, node] = findFirst(clipTypes, 'Positive');
|
|
486
|
+
if (!node) {
|
|
487
|
+
for (const t of clipTypes) {
|
|
488
|
+
const nodes = findNodes(workflow, t);
|
|
489
|
+
if (nodes.length > 0) {
|
|
490
|
+
[_nodeId, node] = nodes[0];
|
|
491
|
+
break;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
if (node) {
|
|
496
|
+
node.inputs.text = params.prompt;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
// Negative prompt
|
|
501
|
+
if (params.negative) {
|
|
502
|
+
let [_nodeId, node] = findNode(workflow, 'TextEncodeQwenImageEditPlus', 'Negative');
|
|
503
|
+
if (node) {
|
|
504
|
+
node.inputs.prompt = params.negative;
|
|
505
|
+
}
|
|
506
|
+
else {
|
|
507
|
+
[_nodeId, node] = findFirst(clipTypes, 'Negative');
|
|
508
|
+
if (node) {
|
|
509
|
+
node.inputs.text = params.negative;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// Width/Height/Length
|
|
514
|
+
if (params.width || params.height || params.length) {
|
|
515
|
+
const latentTypes = [
|
|
516
|
+
'EmptySD3LatentImage', 'EmptyFlux2LatentImage', 'EmptyLatentImage',
|
|
517
|
+
'EmptyHunyuanLatentVideo', 'EmptyMochiLatentVideo', 'EmptyLTXVLatentVideo',
|
|
518
|
+
'WanImageToVideo', 'WanStartEndFrames', 'WanFirstLastFrameToVideo', 'WanHuMoImageToVideo',
|
|
519
|
+
];
|
|
520
|
+
const [_nodeId, node] = findFirst(latentTypes);
|
|
521
|
+
if (node) {
|
|
522
|
+
if (params.width)
|
|
523
|
+
node.inputs.width = params.width;
|
|
524
|
+
if (params.height)
|
|
525
|
+
node.inputs.height = params.height;
|
|
526
|
+
if (params.length)
|
|
527
|
+
node.inputs.length = params.length;
|
|
528
|
+
if (params.batch_size)
|
|
529
|
+
node.inputs.batch_size = params.batch_size;
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
// Try PrimitiveNode with "width"/"height" title (Flux2 style)
|
|
533
|
+
if (params.width) {
|
|
534
|
+
const [_wid, widthNode] = findNode(workflow, 'PrimitiveNode', 'width');
|
|
535
|
+
if (widthNode && 'value' in (widthNode.inputs ?? {})) {
|
|
536
|
+
widthNode.inputs.value = params.width;
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
if (params.height) {
|
|
540
|
+
const [_hid, heightNode] = findNode(workflow, 'PrimitiveNode', 'height');
|
|
541
|
+
if (heightNode && 'value' in (heightNode.inputs ?? {})) {
|
|
542
|
+
heightNode.inputs.value = params.height;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Seed
|
|
548
|
+
if (params.seed !== undefined) {
|
|
549
|
+
const [_nodeId, node] = findNode(workflow, 'KSampler');
|
|
550
|
+
if (node) {
|
|
551
|
+
node.inputs.seed = params.seed;
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
const advancedNodes = findNodes(workflow, 'KSamplerAdvanced');
|
|
555
|
+
let targetNode = null;
|
|
556
|
+
for (const [_nid, n] of advancedNodes) {
|
|
557
|
+
if (n.inputs.add_noise === 'enable') {
|
|
558
|
+
targetNode = n;
|
|
559
|
+
break;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (!targetNode && advancedNodes.length > 0) {
|
|
563
|
+
targetNode = advancedNodes[0][1];
|
|
564
|
+
}
|
|
565
|
+
if (targetNode) {
|
|
566
|
+
targetNode.inputs.noise_seed = params.seed;
|
|
567
|
+
}
|
|
568
|
+
else {
|
|
569
|
+
// Try other sampler types
|
|
570
|
+
const [_rnid, randomNoiseNode] = findNode(workflow, 'RandomNoise');
|
|
571
|
+
if (randomNoiseNode) {
|
|
572
|
+
randomNoiseNode.inputs.noise_seed = params.seed;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Steps
|
|
578
|
+
if (params.steps !== undefined) {
|
|
579
|
+
const [_nodeId, node] = findFirst(['KSampler', 'KSamplerAdvanced', 'SamplerCustom', 'SamplerCustomAdvanced']);
|
|
580
|
+
if (node) {
|
|
581
|
+
node.inputs.steps = params.steps;
|
|
582
|
+
}
|
|
583
|
+
else {
|
|
584
|
+
const [_fsid, fluxScheduler] = findNode(workflow, 'Flux2Scheduler');
|
|
585
|
+
if (fluxScheduler) {
|
|
586
|
+
fluxScheduler.inputs.steps = params.steps;
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
// CFG
|
|
591
|
+
if (params.cfg !== undefined) {
|
|
592
|
+
const [_nodeId, node] = findFirst(['KSampler', 'KSamplerAdvanced', 'SamplerCustom', 'SamplerCustomAdvanced']);
|
|
593
|
+
if (node) {
|
|
594
|
+
node.inputs.cfg = params.cfg;
|
|
595
|
+
}
|
|
596
|
+
else {
|
|
597
|
+
const [_fgid, fluxGuidance] = findNode(workflow, 'FluxGuidance');
|
|
598
|
+
if (fluxGuidance) {
|
|
599
|
+
fluxGuidance.inputs.guidance = params.cfg;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// Filename prefix
|
|
604
|
+
if (params.filename_prefix) {
|
|
605
|
+
const [_nodeId, node] = findFirst(['SaveImage', 'SaveVideo', 'SaveAnimatedWEBP', 'SaveAnimatedPNG']);
|
|
606
|
+
if (node) {
|
|
607
|
+
node.inputs.filename_prefix = params.filename_prefix;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
// Node-specific params
|
|
611
|
+
if (params.nodes) {
|
|
612
|
+
for (const [nodeId, values] of Object.entries(params.nodes)) {
|
|
613
|
+
const node = workflow[nodeId];
|
|
614
|
+
if (!node)
|
|
615
|
+
continue;
|
|
616
|
+
const nodeType = node.class_type || '';
|
|
617
|
+
for (const [key, value] of Object.entries(values)) {
|
|
618
|
+
if (key === 'image' && nodeType === 'LoadImage') {
|
|
619
|
+
node.inputs.image = value;
|
|
620
|
+
}
|
|
621
|
+
else if (key === 'audio' && nodeType === 'LoadAudio') {
|
|
622
|
+
node.inputs.audio = value;
|
|
623
|
+
}
|
|
624
|
+
else if (key === 'text' && nodeType.includes('Text')) {
|
|
625
|
+
node.inputs.text = value;
|
|
626
|
+
}
|
|
627
|
+
else {
|
|
628
|
+
node.inputs[key] = value;
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return workflow;
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Convert ComfyUI graph format (from UI) to API format (for /prompt endpoint).
|
|
637
|
+
*/
|
|
638
|
+
export function graphToApi(graph, objectInfo, debug = false) {
|
|
639
|
+
const log = debug ? console.debug.bind(console) : () => undefined;
|
|
640
|
+
// First, expand any subgraphs/group nodes
|
|
641
|
+
graph = expandSubgraphs(graph, debug);
|
|
642
|
+
if (!objectInfo) {
|
|
643
|
+
objectInfo = DEFAULT_OBJECT_INFO;
|
|
644
|
+
}
|
|
645
|
+
const api = {};
|
|
646
|
+
const nodesById = {};
|
|
647
|
+
const links = {};
|
|
648
|
+
// Build lookups
|
|
649
|
+
for (const node of graph.nodes || []) {
|
|
650
|
+
nodesById[node.id] = node;
|
|
651
|
+
}
|
|
652
|
+
for (const link of graph.links || []) {
|
|
653
|
+
const [linkId, fromNode, fromSlot] = link;
|
|
654
|
+
links[linkId] = [fromNode, fromSlot];
|
|
655
|
+
}
|
|
656
|
+
const isSkippedNode = (node) => {
|
|
657
|
+
if (!node)
|
|
658
|
+
return true;
|
|
659
|
+
const classType = node.type;
|
|
660
|
+
if (!classType || ['Note', 'Reroute', 'MarkdownNote'].includes(classType)) {
|
|
661
|
+
return true;
|
|
662
|
+
}
|
|
663
|
+
// mode 2 = muted, mode 4 = bypassed
|
|
664
|
+
if ([2, 4].includes(node.mode ?? 0)) {
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
return false;
|
|
668
|
+
};
|
|
669
|
+
// Follow a link through skipped nodes (reroutes, bypassed) to the real source.
|
|
670
|
+
const resolveLink = (linkId, visited = new Set()) => {
|
|
671
|
+
const key = String(linkId);
|
|
672
|
+
if (visited.has(key))
|
|
673
|
+
return null; // Cycle detection
|
|
674
|
+
visited.add(key);
|
|
675
|
+
if (!(key in links))
|
|
676
|
+
return null;
|
|
677
|
+
const [fromNodeId, fromSlot] = links[key];
|
|
678
|
+
const fromNode = nodesById[fromNodeId];
|
|
679
|
+
if (!isSkippedNode(fromNode)) {
|
|
680
|
+
return [fromNodeId, fromSlot];
|
|
681
|
+
}
|
|
682
|
+
// Node is skipped - follow through its input; output slot 0 passes through input slot 0
|
|
683
|
+
if (fromSlot === 0 && fromNode) {
|
|
684
|
+
const nodeInputs = fromNode.inputs ?? [];
|
|
685
|
+
if (nodeInputs.length) {
|
|
686
|
+
const upstreamLink = nodeInputs[0].link;
|
|
687
|
+
if (upstreamLink !== null && upstreamLink !== undefined) {
|
|
688
|
+
return resolveLink(upstreamLink, visited);
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return null;
|
|
693
|
+
};
|
|
694
|
+
// Convert nodes
|
|
695
|
+
for (const node of graph.nodes || []) {
|
|
696
|
+
const nodeId = String(node.id);
|
|
697
|
+
const classType = node.type;
|
|
698
|
+
if (!classType || ['Note', 'Reroute', 'MarkdownNote'].includes(classType)) {
|
|
699
|
+
continue;
|
|
700
|
+
}
|
|
701
|
+
if ([2, 4].includes(node.mode ?? 0)) {
|
|
702
|
+
continue; // Skip muted/bypassed
|
|
703
|
+
}
|
|
704
|
+
const info = objectInfo[classType] || {};
|
|
705
|
+
const inputs = {};
|
|
706
|
+
// Get input specs from schema
|
|
707
|
+
const inputSpecs = {};
|
|
708
|
+
for (const section of ['required', 'optional']) {
|
|
709
|
+
for (const [name, spec] of Object.entries(info.input?.[section] ?? {})) {
|
|
710
|
+
inputSpecs[name] = spec;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
// Get input order from schema; fall back to input spec keys for older ComfyUI
|
|
714
|
+
const inputOrder = [...(info.input_order?.required || []), ...(info.input_order?.optional || [])];
|
|
715
|
+
if (inputOrder.length === 0 && Object.keys(inputSpecs).length > 0) {
|
|
716
|
+
inputOrder.push(...Object.keys(info.input?.required ?? {}), ...Object.keys(info.input?.optional ?? {}));
|
|
717
|
+
}
|
|
718
|
+
// Map connections
|
|
719
|
+
const connectedInputs = new Set();
|
|
720
|
+
for (const inp of node.inputs || []) {
|
|
721
|
+
const linkId = inp.link;
|
|
722
|
+
if (linkId !== null && linkId !== undefined) {
|
|
723
|
+
const resolved = resolveLink(linkId);
|
|
724
|
+
if (resolved) {
|
|
725
|
+
const [fromNode, fromSlot] = resolved;
|
|
726
|
+
inputs[inp.name] = [String(fromNode), fromSlot];
|
|
727
|
+
connectedInputs.add(inp.name);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
// Map widget values to unconnected inputs with type validation
|
|
732
|
+
const widgets = node.widgets_values ?? [];
|
|
733
|
+
if (!Array.isArray(widgets) && widgets && typeof widgets === 'object') {
|
|
734
|
+
// Some nodes use dict format for widgets
|
|
735
|
+
for (const [name, value] of Object.entries(widgets)) {
|
|
736
|
+
if (!connectedInputs.has(name)) {
|
|
737
|
+
inputs[name] = value;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
else {
|
|
742
|
+
// List format - map positionally to input names, skipping UI-only widgets
|
|
743
|
+
let wIdx = 0;
|
|
744
|
+
for (const name of inputOrder) {
|
|
745
|
+
if (connectedInputs.has(name))
|
|
746
|
+
continue;
|
|
747
|
+
const inputSpec = inputSpecs[name];
|
|
748
|
+
while (wIdx < widgets.length) {
|
|
749
|
+
const value = widgets[wIdx];
|
|
750
|
+
wIdx += 1;
|
|
751
|
+
if (valueMatchesType(value, inputSpec)) {
|
|
752
|
+
inputs[name] = value;
|
|
753
|
+
break;
|
|
754
|
+
}
|
|
755
|
+
// Skip UI-only widgets (e.g., 'randomize', 'fixed', etc.)
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
log(`node ${nodeId} (${classType}) inputs: ${JSON.stringify(inputs)}`);
|
|
760
|
+
api[nodeId] = {
|
|
761
|
+
class_type: classType,
|
|
762
|
+
inputs,
|
|
763
|
+
_meta: { title: node.title || classType },
|
|
764
|
+
};
|
|
765
|
+
}
|
|
766
|
+
return api;
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Apply graph modes (enable/disable nodes)
|
|
770
|
+
*/
|
|
771
|
+
export function applyGraphModes(graph, nodesConfig) {
|
|
772
|
+
const nodesById = {};
|
|
773
|
+
for (const node of graph.nodes || []) {
|
|
774
|
+
nodesById[String(node.id)] = node;
|
|
775
|
+
}
|
|
776
|
+
for (const [nodeId, config] of Object.entries(nodesConfig)) {
|
|
777
|
+
const node = nodesById[nodeId];
|
|
778
|
+
if (!node)
|
|
779
|
+
continue;
|
|
780
|
+
if ('mode' in config) {
|
|
781
|
+
node.mode = config.mode;
|
|
782
|
+
}
|
|
783
|
+
else if ('enabled' in config) {
|
|
784
|
+
node.mode = config.enabled ? 0 : 4;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
return graph;
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* ComfyUI-specific job with workflow execution helpers
|
|
791
|
+
*/
|
|
792
|
+
export class ComfyUIJob extends BaseJob {
|
|
793
|
+
static DEFAULT_IMAGE = COMFYUI_IMAGE;
|
|
794
|
+
static DEFAULT_GPU_TYPE = 'l40s';
|
|
795
|
+
static HEALTH_ENDPOINT = '/system_stats';
|
|
796
|
+
static COMFYUI_PORT = 8188;
|
|
797
|
+
_objectInfo = null;
|
|
798
|
+
_jobToken = null;
|
|
799
|
+
_useLb;
|
|
800
|
+
useAuth;
|
|
801
|
+
template = null;
|
|
802
|
+
constructor(client, job, template, useLb = false, useAuth = false) {
|
|
803
|
+
super(client, job);
|
|
804
|
+
this.template = template || null;
|
|
805
|
+
this._useLb = useLb;
|
|
806
|
+
this.useAuth = useAuth;
|
|
807
|
+
}
|
|
808
|
+
get useLb() {
|
|
809
|
+
return this._useLb;
|
|
810
|
+
}
|
|
811
|
+
set useLb(value) {
|
|
812
|
+
this._useLb = value;
|
|
813
|
+
this._baseUrl = null;
|
|
814
|
+
}
|
|
815
|
+
get baseUrl() {
|
|
816
|
+
if (!this._baseUrl && this.hostname) {
|
|
817
|
+
if (this._useLb) {
|
|
818
|
+
this._baseUrl = `https://${this.hostname}`;
|
|
819
|
+
}
|
|
820
|
+
else {
|
|
821
|
+
this._baseUrl = `http://${this.hostname}:${ComfyUIJob.COMFYUI_PORT}`;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return this._baseUrl || '';
|
|
825
|
+
}
|
|
826
|
+
get authHeaders() {
|
|
827
|
+
if (this.useAuth) {
|
|
828
|
+
if (!this._jobToken) {
|
|
829
|
+
throw new Error('Job token not loaded. Call await job.jobToken() first.');
|
|
830
|
+
}
|
|
831
|
+
return { 'Authorization': `Bearer ${this._jobToken}` };
|
|
832
|
+
}
|
|
833
|
+
return { 'Authorization': `Bearer ${this.client.apiKey}` };
|
|
834
|
+
}
|
|
835
|
+
async jobToken() {
|
|
836
|
+
if (!this._jobToken) {
|
|
837
|
+
this._jobToken = await this.client.jobs.token(this.jobId);
|
|
838
|
+
}
|
|
839
|
+
return this._jobToken;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* Create a new ComfyUI job configured for a specific template
|
|
843
|
+
*/
|
|
844
|
+
static async createForTemplate(client, template, options = {}) {
|
|
845
|
+
const { gpuType, gpuCount = 1, runtime = 3600, lb, auth = false, ...kwargs } = options;
|
|
846
|
+
const env = kwargs.env || {};
|
|
847
|
+
env.COMFYUI_TEMPLATES = template;
|
|
848
|
+
const ports = {};
|
|
849
|
+
if (lb) {
|
|
850
|
+
ports.lb = lb;
|
|
851
|
+
}
|
|
852
|
+
else {
|
|
853
|
+
ports[String(ComfyUIJob.COMFYUI_PORT)] = ComfyUIJob.COMFYUI_PORT;
|
|
854
|
+
}
|
|
855
|
+
const job = await client.jobs.create({
|
|
856
|
+
image: ComfyUIJob.DEFAULT_IMAGE,
|
|
857
|
+
gpuType: gpuType || ComfyUIJob.DEFAULT_GPU_TYPE,
|
|
858
|
+
gpuCount,
|
|
859
|
+
runtime,
|
|
860
|
+
env,
|
|
861
|
+
ports,
|
|
862
|
+
auth,
|
|
863
|
+
...kwargs,
|
|
864
|
+
});
|
|
865
|
+
return new ComfyUIJob(client, job, template, Boolean(lb), auth);
|
|
866
|
+
}
|
|
867
|
+
/**
|
|
868
|
+
* Get object_info from ComfyUI (cached)
|
|
869
|
+
*/
|
|
870
|
+
async getObjectInfo(refresh = false) {
|
|
871
|
+
if (this._objectInfo === null || refresh) {
|
|
872
|
+
const response = await fetch(`${this.baseUrl}/object_info`, {
|
|
873
|
+
headers: await this.resolvedAuthHeaders(),
|
|
874
|
+
});
|
|
875
|
+
if (!response.ok) {
|
|
876
|
+
throw new Error(`Failed to get object_info: ${response.statusText}`);
|
|
877
|
+
}
|
|
878
|
+
this._objectInfo = (await response.json());
|
|
879
|
+
}
|
|
880
|
+
return this._objectInfo;
|
|
881
|
+
}
|
|
882
|
+
/**
|
|
883
|
+
* Convert graph format workflow to API format
|
|
884
|
+
*/
|
|
885
|
+
async convertWorkflow(graph, debug = false) {
|
|
886
|
+
const objectInfo = await this.getObjectInfo();
|
|
887
|
+
return graphToApi(graph, objectInfo, debug);
|
|
888
|
+
}
|
|
889
|
+
/**
|
|
890
|
+
* Connect to a specific ComfyUI instance by job ID or hostname.
|
|
891
|
+
*/
|
|
892
|
+
static async getInstance(client, instance, options = {}) {
|
|
893
|
+
let job = null;
|
|
894
|
+
// Check if it looks like a UUID (job ID)
|
|
895
|
+
if (instance.includes('-') && instance.length > 30) {
|
|
896
|
+
job = await client.jobs.get(instance);
|
|
897
|
+
}
|
|
898
|
+
else {
|
|
899
|
+
// Assume hostname - search running jobs
|
|
900
|
+
const jobs = await client.jobs.list('running');
|
|
901
|
+
for (const candidate of jobs) {
|
|
902
|
+
if (candidate.hostname && (candidate.hostname === instance || candidate.hostname.startsWith(instance))) {
|
|
903
|
+
job = candidate;
|
|
904
|
+
break;
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
if (!job) {
|
|
908
|
+
throw new Error(`No running job found with hostname: ${instance}`);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
return new ComfyUIJob(client, job, undefined, Boolean(options.useLb), Boolean(options.useAuth));
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Get existing running job or create new one for a template.
|
|
915
|
+
*
|
|
916
|
+
* If reuse is true and a ComfyUI job is already running, it will be reused
|
|
917
|
+
* (note: the existing job may have different models loaded).
|
|
918
|
+
*/
|
|
919
|
+
static async getOrCreateForTemplate(client, template, options = {}) {
|
|
920
|
+
const { reuse = true, gpuType, gpuCount, runtime, lb, auth = false, ...kwargs } = options;
|
|
921
|
+
if (reuse) {
|
|
922
|
+
const existing = await ComfyUIJob.getRunning(client, ComfyUIJob.DEFAULT_IMAGE);
|
|
923
|
+
if (existing) {
|
|
924
|
+
existing.template = template;
|
|
925
|
+
existing.useLb = Boolean(lb);
|
|
926
|
+
existing.useAuth = auth;
|
|
927
|
+
return existing;
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
return ComfyUIJob.createForTemplate(client, template, {
|
|
931
|
+
gpuType,
|
|
932
|
+
gpuCount,
|
|
933
|
+
runtime,
|
|
934
|
+
lb,
|
|
935
|
+
auth,
|
|
936
|
+
...kwargs,
|
|
937
|
+
});
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Load workflow template JSON in graph format.
|
|
941
|
+
*
|
|
942
|
+
* Unlike the Python SDK, Node has no comfyui-workflow-templates package, so
|
|
943
|
+
* the ID must resolve to a JSON file (see the module-level loadTemplate).
|
|
944
|
+
*/
|
|
945
|
+
loadTemplate(templateId, options = {}) {
|
|
946
|
+
return loadTemplate(templateId, options);
|
|
947
|
+
}
|
|
948
|
+
// =========================================================================
|
|
949
|
+
// ComfyUI Manager - Custom Node Installation
|
|
950
|
+
// =========================================================================
|
|
951
|
+
async comfyJson(method, path, options = {}) {
|
|
952
|
+
const response = await requestWithRetry({
|
|
953
|
+
method,
|
|
954
|
+
url: `${this.baseUrl}${path}`,
|
|
955
|
+
headers: {
|
|
956
|
+
...(await this.resolvedAuthHeaders()),
|
|
957
|
+
...(options.body !== undefined ? { 'Content-Type': 'application/json' } : {}),
|
|
958
|
+
},
|
|
959
|
+
params: options.params,
|
|
960
|
+
body: options.body,
|
|
961
|
+
timeout: options.timeout ?? 60000,
|
|
962
|
+
retries: options.retries ?? 3,
|
|
963
|
+
});
|
|
964
|
+
if (response.status >= 400) {
|
|
965
|
+
const text = await response.text();
|
|
966
|
+
throw new Error(`ComfyUI ${method} ${path} failed (${response.status}): ${text}`);
|
|
967
|
+
}
|
|
968
|
+
return response.json();
|
|
969
|
+
}
|
|
970
|
+
async resolvedAuthHeaders() {
|
|
971
|
+
if (this.useAuth) {
|
|
972
|
+
await this.jobToken();
|
|
973
|
+
}
|
|
974
|
+
return this.authHeaders;
|
|
975
|
+
}
|
|
976
|
+
static isTransientNetworkError(error) {
|
|
977
|
+
if (!(error instanceof Error))
|
|
978
|
+
return false;
|
|
979
|
+
// fetch() throws TypeError on connect/DNS failures; AbortError on timeout.
|
|
980
|
+
return error instanceof TypeError || error.name === 'AbortError';
|
|
981
|
+
}
|
|
982
|
+
/**
|
|
983
|
+
* Get set of available node class_types from ComfyUI's /object_info.
|
|
984
|
+
*/
|
|
985
|
+
async getAvailableNodeTypes() {
|
|
986
|
+
const info = await this.comfyJson('GET', '/object_info', { timeout: 60000 });
|
|
987
|
+
return new Set(Object.keys(info ?? {}));
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Extract all class_type values from a workflow (API format or UI format).
|
|
991
|
+
*/
|
|
992
|
+
getWorkflowNodeTypes(workflow) {
|
|
993
|
+
const classTypes = new Set();
|
|
994
|
+
// UI format: {"nodes": [{"type": "..."}, ...]}
|
|
995
|
+
if (Array.isArray(workflow?.nodes)) {
|
|
996
|
+
for (const node of workflow.nodes) {
|
|
997
|
+
if (node && typeof node.type === 'string') {
|
|
998
|
+
classTypes.add(node.type);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
else {
|
|
1003
|
+
// API format: {"1": {"class_type": "..."}, ...}
|
|
1004
|
+
for (const nodeData of Object.values(workflow ?? {})) {
|
|
1005
|
+
if (nodeData && typeof nodeData === 'object' && typeof nodeData.class_type === 'string') {
|
|
1006
|
+
classTypes.add(nodeData.class_type);
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
return classTypes;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Find node types in workflow that aren't available in ComfyUI.
|
|
1014
|
+
*/
|
|
1015
|
+
async getMissingNodeTypes(workflow) {
|
|
1016
|
+
const available = await this.getAvailableNodeTypes();
|
|
1017
|
+
const required = this.getWorkflowNodeTypes(workflow);
|
|
1018
|
+
const missing = new Set();
|
|
1019
|
+
for (const nodeType of required) {
|
|
1020
|
+
if (!available.has(nodeType)) {
|
|
1021
|
+
missing.add(nodeType);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
return missing;
|
|
1025
|
+
}
|
|
1026
|
+
/**
|
|
1027
|
+
* Get node class_type to package mappings from ComfyUI Manager.
|
|
1028
|
+
*
|
|
1029
|
+
* Returns a mapping of package URL to [node_list, metadata].
|
|
1030
|
+
*/
|
|
1031
|
+
async getNodeMappings() {
|
|
1032
|
+
return await this.comfyJson('GET', '/customnode/getmappings', { timeout: 60000 });
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Look up which packages provide the given node types.
|
|
1036
|
+
*
|
|
1037
|
+
* Returns a mapping of package URL to the node types it provides.
|
|
1038
|
+
*/
|
|
1039
|
+
async lookupPackagesForNodes(nodeTypes) {
|
|
1040
|
+
const mappings = await this.getNodeMappings();
|
|
1041
|
+
// Invert the mapping: node_type -> package_url
|
|
1042
|
+
const nodeToPackage = {};
|
|
1043
|
+
for (const [url, data] of Object.entries(mappings ?? {})) {
|
|
1044
|
+
if (Array.isArray(data) && data.length > 0 && Array.isArray(data[0])) {
|
|
1045
|
+
for (const node of data[0]) {
|
|
1046
|
+
nodeToPackage[node] = url;
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
// Group requested nodes by package
|
|
1051
|
+
const packages = {};
|
|
1052
|
+
for (const nodeType of nodeTypes) {
|
|
1053
|
+
const pkg = nodeToPackage[nodeType];
|
|
1054
|
+
if (pkg) {
|
|
1055
|
+
(packages[pkg] ??= []).push(nodeType);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
return packages;
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Get the full list of available custom node packages.
|
|
1062
|
+
*/
|
|
1063
|
+
async getCustomNodeList() {
|
|
1064
|
+
return await this.comfyJson('GET', '/customnode/getlist', {
|
|
1065
|
+
params: { skip_update: 'true' },
|
|
1066
|
+
timeout: 60000,
|
|
1067
|
+
});
|
|
1068
|
+
}
|
|
1069
|
+
/**
|
|
1070
|
+
* Install custom node packages by their repository URLs. Returns
|
|
1071
|
+
* { queued, failed, notFound } package titles/URLs.
|
|
1072
|
+
*/
|
|
1073
|
+
async installPackagesByUrl(packageUrls) {
|
|
1074
|
+
const results = { queued: [], failed: [], notFound: [] };
|
|
1075
|
+
const pkgList = await this.getCustomNodeList();
|
|
1076
|
+
const nodePacks = pkgList?.node_packs ?? {};
|
|
1077
|
+
const urlToMetadata = {};
|
|
1078
|
+
for (const metadata of Object.values(nodePacks)) {
|
|
1079
|
+
for (const url of metadata?.files ?? []) {
|
|
1080
|
+
urlToMetadata[url] = metadata;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
for (const url of packageUrls) {
|
|
1084
|
+
const metadata = urlToMetadata[url];
|
|
1085
|
+
if (!metadata) {
|
|
1086
|
+
results.notFound.push(url);
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
const response = await requestWithRetry({
|
|
1090
|
+
method: 'POST',
|
|
1091
|
+
url: `${this.baseUrl}/manager/queue/install`,
|
|
1092
|
+
headers: {
|
|
1093
|
+
...(await this.resolvedAuthHeaders()),
|
|
1094
|
+
'Content-Type': 'application/json',
|
|
1095
|
+
},
|
|
1096
|
+
body: metadata,
|
|
1097
|
+
retries: 1,
|
|
1098
|
+
timeout: 60000,
|
|
1099
|
+
});
|
|
1100
|
+
if (response.status === 200 || response.status === 201) {
|
|
1101
|
+
results.queued.push(metadata.title || url);
|
|
1102
|
+
}
|
|
1103
|
+
else {
|
|
1104
|
+
results.failed.push(metadata.title || url);
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
// Start processing if we queued anything
|
|
1108
|
+
if (results.queued.length) {
|
|
1109
|
+
await requestWithRetry({
|
|
1110
|
+
method: 'GET',
|
|
1111
|
+
url: `${this.baseUrl}/manager/queue/start`,
|
|
1112
|
+
headers: await this.resolvedAuthHeaders(),
|
|
1113
|
+
retries: 1,
|
|
1114
|
+
timeout: 60000,
|
|
1115
|
+
});
|
|
1116
|
+
}
|
|
1117
|
+
return results;
|
|
1118
|
+
}
|
|
1119
|
+
/**
|
|
1120
|
+
* Automatically install missing custom nodes for a workflow.
|
|
1121
|
+
*/
|
|
1122
|
+
async autoInstallWorkflowNodes(workflow, options = {}) {
|
|
1123
|
+
const { wait = true, reboot = true } = options;
|
|
1124
|
+
const results = {
|
|
1125
|
+
missingNodes: [],
|
|
1126
|
+
packagesToInstall: {},
|
|
1127
|
+
installed: [],
|
|
1128
|
+
failed: [],
|
|
1129
|
+
notFoundNodes: [],
|
|
1130
|
+
};
|
|
1131
|
+
const missing = await this.getMissingNodeTypes(workflow);
|
|
1132
|
+
if (!missing.size) {
|
|
1133
|
+
return results;
|
|
1134
|
+
}
|
|
1135
|
+
results.missingNodes = [...missing];
|
|
1136
|
+
const packages = await this.lookupPackagesForNodes(missing);
|
|
1137
|
+
results.packagesToInstall = packages;
|
|
1138
|
+
const foundNodes = new Set(Object.values(packages).flat());
|
|
1139
|
+
results.notFoundNodes = [...missing].filter((nodeType) => !foundNodes.has(nodeType));
|
|
1140
|
+
if (!Object.keys(packages).length) {
|
|
1141
|
+
return results;
|
|
1142
|
+
}
|
|
1143
|
+
const installResult = await this.installPackagesByUrl(Object.keys(packages));
|
|
1144
|
+
results.installed = installResult.queued;
|
|
1145
|
+
results.failed = [...installResult.failed, ...installResult.notFound];
|
|
1146
|
+
// Wait for installation (max 3 minutes)
|
|
1147
|
+
if (wait && results.installed.length) {
|
|
1148
|
+
const headers = await this.resolvedAuthHeaders();
|
|
1149
|
+
for (let attempt = 0; attempt < 180; attempt += 1) {
|
|
1150
|
+
await sleep(1000);
|
|
1151
|
+
const response = await requestWithRetry({
|
|
1152
|
+
method: 'GET',
|
|
1153
|
+
url: `${this.baseUrl}/manager/queue/status`,
|
|
1154
|
+
headers,
|
|
1155
|
+
retries: 1,
|
|
1156
|
+
timeout: 60000,
|
|
1157
|
+
}).catch(() => null);
|
|
1158
|
+
if (response && response.status === 200) {
|
|
1159
|
+
const status = await response.json();
|
|
1160
|
+
if (!status?.is_processing) {
|
|
1161
|
+
break;
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (reboot && results.installed.length) {
|
|
1167
|
+
await this.reboot();
|
|
1168
|
+
}
|
|
1169
|
+
return results;
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* Get list of installed custom nodes from ComfyUI Manager.
|
|
1173
|
+
*/
|
|
1174
|
+
async getInstalledNodes() {
|
|
1175
|
+
return await this.comfyJson('GET', '/customnode/installed', { timeout: 30000 });
|
|
1176
|
+
}
|
|
1177
|
+
/**
|
|
1178
|
+
* Install a custom node package via ComfyUI Manager.
|
|
1179
|
+
* Returns true if installation was queued successfully.
|
|
1180
|
+
*/
|
|
1181
|
+
async installNode(nodeName) {
|
|
1182
|
+
const headers = await this.resolvedAuthHeaders();
|
|
1183
|
+
const response = await requestWithRetry({
|
|
1184
|
+
method: 'POST',
|
|
1185
|
+
url: `${this.baseUrl}/manager/queue/install`,
|
|
1186
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
1187
|
+
body: { name: nodeName },
|
|
1188
|
+
retries: 1,
|
|
1189
|
+
timeout: 60000,
|
|
1190
|
+
});
|
|
1191
|
+
if (response.status !== 200 && response.status !== 201) {
|
|
1192
|
+
return false;
|
|
1193
|
+
}
|
|
1194
|
+
const start = await requestWithRetry({
|
|
1195
|
+
method: 'GET',
|
|
1196
|
+
url: `${this.baseUrl}/manager/queue/start`,
|
|
1197
|
+
headers,
|
|
1198
|
+
retries: 1,
|
|
1199
|
+
timeout: 60000,
|
|
1200
|
+
});
|
|
1201
|
+
return start.status === 200 || start.status === 201;
|
|
1202
|
+
}
|
|
1203
|
+
/**
|
|
1204
|
+
* Install multiple custom node packages. Returns { queued, failed }.
|
|
1205
|
+
*/
|
|
1206
|
+
async installNodes(nodeNames, options = {}) {
|
|
1207
|
+
const { wait = true } = options;
|
|
1208
|
+
const results = { queued: [], failed: [] };
|
|
1209
|
+
const headers = await this.resolvedAuthHeaders();
|
|
1210
|
+
for (const name of nodeNames) {
|
|
1211
|
+
const response = await requestWithRetry({
|
|
1212
|
+
method: 'POST',
|
|
1213
|
+
url: `${this.baseUrl}/manager/queue/install`,
|
|
1214
|
+
headers: { ...headers, 'Content-Type': 'application/json' },
|
|
1215
|
+
body: { name },
|
|
1216
|
+
retries: 1,
|
|
1217
|
+
timeout: 60000,
|
|
1218
|
+
});
|
|
1219
|
+
if (response.status === 200 || response.status === 201) {
|
|
1220
|
+
results.queued.push(name);
|
|
1221
|
+
}
|
|
1222
|
+
else {
|
|
1223
|
+
results.failed.push(name);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
if (!results.queued.length) {
|
|
1227
|
+
return results;
|
|
1228
|
+
}
|
|
1229
|
+
await requestWithRetry({
|
|
1230
|
+
method: 'GET',
|
|
1231
|
+
url: `${this.baseUrl}/manager/queue/start`,
|
|
1232
|
+
headers,
|
|
1233
|
+
retries: 1,
|
|
1234
|
+
timeout: 60000,
|
|
1235
|
+
});
|
|
1236
|
+
// Wait for completion (max 2 minutes)
|
|
1237
|
+
if (wait) {
|
|
1238
|
+
for (let attempt = 0; attempt < 120; attempt += 1) {
|
|
1239
|
+
await sleep(1000);
|
|
1240
|
+
const response = await requestWithRetry({
|
|
1241
|
+
method: 'GET',
|
|
1242
|
+
url: `${this.baseUrl}/manager/queue/status`,
|
|
1243
|
+
headers,
|
|
1244
|
+
retries: 1,
|
|
1245
|
+
timeout: 60000,
|
|
1246
|
+
}).catch(() => null);
|
|
1247
|
+
if (response && response.status === 200) {
|
|
1248
|
+
const status = await response.json();
|
|
1249
|
+
if (!status?.is_processing) {
|
|
1250
|
+
break;
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
1255
|
+
return results;
|
|
1256
|
+
}
|
|
1257
|
+
/**
|
|
1258
|
+
* Reboot ComfyUI server (required after installing nodes).
|
|
1259
|
+
*/
|
|
1260
|
+
async reboot(options = {}) {
|
|
1261
|
+
const { waitReady = true, timeoutSeconds = 120 } = options;
|
|
1262
|
+
try {
|
|
1263
|
+
await requestWithRetry({
|
|
1264
|
+
method: 'GET',
|
|
1265
|
+
url: `${this.baseUrl}/manager/reboot`,
|
|
1266
|
+
headers: await this.resolvedAuthHeaders(),
|
|
1267
|
+
retries: 1,
|
|
1268
|
+
timeout: 10000,
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
// The server may not respond as it is rebooting; that is expected.
|
|
1273
|
+
}
|
|
1274
|
+
if (waitReady) {
|
|
1275
|
+
// Wait a moment for the server to start shutting down
|
|
1276
|
+
await sleep(3000);
|
|
1277
|
+
return await this.waitReady(timeoutSeconds * 1000);
|
|
1278
|
+
}
|
|
1279
|
+
return true;
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Ensure custom nodes are installed, installing missing ones. Returns
|
|
1283
|
+
* { alreadyInstalled, installed, failed }.
|
|
1284
|
+
*/
|
|
1285
|
+
async ensureNodesInstalled(nodeNames) {
|
|
1286
|
+
const results = {
|
|
1287
|
+
alreadyInstalled: [],
|
|
1288
|
+
installed: [],
|
|
1289
|
+
failed: [],
|
|
1290
|
+
};
|
|
1291
|
+
let installedNames;
|
|
1292
|
+
try {
|
|
1293
|
+
const installed = await this.getInstalledNodes();
|
|
1294
|
+
installedNames = new Set(installed && typeof installed === 'object' ? Object.keys(installed) : []);
|
|
1295
|
+
}
|
|
1296
|
+
catch {
|
|
1297
|
+
installedNames = new Set();
|
|
1298
|
+
}
|
|
1299
|
+
results.alreadyInstalled = nodeNames.filter((name) => installedNames.has(name));
|
|
1300
|
+
const missing = nodeNames.filter((name) => !installedNames.has(name));
|
|
1301
|
+
if (!missing.length) {
|
|
1302
|
+
return results;
|
|
1303
|
+
}
|
|
1304
|
+
const installResult = await this.installNodes(missing, { wait: true });
|
|
1305
|
+
results.installed = installResult.queued;
|
|
1306
|
+
results.failed = installResult.failed;
|
|
1307
|
+
if (results.installed.length) {
|
|
1308
|
+
await this.reboot();
|
|
1309
|
+
}
|
|
1310
|
+
return results;
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* Submit workflow to ComfyUI, returns prompt_id.
|
|
1314
|
+
* Retries on connection errors including DNS failures.
|
|
1315
|
+
*/
|
|
1316
|
+
async queuePrompt(workflow, retries = 5) {
|
|
1317
|
+
let lastError;
|
|
1318
|
+
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
1319
|
+
try {
|
|
1320
|
+
const response = await fetch(`${this.baseUrl}/prompt`, {
|
|
1321
|
+
method: 'POST',
|
|
1322
|
+
headers: {
|
|
1323
|
+
...(await this.resolvedAuthHeaders()),
|
|
1324
|
+
'Content-Type': 'application/json',
|
|
1325
|
+
},
|
|
1326
|
+
body: JSON.stringify({ prompt: workflow }),
|
|
1327
|
+
signal: AbortSignal.timeout(30000),
|
|
1328
|
+
});
|
|
1329
|
+
if (response.status !== 200) {
|
|
1330
|
+
let errorDetail;
|
|
1331
|
+
try {
|
|
1332
|
+
errorDetail = await response.json();
|
|
1333
|
+
}
|
|
1334
|
+
catch {
|
|
1335
|
+
errorDetail = await response.text();
|
|
1336
|
+
}
|
|
1337
|
+
throw new Error(`ComfyUI prompt failed (${response.status}): ${JSON.stringify(errorDetail)}`);
|
|
1338
|
+
}
|
|
1339
|
+
const data = await response.json();
|
|
1340
|
+
return data.prompt_id;
|
|
1341
|
+
}
|
|
1342
|
+
catch (error) {
|
|
1343
|
+
if (error instanceof Error && error.message.startsWith('ComfyUI prompt failed')) {
|
|
1344
|
+
throw error;
|
|
1345
|
+
}
|
|
1346
|
+
if (!ComfyUIJob.isTransientNetworkError(error)) {
|
|
1347
|
+
throw error;
|
|
1348
|
+
}
|
|
1349
|
+
lastError = error;
|
|
1350
|
+
if (attempt < retries - 1) {
|
|
1351
|
+
await sleep(2 ** attempt * 1000); // Exponential backoff
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
throw error;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
throw lastError;
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* Get execution history for a prompt. Retries on connection errors including
|
|
1361
|
+
* DNS failures.
|
|
1362
|
+
*/
|
|
1363
|
+
async getHistory(promptId, retries = 5) {
|
|
1364
|
+
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
1365
|
+
try {
|
|
1366
|
+
const response = await fetch(`${this.baseUrl}/history/${encodeURIComponent(promptId)}`, {
|
|
1367
|
+
headers: await this.resolvedAuthHeaders(),
|
|
1368
|
+
signal: AbortSignal.timeout(30000),
|
|
1369
|
+
});
|
|
1370
|
+
if (response.status >= 400) {
|
|
1371
|
+
throw new Error(`ComfyUI history failed (${response.status}): ${await response.text()}`);
|
|
1372
|
+
}
|
|
1373
|
+
const data = await response.json();
|
|
1374
|
+
return data?.[promptId] ?? null;
|
|
1375
|
+
}
|
|
1376
|
+
catch (error) {
|
|
1377
|
+
if (!ComfyUIJob.isTransientNetworkError(error)) {
|
|
1378
|
+
throw error;
|
|
1379
|
+
}
|
|
1380
|
+
if (attempt < retries - 1) {
|
|
1381
|
+
await sleep(2 ** attempt * 1000); // Exponential backoff: 1s, 2s, 4s, 8s
|
|
1382
|
+
continue;
|
|
1383
|
+
}
|
|
1384
|
+
throw error;
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
return null;
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Wait for prompt execution to complete, returns the history entry.
|
|
1391
|
+
*/
|
|
1392
|
+
async waitForCompletion(promptId, options = {}) {
|
|
1393
|
+
const timeoutSeconds = options.timeoutSeconds ?? 300;
|
|
1394
|
+
const pollIntervalSeconds = options.pollIntervalSeconds ?? 2;
|
|
1395
|
+
const start = Date.now();
|
|
1396
|
+
let consecutiveErrors = 0;
|
|
1397
|
+
const maxConsecutiveErrors = 10; // Handle DNS propagation delays
|
|
1398
|
+
while (Date.now() - start < timeoutSeconds * 1000) {
|
|
1399
|
+
try {
|
|
1400
|
+
const history = await this.getHistory(promptId);
|
|
1401
|
+
consecutiveErrors = 0;
|
|
1402
|
+
if (history) {
|
|
1403
|
+
const status = history.status ?? {};
|
|
1404
|
+
if (status.completed) {
|
|
1405
|
+
return history;
|
|
1406
|
+
}
|
|
1407
|
+
if (status.status_str === 'error') {
|
|
1408
|
+
throw new Error(`Workflow execution failed: ${JSON.stringify(status)}`);
|
|
1409
|
+
}
|
|
1410
|
+
}
|
|
1411
|
+
}
|
|
1412
|
+
catch (error) {
|
|
1413
|
+
if (!ComfyUIJob.isTransientNetworkError(error)) {
|
|
1414
|
+
throw error;
|
|
1415
|
+
}
|
|
1416
|
+
consecutiveErrors += 1;
|
|
1417
|
+
if (consecutiveErrors >= maxConsecutiveErrors) {
|
|
1418
|
+
throw new Error(`Lost connection to ComfyUI after ${consecutiveErrors} retries: ${error}`);
|
|
1419
|
+
}
|
|
1420
|
+
await sleep(pollIntervalSeconds * 2 * 1000);
|
|
1421
|
+
continue;
|
|
1422
|
+
}
|
|
1423
|
+
await sleep(pollIntervalSeconds * 1000);
|
|
1424
|
+
}
|
|
1425
|
+
throw new Error(`Workflow did not complete within ${timeoutSeconds}s`);
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Download output file from ComfyUI server.
|
|
1429
|
+
* If the file already exists, increments the name (file_1.png, file_2.png, etc.)
|
|
1430
|
+
*/
|
|
1431
|
+
async downloadOutput(filename, options = {}) {
|
|
1432
|
+
const outputDir = options.outputDir ?? '.';
|
|
1433
|
+
const subfolder = options.subfolder ?? '';
|
|
1434
|
+
const retries = options.retries ?? 5;
|
|
1435
|
+
if (!existsSync(outputDir)) {
|
|
1436
|
+
mkdirSync(outputDir, { recursive: true });
|
|
1437
|
+
}
|
|
1438
|
+
const params = new URLSearchParams({ filename, type: 'output' });
|
|
1439
|
+
if (subfolder)
|
|
1440
|
+
params.set('subfolder', subfolder);
|
|
1441
|
+
const url = `${this.baseUrl}/view?${params.toString()}`;
|
|
1442
|
+
for (let attempt = 0; attempt < retries; attempt += 1) {
|
|
1443
|
+
try {
|
|
1444
|
+
const response = await fetch(url, {
|
|
1445
|
+
headers: await this.resolvedAuthHeaders(),
|
|
1446
|
+
signal: AbortSignal.timeout(120000),
|
|
1447
|
+
});
|
|
1448
|
+
if (response.status >= 400) {
|
|
1449
|
+
throw new Error(`ComfyUI download failed (${response.status}): ${await response.text()}`);
|
|
1450
|
+
}
|
|
1451
|
+
const content = Buffer.from(await response.arrayBuffer());
|
|
1452
|
+
// Auto-increment filename if exists
|
|
1453
|
+
let outputPath = join(outputDir, filename);
|
|
1454
|
+
if (existsSync(outputPath)) {
|
|
1455
|
+
const dotIndex = filename.lastIndexOf('.');
|
|
1456
|
+
const stem = dotIndex > 0 ? filename.slice(0, dotIndex) : filename;
|
|
1457
|
+
const suffix = dotIndex > 0 ? filename.slice(dotIndex) : '';
|
|
1458
|
+
let index = 1;
|
|
1459
|
+
while (existsSync(outputPath)) {
|
|
1460
|
+
outputPath = join(outputDir, `${stem}_${index}${suffix}`);
|
|
1461
|
+
index += 1;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
writeFileSync(outputPath, content);
|
|
1465
|
+
return outputPath;
|
|
1466
|
+
}
|
|
1467
|
+
catch (error) {
|
|
1468
|
+
if (error instanceof Error && error.message.startsWith('ComfyUI download failed')) {
|
|
1469
|
+
throw error;
|
|
1470
|
+
}
|
|
1471
|
+
if (!ComfyUIJob.isTransientNetworkError(error)) {
|
|
1472
|
+
throw error;
|
|
1473
|
+
}
|
|
1474
|
+
if (attempt < retries - 1) {
|
|
1475
|
+
await sleep(2 ** attempt * 1000);
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
throw error;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
throw new Error('ComfyUI download failed');
|
|
1482
|
+
}
|
|
1483
|
+
/**
|
|
1484
|
+
* Run a workflow and wait for completion.
|
|
1485
|
+
*/
|
|
1486
|
+
async run(workflow, options = {}) {
|
|
1487
|
+
const { timeoutSeconds = 300, convert = true } = options;
|
|
1488
|
+
// Detect format and convert if needed
|
|
1489
|
+
if (convert && 'nodes' in workflow) {
|
|
1490
|
+
workflow = await this.convertWorkflow(workflow);
|
|
1491
|
+
}
|
|
1492
|
+
const promptId = await this.queuePrompt(workflow);
|
|
1493
|
+
return await this.waitForCompletion(promptId, { timeoutSeconds });
|
|
1494
|
+
}
|
|
1495
|
+
/**
|
|
1496
|
+
* Run a template workflow with parameter overrides.
|
|
1497
|
+
*/
|
|
1498
|
+
async runTemplate(templateId, options = {}) {
|
|
1499
|
+
const graph = this.loadTemplate(templateId, { templatesDir: options.templatesDir });
|
|
1500
|
+
const workflow = await this.convertWorkflow(graph);
|
|
1501
|
+
// Apply parameter overrides using type-based node lookup
|
|
1502
|
+
if (options.params) {
|
|
1503
|
+
applyParams(workflow, options.params);
|
|
1504
|
+
}
|
|
1505
|
+
return await this.run(workflow, { timeoutSeconds: options.timeoutSeconds, convert: false });
|
|
1506
|
+
}
|
|
1507
|
+
/**
|
|
1508
|
+
* Extract output file info from a history entry (images, videos, gifs).
|
|
1509
|
+
*/
|
|
1510
|
+
getOutputImages(history) {
|
|
1511
|
+
const outputs = [];
|
|
1512
|
+
for (const nodeOutput of Object.values(history?.outputs ?? {})) {
|
|
1513
|
+
for (const key of ['images', 'videos', 'gifs']) {
|
|
1514
|
+
const items = nodeOutput?.[key];
|
|
1515
|
+
if (Array.isArray(items)) {
|
|
1516
|
+
outputs.push(...items);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
return outputs;
|
|
1521
|
+
}
|
|
1522
|
+
/**
|
|
1523
|
+
* Upload image to ComfyUI server
|
|
1524
|
+
*/
|
|
1525
|
+
async uploadImage(filePath, filename) {
|
|
1526
|
+
const content = readFileSync(filePath);
|
|
1527
|
+
const fname = filename || basename(filePath);
|
|
1528
|
+
const formData = new FormData();
|
|
1529
|
+
const blob = new Blob([content], { type: 'image/png' });
|
|
1530
|
+
formData.append('image', blob, fname);
|
|
1531
|
+
const response = await fetch(`${this.baseUrl}/upload/image`, {
|
|
1532
|
+
method: 'POST',
|
|
1533
|
+
headers: await this.resolvedAuthHeaders(),
|
|
1534
|
+
body: formData,
|
|
1535
|
+
});
|
|
1536
|
+
if (!response.ok) {
|
|
1537
|
+
throw new Error(`Failed to upload image: ${response.statusText}`);
|
|
1538
|
+
}
|
|
1539
|
+
const data = await response.json();
|
|
1540
|
+
return data.name || fname;
|
|
1541
|
+
}
|
|
1542
|
+
/**
|
|
1543
|
+
* Upload audio to ComfyUI server
|
|
1544
|
+
*/
|
|
1545
|
+
async uploadAudio(filePath, filename) {
|
|
1546
|
+
const content = readFileSync(filePath);
|
|
1547
|
+
const fname = filename || basename(filePath);
|
|
1548
|
+
const formData = new FormData();
|
|
1549
|
+
const blob = new Blob([content], { type: 'audio/mpeg' });
|
|
1550
|
+
formData.append('image', blob, fname); // ComfyUI uses /upload/image for all files
|
|
1551
|
+
const response = await fetch(`${this.baseUrl}/upload/image`, {
|
|
1552
|
+
method: 'POST',
|
|
1553
|
+
headers: await this.resolvedAuthHeaders(),
|
|
1554
|
+
body: formData,
|
|
1555
|
+
});
|
|
1556
|
+
if (!response.ok) {
|
|
1557
|
+
throw new Error(`Failed to upload audio: ${response.statusText}`);
|
|
1558
|
+
}
|
|
1559
|
+
const data = await response.json();
|
|
1560
|
+
return data.name || fname;
|
|
1561
|
+
}
|
|
1562
|
+
}
|
|
1563
|
+
//# sourceMappingURL=comfyui.js.map
|