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/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")