hypercli-cli 0.4.0__py3-none-any.whl
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.
- c3cli/__init__.py +1 -0
- c3cli/billing.py +60 -0
- c3cli/cli.py +183 -0
- c3cli/comfyui.py +823 -0
- c3cli/instances.py +193 -0
- c3cli/jobs.py +239 -0
- c3cli/llm.py +263 -0
- c3cli/output.py +78 -0
- c3cli/renders.py +192 -0
- c3cli/tui/__init__.py +4 -0
- c3cli/tui/job_monitor.py +335 -0
- c3cli/user.py +19 -0
- hypercli_cli-0.4.0.dist-info/METADATA +124 -0
- hypercli_cli-0.4.0.dist-info/RECORD +16 -0
- hypercli_cli-0.4.0.dist-info/WHEEL +4 -0
- hypercli_cli-0.4.0.dist-info/entry_points.txt +2 -0
c3cli/comfyui.py
ADDED
|
@@ -0,0 +1,823 @@
|
|
|
1
|
+
"""c3 comfyui commands - Run ComfyUI workflows on GPU"""
|
|
2
|
+
import random
|
|
3
|
+
import threading
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from queue import Queue
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
|
|
10
|
+
from c3 import C3, ComfyUIJob, APIError, apply_params, apply_graph_modes, load_template, graph_to_api
|
|
11
|
+
from .output import console, error, success, spinner
|
|
12
|
+
from .tui import JobStatus, run_job_monitor
|
|
13
|
+
|
|
14
|
+
app = typer.Typer(help="Run ComfyUI workflows on GPU")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def get_client() -> C3:
|
|
18
|
+
return C3()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_workflow(
|
|
22
|
+
job: ComfyUIJob,
|
|
23
|
+
template: str,
|
|
24
|
+
params: dict,
|
|
25
|
+
timeout: int,
|
|
26
|
+
output_dir: Path,
|
|
27
|
+
status_q: Queue,
|
|
28
|
+
):
|
|
29
|
+
"""Execute workflow and push status updates to queue"""
|
|
30
|
+
try:
|
|
31
|
+
# Refresh and check current state
|
|
32
|
+
job.refresh()
|
|
33
|
+
status_q.put(JobStatus(
|
|
34
|
+
stage="Initializing",
|
|
35
|
+
message=f"Job state: {job.job.state}, checking {job.base_url}..."
|
|
36
|
+
))
|
|
37
|
+
|
|
38
|
+
if not job.wait_ready(timeout=timeout):
|
|
39
|
+
job.refresh()
|
|
40
|
+
status_q.put(JobStatus(
|
|
41
|
+
stage="Failed",
|
|
42
|
+
error=f"Health check failed. State: {job.job.state}, URL: {job.base_url}",
|
|
43
|
+
complete=True
|
|
44
|
+
))
|
|
45
|
+
return
|
|
46
|
+
|
|
47
|
+
status_q.put(JobStatus(
|
|
48
|
+
stage="Loading",
|
|
49
|
+
message=f"Loading template: {template}",
|
|
50
|
+
history=["ComfyUI ready"],
|
|
51
|
+
))
|
|
52
|
+
|
|
53
|
+
# Load and convert workflow
|
|
54
|
+
try:
|
|
55
|
+
graph = job.load_template(template)
|
|
56
|
+
except ImportError as e:
|
|
57
|
+
status_q.put(JobStatus(
|
|
58
|
+
stage="Failed",
|
|
59
|
+
error=str(e),
|
|
60
|
+
history=["ComfyUI ready", "Failed to load template"],
|
|
61
|
+
complete=True,
|
|
62
|
+
))
|
|
63
|
+
return
|
|
64
|
+
|
|
65
|
+
status_q.put(JobStatus(
|
|
66
|
+
stage="Converting",
|
|
67
|
+
message="Converting workflow to API format...",
|
|
68
|
+
history=["ComfyUI ready", f"Loaded template: {template}"],
|
|
69
|
+
))
|
|
70
|
+
|
|
71
|
+
# Apply node mode changes (enable/disable) before conversion
|
|
72
|
+
if "nodes" in params:
|
|
73
|
+
nodes_with_modes = {
|
|
74
|
+
nid: cfg for nid, cfg in params["nodes"].items()
|
|
75
|
+
if "enabled" in cfg or "mode" in cfg
|
|
76
|
+
}
|
|
77
|
+
if nodes_with_modes:
|
|
78
|
+
apply_graph_modes(graph, nodes_with_modes)
|
|
79
|
+
|
|
80
|
+
# Use graph_to_api directly without live object_info - matches test script behavior
|
|
81
|
+
workflow = graph_to_api(graph)
|
|
82
|
+
|
|
83
|
+
# Upload images referenced in nodes param
|
|
84
|
+
if "nodes" in params:
|
|
85
|
+
nodes_dict = params["nodes"]
|
|
86
|
+
for node_id, node_params in nodes_dict.items():
|
|
87
|
+
if "image" in node_params:
|
|
88
|
+
image_path = node_params["image"]
|
|
89
|
+
if Path(image_path).exists():
|
|
90
|
+
status_q.put(JobStatus(
|
|
91
|
+
stage="Uploading",
|
|
92
|
+
message=f"Uploading {Path(image_path).name}...",
|
|
93
|
+
history=["ComfyUI ready", f"Loaded: {template}", "Workflow converted"],
|
|
94
|
+
))
|
|
95
|
+
uploaded_name = job.upload_image(image_path)
|
|
96
|
+
node_params["image"] = uploaded_name
|
|
97
|
+
|
|
98
|
+
# Apply params using type-based node lookup
|
|
99
|
+
apply_params(workflow, **params)
|
|
100
|
+
|
|
101
|
+
status_q.put(JobStatus(
|
|
102
|
+
stage="Queuing",
|
|
103
|
+
message="Submitting workflow to ComfyUI...",
|
|
104
|
+
history=["ComfyUI ready", f"Loaded: {template}", "Workflow converted"],
|
|
105
|
+
))
|
|
106
|
+
|
|
107
|
+
# Submit
|
|
108
|
+
prompt_id = job.queue_prompt(workflow)
|
|
109
|
+
|
|
110
|
+
status_q.put(JobStatus(
|
|
111
|
+
stage="Generating",
|
|
112
|
+
message=f"Prompt ID: {prompt_id[:16]}...",
|
|
113
|
+
progress=10,
|
|
114
|
+
history=["ComfyUI ready", f"Loaded: {template}", "Workflow converted", "Queued"],
|
|
115
|
+
))
|
|
116
|
+
|
|
117
|
+
# Poll for completion
|
|
118
|
+
import time
|
|
119
|
+
start = time.time()
|
|
120
|
+
last_progress = 10
|
|
121
|
+
while time.time() - start < timeout:
|
|
122
|
+
history_entry = job.get_history(prompt_id)
|
|
123
|
+
if history_entry:
|
|
124
|
+
status = history_entry.get("status", {})
|
|
125
|
+
if status.get("completed"):
|
|
126
|
+
break
|
|
127
|
+
if status.get("status_str") == "error":
|
|
128
|
+
status_q.put(JobStatus(
|
|
129
|
+
stage="Failed",
|
|
130
|
+
error=f"Workflow error: {status}",
|
|
131
|
+
history=["ComfyUI ready", f"Loaded: {template}", "Workflow converted", "Queued", "Execution failed"],
|
|
132
|
+
complete=True,
|
|
133
|
+
))
|
|
134
|
+
return
|
|
135
|
+
|
|
136
|
+
# Update progress (fake progress since we don't know actual)
|
|
137
|
+
elapsed = time.time() - start
|
|
138
|
+
# Assume ~60s typical, cap at 90%
|
|
139
|
+
fake_progress = min(10 + (elapsed / 60) * 80, 90)
|
|
140
|
+
if fake_progress > last_progress + 5:
|
|
141
|
+
last_progress = fake_progress
|
|
142
|
+
status_q.put(JobStatus(
|
|
143
|
+
stage="Generating",
|
|
144
|
+
message=f"Processing... ({int(elapsed)}s)",
|
|
145
|
+
progress=fake_progress,
|
|
146
|
+
history=["ComfyUI ready", f"Loaded: {template}", "Workflow converted", "Queued"],
|
|
147
|
+
))
|
|
148
|
+
|
|
149
|
+
time.sleep(2)
|
|
150
|
+
else:
|
|
151
|
+
status_q.put(JobStatus(
|
|
152
|
+
stage="Failed",
|
|
153
|
+
error=f"Timeout after {timeout}s",
|
|
154
|
+
complete=True,
|
|
155
|
+
))
|
|
156
|
+
return
|
|
157
|
+
|
|
158
|
+
status_q.put(JobStatus(
|
|
159
|
+
stage="Downloading",
|
|
160
|
+
message="Fetching output images...",
|
|
161
|
+
progress=95,
|
|
162
|
+
history=["ComfyUI ready", f"Loaded: {template}", "Workflow converted", "Queued", "Generated"],
|
|
163
|
+
))
|
|
164
|
+
|
|
165
|
+
# Get outputs
|
|
166
|
+
images = job.get_output_images(history_entry)
|
|
167
|
+
if not images:
|
|
168
|
+
status_q.put(JobStatus(
|
|
169
|
+
stage="Failed",
|
|
170
|
+
error="No output images found",
|
|
171
|
+
complete=True,
|
|
172
|
+
))
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
# Download
|
|
176
|
+
downloaded = []
|
|
177
|
+
for img in images:
|
|
178
|
+
filename = img["filename"]
|
|
179
|
+
subfolder = img.get("subfolder", "")
|
|
180
|
+
saved_path = job.download_output(filename, output_dir, subfolder)
|
|
181
|
+
downloaded.append(str(saved_path))
|
|
182
|
+
|
|
183
|
+
status_q.put(JobStatus(
|
|
184
|
+
stage="Complete",
|
|
185
|
+
message=f"Saved {len(downloaded)} image(s)",
|
|
186
|
+
progress=100,
|
|
187
|
+
history=[
|
|
188
|
+
"ComfyUI ready",
|
|
189
|
+
f"Loaded: {template}",
|
|
190
|
+
"Workflow converted",
|
|
191
|
+
"Queued",
|
|
192
|
+
"Generated",
|
|
193
|
+
*[f"Saved: {p}" for p in downloaded],
|
|
194
|
+
],
|
|
195
|
+
complete=True,
|
|
196
|
+
result={"images": downloaded},
|
|
197
|
+
))
|
|
198
|
+
|
|
199
|
+
except Exception as e:
|
|
200
|
+
status_q.put(JobStatus(
|
|
201
|
+
stage="Failed",
|
|
202
|
+
error=str(e),
|
|
203
|
+
complete=True,
|
|
204
|
+
))
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@app.command("run")
|
|
208
|
+
def run(
|
|
209
|
+
template: str = typer.Argument(..., help="Template ID (e.g., image_qwen_image)"),
|
|
210
|
+
prompt: str = typer.Option(..., "--prompt", "-p", help="Positive prompt"),
|
|
211
|
+
negative: str = typer.Option("", "--negative", "-n", help="Negative prompt"),
|
|
212
|
+
output: str = typer.Option(..., "--output", "-o", help="Output filename prefix"),
|
|
213
|
+
width: int = typer.Option(1328, "--width", "-W", help="Image width"),
|
|
214
|
+
height: int = typer.Option(1328, "--height", "-H", help="Image height"),
|
|
215
|
+
seed: Optional[int] = typer.Option(None, "--seed", "-s", help="Random seed (increments with --num)"),
|
|
216
|
+
random_seed: bool = typer.Option(False, "--random", "-r", help="Use random seed for each image"),
|
|
217
|
+
steps: Optional[int] = typer.Option(None, "--steps", help="Sampling steps (default: use workflow value)"),
|
|
218
|
+
cfg: Optional[float] = typer.Option(None, "--cfg", help="CFG scale (default: use workflow value)"),
|
|
219
|
+
gpu_type: str = typer.Option("l40s", "--gpu", help="GPU type (e.g., l40s, a100, h100)"),
|
|
220
|
+
gpu_count: int = typer.Option(1, "--count", help="Number of GPUs"),
|
|
221
|
+
interruptible: bool = typer.Option(True, "--interruptible/--on-demand", help="Use interruptible instances"),
|
|
222
|
+
region: Optional[str] = typer.Option(None, "--region", help="Region (e.g., us-east, eu-west, asia-east)"),
|
|
223
|
+
output_dir: str = typer.Option(".", "--output-dir", "-d", help="Output directory"),
|
|
224
|
+
timeout: int = typer.Option(600, "--timeout", "-t", help="Timeout in seconds"),
|
|
225
|
+
num: int = typer.Option(1, "--num", "-N", help="Number of images to generate"),
|
|
226
|
+
new: bool = typer.Option(False, "--new", help="Always launch new instance"),
|
|
227
|
+
instance: Optional[str] = typer.Option(None, "--instance", "-i", help="Connect to job by ID, hostname, or IP"),
|
|
228
|
+
lb: Optional[int] = typer.Option(None, "--lb", help="Enable HTTPS load balancer on port (e.g., 8188)"),
|
|
229
|
+
auth: bool = typer.Option(False, "--auth", help="Enable Bearer token auth on load balancer"),
|
|
230
|
+
stdout: bool = typer.Option(False, "--stdout", help="Output to stdout instead of TUI"),
|
|
231
|
+
debug: bool = typer.Option(False, "--debug", help="Debug mode: stdout + verbose errors"),
|
|
232
|
+
workflow_json: bool = typer.Option(False, "--workflow-json", help="Output generated workflow JSON and exit (don't run)"),
|
|
233
|
+
nodes: Optional[str] = typer.Option(None, "--nodes", help="Node-specific params as JSON: '{\"node_id\": {\"image\": \"file.png\"}}'"),
|
|
234
|
+
install_nodes: Optional[str] = typer.Option(None, "--install-nodes", help="Custom nodes to install (comma-separated): 'comfyui-humo,comfyui-videohelpersuite'"),
|
|
235
|
+
auto_install_nodes: bool = typer.Option(False, "--auto-install-nodes", help="Auto-detect and install missing custom nodes from workflow"),
|
|
236
|
+
):
|
|
237
|
+
"""Run a ComfyUI workflow template"""
|
|
238
|
+
|
|
239
|
+
# --workflow-json: generate JSON offline and exit (no instance needed)
|
|
240
|
+
if workflow_json:
|
|
241
|
+
import json as json_module
|
|
242
|
+
|
|
243
|
+
# Build params
|
|
244
|
+
if seed is not None:
|
|
245
|
+
iteration_seed = seed
|
|
246
|
+
elif random_seed or num > 1:
|
|
247
|
+
iteration_seed = random.randint(0, 2**32 - 1)
|
|
248
|
+
else:
|
|
249
|
+
iteration_seed = None
|
|
250
|
+
|
|
251
|
+
params = {
|
|
252
|
+
"prompt": prompt,
|
|
253
|
+
"negative": negative,
|
|
254
|
+
"width": width,
|
|
255
|
+
"height": height,
|
|
256
|
+
"filename_prefix": output,
|
|
257
|
+
}
|
|
258
|
+
# Only override steps/cfg if explicitly set (not None)
|
|
259
|
+
if steps is not None:
|
|
260
|
+
params["steps"] = steps
|
|
261
|
+
if cfg is not None:
|
|
262
|
+
params["cfg"] = cfg
|
|
263
|
+
if iteration_seed is not None:
|
|
264
|
+
params["seed"] = iteration_seed
|
|
265
|
+
if nodes:
|
|
266
|
+
try:
|
|
267
|
+
params["nodes"] = json_module.loads(nodes)
|
|
268
|
+
except json_module.JSONDecodeError as e:
|
|
269
|
+
error(f"Invalid JSON for --nodes: {e}")
|
|
270
|
+
raise typer.Exit(1)
|
|
271
|
+
|
|
272
|
+
try:
|
|
273
|
+
graph = load_template(template)
|
|
274
|
+
|
|
275
|
+
# Apply node mode changes (enable/disable) before conversion
|
|
276
|
+
if "nodes" in params:
|
|
277
|
+
nodes_with_modes = {
|
|
278
|
+
nid: cfg for nid, cfg in params["nodes"].items()
|
|
279
|
+
if "enabled" in cfg or "mode" in cfg
|
|
280
|
+
}
|
|
281
|
+
if nodes_with_modes:
|
|
282
|
+
apply_graph_modes(graph, nodes_with_modes)
|
|
283
|
+
|
|
284
|
+
workflow = graph_to_api(graph, debug=debug)
|
|
285
|
+
apply_params(workflow, **params)
|
|
286
|
+
|
|
287
|
+
# Output JSON to stdout
|
|
288
|
+
print(json_module.dumps(workflow, indent=2))
|
|
289
|
+
except ImportError as e:
|
|
290
|
+
error(str(e))
|
|
291
|
+
raise typer.Exit(1)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
error(f"Failed to generate workflow: {e}")
|
|
294
|
+
if debug:
|
|
295
|
+
import traceback
|
|
296
|
+
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
|
297
|
+
raise typer.Exit(1)
|
|
298
|
+
raise typer.Exit(0)
|
|
299
|
+
|
|
300
|
+
c3 = get_client()
|
|
301
|
+
|
|
302
|
+
# Get or create job
|
|
303
|
+
try:
|
|
304
|
+
if instance:
|
|
305
|
+
# Connect to specific instance by ID, hostname, or IP
|
|
306
|
+
with spinner(f"Connecting to instance {instance}..."):
|
|
307
|
+
job = ComfyUIJob.get_by_instance(c3, instance)
|
|
308
|
+
job.template = template
|
|
309
|
+
job.use_lb = lb is not None
|
|
310
|
+
job.use_auth = auth
|
|
311
|
+
else:
|
|
312
|
+
# Get or create job with template env var
|
|
313
|
+
with spinner(f"Getting ComfyUI instance for {template}..."):
|
|
314
|
+
job = ComfyUIJob.get_or_create_for_template(
|
|
315
|
+
c3,
|
|
316
|
+
template=template,
|
|
317
|
+
gpu_type=gpu_type,
|
|
318
|
+
gpu_count=gpu_count,
|
|
319
|
+
region=region,
|
|
320
|
+
reuse=not new,
|
|
321
|
+
lb=lb,
|
|
322
|
+
auth=auth,
|
|
323
|
+
interruptible=interruptible,
|
|
324
|
+
)
|
|
325
|
+
except APIError as e:
|
|
326
|
+
error(f"API Error ({e.status_code}): {e.detail}")
|
|
327
|
+
if debug:
|
|
328
|
+
console.print(f"[dim]Request: gpu_type={gpu_type}, gpu_count={gpu_count}, region={region}, interruptible={interruptible}, lb={lb}, auth={auth}[/dim]")
|
|
329
|
+
raise typer.Exit(1)
|
|
330
|
+
|
|
331
|
+
console.print(f"Job: [cyan]{job.job_id}[/cyan]")
|
|
332
|
+
if job.use_lb:
|
|
333
|
+
console.print(f"URL: [cyan]{job.base_url}[/cyan] (HTTPS + {'auth' if job.use_auth else 'no auth'})")
|
|
334
|
+
|
|
335
|
+
# Install custom nodes if requested
|
|
336
|
+
if install_nodes:
|
|
337
|
+
node_list = [n.strip() for n in install_nodes.split(",") if n.strip()]
|
|
338
|
+
if node_list:
|
|
339
|
+
with spinner(f"Installing custom nodes: {', '.join(node_list)}..."):
|
|
340
|
+
# Wait for ComfyUI to be ready first
|
|
341
|
+
if not job.wait_ready(timeout=300):
|
|
342
|
+
error("ComfyUI failed to start")
|
|
343
|
+
raise typer.Exit(1)
|
|
344
|
+
|
|
345
|
+
try:
|
|
346
|
+
result = job.ensure_nodes_installed(node_list)
|
|
347
|
+
if result.get("installed"):
|
|
348
|
+
console.print(f"[green]Installed:[/green] {', '.join(result['installed'])}")
|
|
349
|
+
if result.get("already_installed"):
|
|
350
|
+
console.print(f"[dim]Already installed:[/dim] {', '.join(result['already_installed'])}")
|
|
351
|
+
if result.get("failed"):
|
|
352
|
+
console.print(f"[yellow]Failed to install:[/yellow] {', '.join(result['failed'])}")
|
|
353
|
+
except Exception as e:
|
|
354
|
+
error(f"Failed to install nodes: {e}")
|
|
355
|
+
if debug:
|
|
356
|
+
import traceback
|
|
357
|
+
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
|
358
|
+
raise typer.Exit(1)
|
|
359
|
+
|
|
360
|
+
# Auto-install missing nodes from workflow
|
|
361
|
+
if auto_install_nodes:
|
|
362
|
+
with spinner("Checking for missing custom nodes..."):
|
|
363
|
+
# Wait for ComfyUI to be ready first
|
|
364
|
+
if not job.wait_ready(timeout=300):
|
|
365
|
+
error("ComfyUI failed to start")
|
|
366
|
+
raise typer.Exit(1)
|
|
367
|
+
|
|
368
|
+
try:
|
|
369
|
+
# Load the workflow template
|
|
370
|
+
graph = load_template(template)
|
|
371
|
+
workflow = graph_to_api(graph, debug=debug)
|
|
372
|
+
|
|
373
|
+
result = job.auto_install_workflow_nodes(workflow)
|
|
374
|
+
|
|
375
|
+
if result.get("missing_nodes"):
|
|
376
|
+
console.print(f"[yellow]Missing nodes:[/yellow] {', '.join(result['missing_nodes'])}")
|
|
377
|
+
|
|
378
|
+
if result.get("installed"):
|
|
379
|
+
console.print(f"[green]Installed packages:[/green] {', '.join(result['installed'])}")
|
|
380
|
+
if result.get("failed"):
|
|
381
|
+
console.print(f"[red]Failed to install:[/red] {', '.join(result['failed'])}")
|
|
382
|
+
if result.get("not_found_nodes"):
|
|
383
|
+
console.print(f"[yellow]Nodes not found in registry:[/yellow] {', '.join(result['not_found_nodes'])}")
|
|
384
|
+
|
|
385
|
+
if not result.get("missing_nodes"):
|
|
386
|
+
console.print("[dim]All required nodes already available[/dim]")
|
|
387
|
+
|
|
388
|
+
except ImportError as e:
|
|
389
|
+
error(str(e))
|
|
390
|
+
raise typer.Exit(1)
|
|
391
|
+
except Exception as e:
|
|
392
|
+
error(f"Failed to auto-install nodes: {e}")
|
|
393
|
+
if debug:
|
|
394
|
+
import traceback
|
|
395
|
+
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
|
396
|
+
raise typer.Exit(1)
|
|
397
|
+
|
|
398
|
+
# Run workflow(s)
|
|
399
|
+
for i in range(num):
|
|
400
|
+
# Build params for this iteration
|
|
401
|
+
# Seed logic:
|
|
402
|
+
# - --seed X: use X, increment for each image (X, X+1, X+2...)
|
|
403
|
+
# - --random: random seed per image
|
|
404
|
+
# - --num > 1 (no seed): assume random (different images wanted)
|
|
405
|
+
# - nothing: don't pass seed (use workflow default = reference image)
|
|
406
|
+
if seed is not None:
|
|
407
|
+
iteration_seed = seed + i
|
|
408
|
+
elif random_seed or num > 1:
|
|
409
|
+
iteration_seed = random.randint(0, 2**32 - 1)
|
|
410
|
+
else:
|
|
411
|
+
iteration_seed = None # Use workflow default
|
|
412
|
+
|
|
413
|
+
iteration_prefix = f"{output}_{i+1}" if num > 1 else output
|
|
414
|
+
|
|
415
|
+
params = {
|
|
416
|
+
"prompt": prompt,
|
|
417
|
+
"negative": negative,
|
|
418
|
+
"width": width,
|
|
419
|
+
"height": height,
|
|
420
|
+
"filename_prefix": iteration_prefix,
|
|
421
|
+
}
|
|
422
|
+
# Only override steps/cfg if explicitly set (not None)
|
|
423
|
+
if steps is not None:
|
|
424
|
+
params["steps"] = steps
|
|
425
|
+
if cfg is not None:
|
|
426
|
+
params["cfg"] = cfg
|
|
427
|
+
if iteration_seed is not None:
|
|
428
|
+
params["seed"] = iteration_seed
|
|
429
|
+
if nodes:
|
|
430
|
+
import json as json_module
|
|
431
|
+
try:
|
|
432
|
+
params["nodes"] = json_module.loads(nodes)
|
|
433
|
+
except json_module.JSONDecodeError as e:
|
|
434
|
+
error(f"Invalid JSON for --nodes: {e}")
|
|
435
|
+
raise typer.Exit(1)
|
|
436
|
+
|
|
437
|
+
if num > 1:
|
|
438
|
+
seed_info = f"seed: {iteration_seed}" if iteration_seed else "default seed"
|
|
439
|
+
console.print(f"\n[bold]Image {i+1}/{num}[/bold] ({seed_info})")
|
|
440
|
+
|
|
441
|
+
if stdout or debug:
|
|
442
|
+
# Simple mode without TUI
|
|
443
|
+
_run_simple(job, template, params, timeout, Path(output_dir), debug=debug)
|
|
444
|
+
else:
|
|
445
|
+
# TUI mode with status pane
|
|
446
|
+
status_q: Queue = Queue()
|
|
447
|
+
|
|
448
|
+
# Start workflow in background thread
|
|
449
|
+
workflow_thread = threading.Thread(
|
|
450
|
+
target=_run_workflow,
|
|
451
|
+
args=(job, template, params, timeout, Path(output_dir), status_q),
|
|
452
|
+
daemon=True,
|
|
453
|
+
)
|
|
454
|
+
workflow_thread.start()
|
|
455
|
+
|
|
456
|
+
# Run TUI in main thread
|
|
457
|
+
run_job_monitor(job.job_id, status_q=status_q, stop_on_status_complete=True)
|
|
458
|
+
|
|
459
|
+
# Wait for workflow thread to finish
|
|
460
|
+
workflow_thread.join(timeout=5)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _run_simple(job: ComfyUIJob, template: str, params: dict, timeout: int, output_dir: Path, debug: bool = False):
|
|
464
|
+
"""Simple mode without TUI"""
|
|
465
|
+
if debug:
|
|
466
|
+
console.print(f"[dim]Debug: base_url={job.base_url}[/dim]")
|
|
467
|
+
console.print(f"[dim]Debug: params={params}[/dim]")
|
|
468
|
+
|
|
469
|
+
if not job.hostname:
|
|
470
|
+
with spinner("Waiting for instance to start..."):
|
|
471
|
+
if not job.wait_for_hostname(timeout=timeout):
|
|
472
|
+
error("Instance failed to start")
|
|
473
|
+
raise typer.Exit(1)
|
|
474
|
+
|
|
475
|
+
console.print(f"Host: [cyan]{job.hostname}[/cyan]")
|
|
476
|
+
|
|
477
|
+
with spinner("Waiting for ComfyUI to be ready..."):
|
|
478
|
+
if not job.wait_ready(timeout=timeout):
|
|
479
|
+
error("ComfyUI failed to become ready")
|
|
480
|
+
raise typer.Exit(1)
|
|
481
|
+
|
|
482
|
+
success("ComfyUI ready")
|
|
483
|
+
|
|
484
|
+
try:
|
|
485
|
+
if debug:
|
|
486
|
+
console.print(f"[dim]Debug: Loading template {template}...[/dim]")
|
|
487
|
+
graph = job.load_template(template)
|
|
488
|
+
|
|
489
|
+
# Apply node mode changes (enable/disable) before conversion
|
|
490
|
+
if "nodes" in params:
|
|
491
|
+
nodes_with_modes = {
|
|
492
|
+
nid: cfg for nid, cfg in params["nodes"].items()
|
|
493
|
+
if "enabled" in cfg or "mode" in cfg
|
|
494
|
+
}
|
|
495
|
+
if nodes_with_modes:
|
|
496
|
+
if debug:
|
|
497
|
+
console.print(f"[dim]Debug: Applying node modes: {nodes_with_modes}[/dim]")
|
|
498
|
+
apply_graph_modes(graph, nodes_with_modes)
|
|
499
|
+
|
|
500
|
+
if debug:
|
|
501
|
+
console.print(f"[dim]Debug: Converting workflow (using DEFAULT_OBJECT_INFO)...[/dim]")
|
|
502
|
+
# Use graph_to_api directly without live object_info - matches test script behavior
|
|
503
|
+
workflow = graph_to_api(graph, debug=debug)
|
|
504
|
+
|
|
505
|
+
# Upload images referenced in nodes param
|
|
506
|
+
if "nodes" in params:
|
|
507
|
+
nodes_dict = params["nodes"]
|
|
508
|
+
for node_id, node_params in nodes_dict.items():
|
|
509
|
+
if "image" in node_params:
|
|
510
|
+
image_path = node_params["image"]
|
|
511
|
+
# Check if it's a local file path
|
|
512
|
+
if Path(image_path).exists():
|
|
513
|
+
if debug:
|
|
514
|
+
console.print(f"[dim]Debug: Uploading image {image_path}...[/dim]")
|
|
515
|
+
uploaded_name = job.upload_image(image_path)
|
|
516
|
+
node_params["image"] = uploaded_name
|
|
517
|
+
console.print(f"Uploaded: [cyan]{image_path}[/cyan] -> [green]{uploaded_name}[/green]")
|
|
518
|
+
|
|
519
|
+
# Apply params using type-based node lookup
|
|
520
|
+
apply_params(workflow, **params)
|
|
521
|
+
|
|
522
|
+
if debug:
|
|
523
|
+
import json
|
|
524
|
+
console.print(f"[dim]Debug: Workflow nodes: {list(workflow.keys())}[/dim]")
|
|
525
|
+
# Dump key nodes for debugging
|
|
526
|
+
for node_id in ["85", "86", "97", "98"]:
|
|
527
|
+
if node_id in workflow:
|
|
528
|
+
console.print(f"[dim]Debug: Node {node_id} ({workflow[node_id].get('class_type')}): {json.dumps(workflow[node_id], indent=2)}[/dim]")
|
|
529
|
+
console.print(f"[dim]Debug: Submitting to {job.base_url}/prompt[/dim]")
|
|
530
|
+
|
|
531
|
+
with spinner("Running workflow..."):
|
|
532
|
+
history = job.run(workflow, timeout=timeout, convert=False)
|
|
533
|
+
|
|
534
|
+
except ImportError as e:
|
|
535
|
+
error(str(e))
|
|
536
|
+
console.print("\n[dim]pip install comfyui-workflow-templates comfyui-workflow-templates-media-image[/dim]")
|
|
537
|
+
raise typer.Exit(1)
|
|
538
|
+
except Exception as e:
|
|
539
|
+
error(f"Workflow failed: {e}")
|
|
540
|
+
if debug:
|
|
541
|
+
import traceback
|
|
542
|
+
console.print(f"[dim]{traceback.format_exc()}[/dim]")
|
|
543
|
+
# Try to get response body for HTTP errors
|
|
544
|
+
if hasattr(e, 'response'):
|
|
545
|
+
try:
|
|
546
|
+
console.print(f"[red]Response body: {e.response.text}[/red]")
|
|
547
|
+
except:
|
|
548
|
+
pass
|
|
549
|
+
raise typer.Exit(1)
|
|
550
|
+
|
|
551
|
+
success("Workflow completed")
|
|
552
|
+
|
|
553
|
+
images = job.get_output_images(history)
|
|
554
|
+
if not images:
|
|
555
|
+
error("No output images found")
|
|
556
|
+
raise typer.Exit(1)
|
|
557
|
+
|
|
558
|
+
for img in images:
|
|
559
|
+
filename = img["filename"]
|
|
560
|
+
subfolder = img.get("subfolder", "")
|
|
561
|
+
with spinner(f"Downloading {filename}..."):
|
|
562
|
+
saved_path = job.download_output(filename, output_dir, subfolder)
|
|
563
|
+
console.print(f" [green]✓[/green] {saved_path}")
|
|
564
|
+
|
|
565
|
+
success("Done!")
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
@app.command("templates")
|
|
569
|
+
def templates():
|
|
570
|
+
"""List available workflow templates"""
|
|
571
|
+
try:
|
|
572
|
+
from comfyui_workflow_templates import iter_templates
|
|
573
|
+
except ImportError:
|
|
574
|
+
error("comfyui-workflow-templates not installed")
|
|
575
|
+
console.print("\n[dim]pip install comfyui-workflow-templates[/dim]")
|
|
576
|
+
raise typer.Exit(1)
|
|
577
|
+
|
|
578
|
+
console.print("[bold]Available templates:[/bold]\n")
|
|
579
|
+
|
|
580
|
+
by_bundle: dict[str, list] = {}
|
|
581
|
+
for entry in iter_templates():
|
|
582
|
+
bundle = entry.bundle
|
|
583
|
+
if bundle not in by_bundle:
|
|
584
|
+
by_bundle[bundle] = []
|
|
585
|
+
by_bundle[bundle].append(entry.template_id)
|
|
586
|
+
|
|
587
|
+
for bundle, ids in sorted(by_bundle.items()):
|
|
588
|
+
console.print(f"[bold cyan]{bundle}[/bold cyan]")
|
|
589
|
+
for tid in sorted(ids):
|
|
590
|
+
console.print(f" {tid}")
|
|
591
|
+
console.print()
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
@app.command("show")
|
|
595
|
+
def show(
|
|
596
|
+
template: str = typer.Argument(..., help="Template ID to show"),
|
|
597
|
+
json_output: bool = typer.Option(False, "--json", "-j", help="Output full JSON"),
|
|
598
|
+
api_format: bool = typer.Option(False, "--api", "-a", help="Show API format (what gets sent to ComfyUI)"),
|
|
599
|
+
):
|
|
600
|
+
"""Show template structure and key nodes"""
|
|
601
|
+
import json as json_module
|
|
602
|
+
|
|
603
|
+
try:
|
|
604
|
+
graph = load_template(template)
|
|
605
|
+
except ImportError as e:
|
|
606
|
+
error(str(e))
|
|
607
|
+
raise typer.Exit(1)
|
|
608
|
+
|
|
609
|
+
if json_output:
|
|
610
|
+
print(json_module.dumps(graph, indent=2))
|
|
611
|
+
raise typer.Exit(0)
|
|
612
|
+
|
|
613
|
+
if api_format:
|
|
614
|
+
workflow = graph_to_api(graph)
|
|
615
|
+
print(json_module.dumps(workflow, indent=2))
|
|
616
|
+
raise typer.Exit(0)
|
|
617
|
+
|
|
618
|
+
# Show summary
|
|
619
|
+
console.print(f"[bold]Template:[/bold] {template}\n")
|
|
620
|
+
|
|
621
|
+
nodes = graph.get("nodes", [])
|
|
622
|
+
console.print(f"[bold]Nodes:[/bold] {len(nodes)} total\n")
|
|
623
|
+
|
|
624
|
+
# Group by type
|
|
625
|
+
by_type: dict[str, list] = {}
|
|
626
|
+
for node in nodes:
|
|
627
|
+
node_type = node.get("type", "unknown")
|
|
628
|
+
if node_type not in by_type:
|
|
629
|
+
by_type[node_type] = []
|
|
630
|
+
by_type[node_type].append(node)
|
|
631
|
+
|
|
632
|
+
# Show LoadImage nodes first (important for input)
|
|
633
|
+
if "LoadImage" in by_type:
|
|
634
|
+
console.print("[bold cyan]LoadImage nodes (inputs):[/bold cyan]")
|
|
635
|
+
for node in by_type["LoadImage"]:
|
|
636
|
+
node_id = node.get("id")
|
|
637
|
+
title = node.get("title", "")
|
|
638
|
+
widgets = node.get("widgets_values", [])
|
|
639
|
+
mode = node.get("mode", 0) # 0=active, 2=muted, 4=bypassed
|
|
640
|
+
status = ""
|
|
641
|
+
if mode == 2:
|
|
642
|
+
status = " [dim](muted)[/dim]"
|
|
643
|
+
elif mode == 4:
|
|
644
|
+
status = " [dim](bypassed)[/dim]"
|
|
645
|
+
filename = widgets[0] if widgets else "none"
|
|
646
|
+
console.print(f" Node {node_id}: {filename}{status}")
|
|
647
|
+
console.print()
|
|
648
|
+
|
|
649
|
+
# Show KSampler nodes (sampling params)
|
|
650
|
+
sampler_types = ["KSampler", "KSamplerAdvanced"]
|
|
651
|
+
for st in sampler_types:
|
|
652
|
+
if st in by_type:
|
|
653
|
+
console.print(f"[bold cyan]{st} nodes:[/bold cyan]")
|
|
654
|
+
for node in by_type[st]:
|
|
655
|
+
node_id = node.get("id")
|
|
656
|
+
widgets = node.get("widgets_values", [])
|
|
657
|
+
console.print(f" Node {node_id}: widgets={widgets}")
|
|
658
|
+
console.print()
|
|
659
|
+
|
|
660
|
+
# Show SaveImage nodes (outputs)
|
|
661
|
+
if "SaveImage" in by_type:
|
|
662
|
+
console.print("[bold cyan]SaveImage nodes (outputs):[/bold cyan]")
|
|
663
|
+
for node in by_type["SaveImage"]:
|
|
664
|
+
node_id = node.get("id")
|
|
665
|
+
widgets = node.get("widgets_values", [])
|
|
666
|
+
prefix = widgets[0] if widgets else "ComfyUI"
|
|
667
|
+
console.print(f" Node {node_id}: prefix={prefix}")
|
|
668
|
+
console.print()
|
|
669
|
+
|
|
670
|
+
# Show all node types
|
|
671
|
+
console.print("[bold]All node types:[/bold]")
|
|
672
|
+
for node_type in sorted(by_type.keys()):
|
|
673
|
+
count = len(by_type[node_type])
|
|
674
|
+
console.print(f" {node_type}: {count}")
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@app.command("status")
|
|
678
|
+
def status():
|
|
679
|
+
"""Show running ComfyUI job status"""
|
|
680
|
+
c3 = get_client()
|
|
681
|
+
|
|
682
|
+
with spinner("Checking for running jobs..."):
|
|
683
|
+
job = ComfyUIJob.get_running(c3)
|
|
684
|
+
|
|
685
|
+
if not job:
|
|
686
|
+
console.print("[dim]No running ComfyUI job[/dim]")
|
|
687
|
+
return
|
|
688
|
+
|
|
689
|
+
console.print(f"[bold]Job ID:[/bold] {job.job_id}")
|
|
690
|
+
console.print(f"[bold]State:[/bold] {job.job.state}")
|
|
691
|
+
console.print(f"[bold]GPU:[/bold] {job.job.gpu_type} x{job.job.gpu_count}")
|
|
692
|
+
console.print(f"[bold]Region:[/bold] {job.job.region}")
|
|
693
|
+
|
|
694
|
+
if job.hostname:
|
|
695
|
+
console.print(f"[bold]Hostname:[/bold] {job.hostname}")
|
|
696
|
+
console.print(f"[bold]URL:[/bold] {job.base_url}")
|
|
697
|
+
|
|
698
|
+
with spinner("Checking health..."):
|
|
699
|
+
healthy = job.check_health()
|
|
700
|
+
|
|
701
|
+
if healthy:
|
|
702
|
+
success("ComfyUI is healthy")
|
|
703
|
+
else:
|
|
704
|
+
console.print("[yellow]ComfyUI not responding[/yellow]")
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
@app.command("download")
|
|
708
|
+
def download(
|
|
709
|
+
instance: str = typer.Option(None, "--instance", "-i", help="Job ID, hostname, or IP"),
|
|
710
|
+
output_dir: str = typer.Option(".", "--output-dir", "-d", help="Output directory"),
|
|
711
|
+
lb: Optional[int] = typer.Option(None, "--lb", help="Use HTTPS load balancer on port"),
|
|
712
|
+
auth: bool = typer.Option(False, "--auth", help="Use Bearer token auth"),
|
|
713
|
+
):
|
|
714
|
+
"""Download outputs from a running ComfyUI instance"""
|
|
715
|
+
c3 = get_client()
|
|
716
|
+
|
|
717
|
+
# Get job
|
|
718
|
+
if instance:
|
|
719
|
+
with spinner(f"Connecting to {instance}..."):
|
|
720
|
+
job = ComfyUIJob.get_by_instance(c3, instance)
|
|
721
|
+
else:
|
|
722
|
+
with spinner("Finding running job..."):
|
|
723
|
+
job = ComfyUIJob.get_running(c3)
|
|
724
|
+
if not job:
|
|
725
|
+
error("No running ComfyUI job found. Use --instance to specify one.")
|
|
726
|
+
raise typer.Exit(1)
|
|
727
|
+
|
|
728
|
+
# Set LB/auth mode - applies regardless of how we got the job
|
|
729
|
+
job.use_lb = lb is not None
|
|
730
|
+
job.use_auth = auth
|
|
731
|
+
|
|
732
|
+
console.print(f"Job: [cyan]{job.job_id}[/cyan]")
|
|
733
|
+
console.print(f"URL: [cyan]{job.base_url}[/cyan]")
|
|
734
|
+
|
|
735
|
+
# Check health
|
|
736
|
+
with spinner("Checking ComfyUI..."):
|
|
737
|
+
if not job.check_health():
|
|
738
|
+
error("ComfyUI not responding")
|
|
739
|
+
raise typer.Exit(1)
|
|
740
|
+
|
|
741
|
+
success("Connected")
|
|
742
|
+
|
|
743
|
+
# Get history - list all prompts
|
|
744
|
+
try:
|
|
745
|
+
import httpx
|
|
746
|
+
with httpx.Client(timeout=30) as client:
|
|
747
|
+
resp = client.get(f"{job.base_url}/history", headers=job.auth_headers)
|
|
748
|
+
resp.raise_for_status()
|
|
749
|
+
all_history = resp.json()
|
|
750
|
+
except Exception as e:
|
|
751
|
+
error(f"Failed to get history: {e}")
|
|
752
|
+
raise typer.Exit(1)
|
|
753
|
+
|
|
754
|
+
if not all_history:
|
|
755
|
+
console.print("[dim]No outputs found[/dim]")
|
|
756
|
+
return
|
|
757
|
+
|
|
758
|
+
console.print(f"\n[bold]Found {len(all_history)} prompt(s)[/bold]\n")
|
|
759
|
+
|
|
760
|
+
# Collect all outputs
|
|
761
|
+
all_outputs = []
|
|
762
|
+
for prompt_id, history in all_history.items():
|
|
763
|
+
status = history.get("status", {})
|
|
764
|
+
completed = status.get("completed", False)
|
|
765
|
+
status_str = "completed" if completed else status.get("status_str", "unknown")
|
|
766
|
+
|
|
767
|
+
outputs = history.get("outputs", {})
|
|
768
|
+
for node_id, node_output in outputs.items():
|
|
769
|
+
for key in ["images", "gifs", "videos"]:
|
|
770
|
+
if key in node_output:
|
|
771
|
+
for item in node_output[key]:
|
|
772
|
+
all_outputs.append({
|
|
773
|
+
"prompt_id": prompt_id[:8],
|
|
774
|
+
"status": status_str,
|
|
775
|
+
"type": key[:-1], # image, gif, video
|
|
776
|
+
**item,
|
|
777
|
+
})
|
|
778
|
+
|
|
779
|
+
if not all_outputs:
|
|
780
|
+
console.print("[dim]No output files found[/dim]")
|
|
781
|
+
return
|
|
782
|
+
|
|
783
|
+
console.print(f"[bold]Found {len(all_outputs)} output file(s):[/bold]")
|
|
784
|
+
for out in all_outputs:
|
|
785
|
+
subfolder = out.get("subfolder", "")
|
|
786
|
+
path = f"{subfolder}/{out['filename']}" if subfolder else out["filename"]
|
|
787
|
+
console.print(f" [{out['status']}] {out['type']}: {path}")
|
|
788
|
+
|
|
789
|
+
console.print()
|
|
790
|
+
|
|
791
|
+
# Download all
|
|
792
|
+
output_path = Path(output_dir)
|
|
793
|
+
for out in all_outputs:
|
|
794
|
+
filename = out["filename"]
|
|
795
|
+
subfolder = out.get("subfolder", "")
|
|
796
|
+
with spinner(f"Downloading {filename}..."):
|
|
797
|
+
try:
|
|
798
|
+
saved = job.download_output(filename, output_path, subfolder)
|
|
799
|
+
console.print(f" [green]✓[/green] {saved}")
|
|
800
|
+
except Exception as e:
|
|
801
|
+
console.print(f" [red]✗[/red] {filename}: {e}")
|
|
802
|
+
|
|
803
|
+
success("Done!")
|
|
804
|
+
|
|
805
|
+
|
|
806
|
+
@app.command("stop")
|
|
807
|
+
def stop():
|
|
808
|
+
"""Stop running ComfyUI job"""
|
|
809
|
+
c3 = get_client()
|
|
810
|
+
|
|
811
|
+
with spinner("Finding running job..."):
|
|
812
|
+
job = ComfyUIJob.get_running(c3)
|
|
813
|
+
|
|
814
|
+
if not job:
|
|
815
|
+
console.print("[dim]No running ComfyUI job[/dim]")
|
|
816
|
+
return
|
|
817
|
+
|
|
818
|
+
console.print(f"Stopping job [cyan]{job.job_id}[/cyan]...")
|
|
819
|
+
|
|
820
|
+
with spinner("Cancelling..."):
|
|
821
|
+
job.shutdown()
|
|
822
|
+
|
|
823
|
+
success("Job stopped")
|