confyui 0.1.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.
- confyui/__init__.py +10 -0
- confyui/cli.py +203 -0
- confyui/client.py +248 -0
- confyui/config.py +103 -0
- confyui/server.py +264 -0
- confyui/workflows.py +212 -0
- confyui-0.1.0.dist-info/METADATA +152 -0
- confyui-0.1.0.dist-info/RECORD +10 -0
- confyui-0.1.0.dist-info/WHEEL +4 -0
- confyui-0.1.0.dist-info/entry_points.txt +3 -0
confyui/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""KπX sovereign local image generation & editing daemon over ComfyUI."""
|
|
2
|
+
|
|
3
|
+
# Note: per sovereign standard, package version is defined exclusively in
|
|
4
|
+
# pyproject.toml to eliminate desynchronization risks. Do NOT add __version__ here.
|
|
5
|
+
|
|
6
|
+
from confyui.client import ComfyClient
|
|
7
|
+
from confyui.config import Config
|
|
8
|
+
from confyui.server import ServerManager
|
|
9
|
+
|
|
10
|
+
__all__ = ["ComfyClient", "Config", "ServerManager"]
|
confyui/cli.py
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
"""confyui CLI — Typer-based interface to the sovereign ComfyUI daemon.
|
|
2
|
+
|
|
3
|
+
Provides `generate` (txt2img), `edit` (img2img), `inpaint`, `status`, `stop`, and `models`.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
from rich.console import Console
|
|
12
|
+
from rich.table import Table
|
|
13
|
+
|
|
14
|
+
from confyui.client import ComfyClient
|
|
15
|
+
from confyui.config import Config
|
|
16
|
+
from confyui.server import ServerManager
|
|
17
|
+
|
|
18
|
+
app = typer.Typer(
|
|
19
|
+
name="confyui",
|
|
20
|
+
help="KπX sovereign local image generation & editing daemon over ComfyUI (XPU-first).",
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
)
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@app.command()
|
|
27
|
+
def generate(
|
|
28
|
+
prompt: str = typer.Argument(..., help="Text prompt to generate"),
|
|
29
|
+
negative: str = typer.Option("", "--negative", "-n", help="Negative prompt"),
|
|
30
|
+
width: int = typer.Option(512, "--width", "-w", help="Image width in pixels"),
|
|
31
|
+
height: int = typer.Option(512, "--height", "-h", help="Image height in pixels"),
|
|
32
|
+
steps: int = typer.Option(20, "--steps", "-s", help="Sampling steps"),
|
|
33
|
+
cfg: float = typer.Option(7.0, "--cfg", help="Classifier-free guidance scale"),
|
|
34
|
+
seed: int | None = typer.Option(None, "--seed", help="Random seed for reproducibility"),
|
|
35
|
+
sampler: str = typer.Option("euler", "--sampler", help="Sampler name (euler, dpmpp_2m, etc.)"),
|
|
36
|
+
scheduler: str = typer.Option("normal", "--scheduler", help="Scheduler name (normal, karras, etc.)"),
|
|
37
|
+
model: str | None = typer.Option(None, "--model", "-m", help="Checkpoint filename"),
|
|
38
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Destination path for output image"),
|
|
39
|
+
timeout: float = typer.Option(300.0, "--timeout", help="Max seconds to wait"),
|
|
40
|
+
) -> None:
|
|
41
|
+
"""Generate an image from text (txt2img)."""
|
|
42
|
+
client = ComfyClient()
|
|
43
|
+
with console.status("[bold green]Generating image..."):
|
|
44
|
+
dest = client.generate(
|
|
45
|
+
prompt=prompt,
|
|
46
|
+
negative_prompt=negative,
|
|
47
|
+
width=width,
|
|
48
|
+
height=height,
|
|
49
|
+
steps=steps,
|
|
50
|
+
cfg=cfg,
|
|
51
|
+
seed=seed,
|
|
52
|
+
sampler_name=sampler,
|
|
53
|
+
scheduler=scheduler,
|
|
54
|
+
model=model,
|
|
55
|
+
out=out,
|
|
56
|
+
timeout=timeout,
|
|
57
|
+
)
|
|
58
|
+
console.print(f"[bold green]✓[/bold green] Saved: [cyan]{dest}[/cyan] ({dest.stat().st_size:,} bytes)")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@app.command()
|
|
62
|
+
def edit(
|
|
63
|
+
source: Path = typer.Argument(..., help="Path to source image to modify"),
|
|
64
|
+
prompt: str = typer.Argument(..., help="Text prompt describing the desired modification"),
|
|
65
|
+
negative: str = typer.Option("", "--negative", "-n", help="Negative prompt"),
|
|
66
|
+
denoise: float = typer.Option(
|
|
67
|
+
0.55, "--denoise", "-d", help="Denoising strength (0.0 = untouched, 1.0 = full rewrite)"
|
|
68
|
+
),
|
|
69
|
+
steps: int = typer.Option(20, "--steps", "-s", help="Sampling steps"),
|
|
70
|
+
cfg: float = typer.Option(7.0, "--cfg", help="Classifier-free guidance scale"),
|
|
71
|
+
seed: int | None = typer.Option(None, "--seed", help="Random seed for reproducibility"),
|
|
72
|
+
sampler: str = typer.Option("euler", "--sampler", help="Sampler name"),
|
|
73
|
+
scheduler: str = typer.Option("normal", "--scheduler", help="Scheduler name"),
|
|
74
|
+
model: str | None = typer.Option(None, "--model", "-m", help="Checkpoint filename"),
|
|
75
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Destination path"),
|
|
76
|
+
timeout: float = typer.Option(300.0, "--timeout", help="Max seconds to wait"),
|
|
77
|
+
) -> None:
|
|
78
|
+
"""Edit an existing image with a text prompt (img2img)."""
|
|
79
|
+
client = ComfyClient()
|
|
80
|
+
with console.status(f"[bold green]Editing {source.name} (denoise={denoise})..."):
|
|
81
|
+
dest = client.edit(
|
|
82
|
+
source=source,
|
|
83
|
+
prompt=prompt,
|
|
84
|
+
negative_prompt=negative,
|
|
85
|
+
denoise=denoise,
|
|
86
|
+
steps=steps,
|
|
87
|
+
cfg=cfg,
|
|
88
|
+
seed=seed,
|
|
89
|
+
sampler_name=sampler,
|
|
90
|
+
scheduler=scheduler,
|
|
91
|
+
model=model,
|
|
92
|
+
out=out,
|
|
93
|
+
timeout=timeout,
|
|
94
|
+
)
|
|
95
|
+
console.print(f"[bold green]✓[/bold green] Saved: [cyan]{dest}[/cyan] ({dest.stat().st_size:,} bytes)")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@app.command()
|
|
99
|
+
def inpaint(
|
|
100
|
+
source: Path = typer.Argument(..., help="Path to source image"),
|
|
101
|
+
mask: Path = typer.Argument(..., help="Path to binary mask (white = regenerate, black = preserve)"),
|
|
102
|
+
prompt: str = typer.Argument(..., help="Text prompt for the masked region"),
|
|
103
|
+
negative: str = typer.Option("", "--negative", "-n", help="Negative prompt"),
|
|
104
|
+
denoise: float = typer.Option(1.0, "--denoise", "-d", help="Denoising strength for masked region"),
|
|
105
|
+
steps: int = typer.Option(25, "--steps", "-s", help="Sampling steps"),
|
|
106
|
+
cfg: float = typer.Option(7.0, "--cfg", help="Classifier-free guidance scale"),
|
|
107
|
+
seed: int | None = typer.Option(None, "--seed", help="Random seed"),
|
|
108
|
+
sampler: str = typer.Option("euler", "--sampler", help="Sampler name"),
|
|
109
|
+
scheduler: str = typer.Option("normal", "--scheduler", help="Scheduler name"),
|
|
110
|
+
model: str | None = typer.Option(None, "--model", "-m", help="Inpainting checkpoint filename"),
|
|
111
|
+
out: Path | None = typer.Option(None, "--out", "-o", help="Destination path"),
|
|
112
|
+
timeout: float = typer.Option(300.0, "--timeout", help="Max seconds to wait"),
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Inpaint a region defined by a mask."""
|
|
115
|
+
client = ComfyClient()
|
|
116
|
+
with console.status(f"[bold green]Inpainting {source.name}..."):
|
|
117
|
+
dest = client.inpaint(
|
|
118
|
+
source=source,
|
|
119
|
+
mask=mask,
|
|
120
|
+
prompt=prompt,
|
|
121
|
+
negative_prompt=negative,
|
|
122
|
+
denoise=denoise,
|
|
123
|
+
steps=steps,
|
|
124
|
+
cfg=cfg,
|
|
125
|
+
seed=seed,
|
|
126
|
+
sampler_name=sampler,
|
|
127
|
+
scheduler=scheduler,
|
|
128
|
+
model=model,
|
|
129
|
+
out=out,
|
|
130
|
+
timeout=timeout,
|
|
131
|
+
)
|
|
132
|
+
console.print(f"[bold green]✓[/bold green] Saved: [cyan]{dest}[/cyan] ({dest.stat().st_size:,} bytes)")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@app.command()
|
|
136
|
+
def status() -> None:
|
|
137
|
+
"""Show the status of the ComfyUI background daemon."""
|
|
138
|
+
mgr = ServerManager()
|
|
139
|
+
st = mgr.status()
|
|
140
|
+
|
|
141
|
+
table = Table(title="confyui Daemon Status")
|
|
142
|
+
table.add_column("Property", style="bold cyan")
|
|
143
|
+
table.add_column("Value")
|
|
144
|
+
|
|
145
|
+
table.add_row("HTTP Server", "[green]UP[/green]" if st["http_up"] else "[red]DOWN[/red]")
|
|
146
|
+
table.add_row("Supervisor Process", "[green]ALIVE[/green]" if st["running"] else "[dim]STOPPED[/dim]")
|
|
147
|
+
table.add_row("PID", str(st["pid"]) if st["pid"] else "—")
|
|
148
|
+
table.add_row("Base URL", st["base_url"])
|
|
149
|
+
table.add_row("Idle Timeout", f"{st['idle_timeout_sec']}s")
|
|
150
|
+
|
|
151
|
+
rem = st["idle_remaining_sec"]
|
|
152
|
+
if rem is not None:
|
|
153
|
+
table.add_row("Idle Remaining", f"{rem}s before auto-shutdown")
|
|
154
|
+
else:
|
|
155
|
+
table.add_row("Idle Remaining", "—")
|
|
156
|
+
|
|
157
|
+
table.add_row("ComfyUI Dir", st["comfyui_dir"])
|
|
158
|
+
table.add_row("Log File", st["log_file"])
|
|
159
|
+
|
|
160
|
+
console.print(table)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
@app.command()
|
|
164
|
+
def stop() -> None:
|
|
165
|
+
"""Stop the running ComfyUI daemon and free RAM/VRAM immediately."""
|
|
166
|
+
mgr = ServerManager()
|
|
167
|
+
if mgr.stop():
|
|
168
|
+
console.print("[bold yellow]✓[/bold yellow] ComfyUI daemon stopped.")
|
|
169
|
+
else:
|
|
170
|
+
console.print("[dim]ComfyUI daemon is not running.[/dim]")
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@app.command()
|
|
174
|
+
def models() -> None:
|
|
175
|
+
"""List available checkpoint models in ComfyUI."""
|
|
176
|
+
client = ComfyClient()
|
|
177
|
+
ckpts = client.list_checkpoints()
|
|
178
|
+
if not ckpts:
|
|
179
|
+
console.print("[yellow]No checkpoints found in ComfyUI checkpoints directory.[/yellow]")
|
|
180
|
+
return
|
|
181
|
+
|
|
182
|
+
table = Table(title="Available Checkpoints")
|
|
183
|
+
table.add_column("#", style="dim")
|
|
184
|
+
table.add_column("Model Checkpoint", style="cyan")
|
|
185
|
+
|
|
186
|
+
cfg = Config.from_env()
|
|
187
|
+
for idx, c in enumerate(ckpts, 1):
|
|
188
|
+
suffix = ""
|
|
189
|
+
if c == cfg.default_ckpt:
|
|
190
|
+
suffix = " [bold green](default)[/bold green]"
|
|
191
|
+
elif c == cfg.default_inpaint_ckpt:
|
|
192
|
+
suffix = " [bold blue](default inpaint)[/bold blue]"
|
|
193
|
+
table.add_row(str(idx), c + suffix)
|
|
194
|
+
|
|
195
|
+
console.print(table)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main() -> None:
|
|
199
|
+
app()
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
if __name__ == "__main__":
|
|
203
|
+
main()
|
confyui/client.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""HTTP client for ComfyUI.
|
|
2
|
+
|
|
3
|
+
Submits workflows to the REST API, polls execution history, and fetches output images.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import httpx
|
|
15
|
+
from PIL import Image
|
|
16
|
+
|
|
17
|
+
from confyui.config import Config
|
|
18
|
+
from confyui.server import ServerManager
|
|
19
|
+
from confyui.workflows import (
|
|
20
|
+
build_img2img_workflow,
|
|
21
|
+
build_inpaint_workflow,
|
|
22
|
+
build_txt2img_workflow,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ComfyClient:
|
|
27
|
+
"""High-level client for the ComfyUI API."""
|
|
28
|
+
|
|
29
|
+
def __init__(self, config: Config | None = None) -> None:
|
|
30
|
+
self.cfg = config or Config.from_env()
|
|
31
|
+
self.manager = ServerManager(self.cfg)
|
|
32
|
+
|
|
33
|
+
def _client(self, timeout: float = 30.0) -> httpx.Client:
|
|
34
|
+
return httpx.Client(base_url=self.cfg.base_url, timeout=timeout)
|
|
35
|
+
|
|
36
|
+
def list_checkpoints(self) -> list[str]:
|
|
37
|
+
"""List checkpoint files available in ComfyUI."""
|
|
38
|
+
self.manager.ensure_running()
|
|
39
|
+
with self._client() as c:
|
|
40
|
+
r = c.get("/object_info/CheckpointLoaderSimple")
|
|
41
|
+
r.raise_for_status()
|
|
42
|
+
data = r.json()
|
|
43
|
+
try:
|
|
44
|
+
ckpts = data["CheckpointLoaderSimple"]["input"]["required"]["ckpt_name"][0]
|
|
45
|
+
return list(ckpts)
|
|
46
|
+
except (KeyError, IndexError):
|
|
47
|
+
return []
|
|
48
|
+
|
|
49
|
+
def submit_and_wait(
|
|
50
|
+
self,
|
|
51
|
+
workflow: dict[str, Any],
|
|
52
|
+
poll_interval: float = 1.0,
|
|
53
|
+
timeout: float = 300.0,
|
|
54
|
+
) -> list[tuple[str, str]]:
|
|
55
|
+
"""Submit a workflow, wait for completion, and return [(filename, subfolder), ...]."""
|
|
56
|
+
self.manager.ensure_running()
|
|
57
|
+
with self._client() as c:
|
|
58
|
+
# 1. POST prompt
|
|
59
|
+
r = c.post("/prompt", json={"prompt": workflow})
|
|
60
|
+
r.raise_for_status()
|
|
61
|
+
data = r.json()
|
|
62
|
+
prompt_id = data.get("prompt_id")
|
|
63
|
+
if not prompt_id:
|
|
64
|
+
raise RuntimeError(f"ComfyUI did not return a prompt_id: {data}")
|
|
65
|
+
|
|
66
|
+
# 2. Poll history
|
|
67
|
+
start = time.time()
|
|
68
|
+
deadline = start + timeout
|
|
69
|
+
while time.time() < deadline:
|
|
70
|
+
self.manager.touch_activity()
|
|
71
|
+
hr = c.get(f"/history/{prompt_id}")
|
|
72
|
+
if hr.status_code == 200:
|
|
73
|
+
hdata = hr.json()
|
|
74
|
+
if prompt_id in hdata:
|
|
75
|
+
entry = hdata[prompt_id]
|
|
76
|
+
outputs = entry.get("outputs", {})
|
|
77
|
+
# Find SaveImage node outputs (class SaveImage)
|
|
78
|
+
images: list[tuple[str, str]] = []
|
|
79
|
+
for node_out in outputs.values():
|
|
80
|
+
for img in node_out.get("images", []):
|
|
81
|
+
fn = img.get("filename")
|
|
82
|
+
sub = img.get("subfolder", "")
|
|
83
|
+
if fn:
|
|
84
|
+
images.append((fn, sub))
|
|
85
|
+
if images:
|
|
86
|
+
return images
|
|
87
|
+
time.sleep(poll_interval)
|
|
88
|
+
|
|
89
|
+
raise TimeoutError(f"Workflow {prompt_id} did not finish within {timeout}s.")
|
|
90
|
+
|
|
91
|
+
def fetch_image(
|
|
92
|
+
self,
|
|
93
|
+
filename: str,
|
|
94
|
+
subfolder: str = "",
|
|
95
|
+
dest: Path | None = None,
|
|
96
|
+
) -> Path:
|
|
97
|
+
"""Download a generated image from ComfyUI /view."""
|
|
98
|
+
self.manager.touch_activity()
|
|
99
|
+
with self._client(timeout=60.0) as c:
|
|
100
|
+
params = {"filename": filename, "type": "output"}
|
|
101
|
+
if subfolder:
|
|
102
|
+
params["subfolder"] = subfolder
|
|
103
|
+
r = c.get("/view", params=params)
|
|
104
|
+
r.raise_for_status()
|
|
105
|
+
|
|
106
|
+
if dest is None:
|
|
107
|
+
dest = Path.cwd() / filename
|
|
108
|
+
dest = Path(dest).expanduser().resolve()
|
|
109
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
dest.write_bytes(r.content)
|
|
111
|
+
return dest
|
|
112
|
+
|
|
113
|
+
def _stage_image(self, source: Path, target_name: str, resize_to: tuple[int, int] | None = None) -> str:
|
|
114
|
+
"""Copy a local image into ComfyUI's input/ directory, optionally pre-resizing."""
|
|
115
|
+
self.cfg.input_dir.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
dest = self.cfg.input_dir / target_name
|
|
117
|
+
|
|
118
|
+
if resize_to is not None:
|
|
119
|
+
im = Image.open(source).convert("RGB")
|
|
120
|
+
im = im.resize(resize_to, Image.Resampling.LANCZOS)
|
|
121
|
+
im.save(dest)
|
|
122
|
+
else:
|
|
123
|
+
shutil.copyfile(source, dest)
|
|
124
|
+
return target_name
|
|
125
|
+
|
|
126
|
+
def generate(
|
|
127
|
+
self,
|
|
128
|
+
prompt: str,
|
|
129
|
+
negative_prompt: str = "",
|
|
130
|
+
width: int = 512,
|
|
131
|
+
height: int = 512,
|
|
132
|
+
steps: int = 20,
|
|
133
|
+
cfg: float = 7.0,
|
|
134
|
+
seed: int | None = None,
|
|
135
|
+
sampler_name: str = "euler",
|
|
136
|
+
scheduler: str = "normal",
|
|
137
|
+
model: str | None = None,
|
|
138
|
+
out: Path | None = None,
|
|
139
|
+
timeout: float = 300.0,
|
|
140
|
+
) -> Path:
|
|
141
|
+
"""txt2img one-shot."""
|
|
142
|
+
ckpt = model or self.cfg.default_ckpt
|
|
143
|
+
wf = build_txt2img_workflow(
|
|
144
|
+
prompt=prompt,
|
|
145
|
+
negative_prompt=negative_prompt,
|
|
146
|
+
width=width,
|
|
147
|
+
height=height,
|
|
148
|
+
steps=steps,
|
|
149
|
+
cfg=cfg,
|
|
150
|
+
seed=seed,
|
|
151
|
+
sampler_name=sampler_name,
|
|
152
|
+
scheduler=scheduler,
|
|
153
|
+
ckpt_name=ckpt,
|
|
154
|
+
)
|
|
155
|
+
images = self.submit_and_wait(wf, timeout=timeout)
|
|
156
|
+
fn, sub = images[0]
|
|
157
|
+
return self.fetch_image(fn, sub, dest=out)
|
|
158
|
+
|
|
159
|
+
def edit(
|
|
160
|
+
self,
|
|
161
|
+
source: Path,
|
|
162
|
+
prompt: str,
|
|
163
|
+
negative_prompt: str = "",
|
|
164
|
+
denoise: float = 0.55,
|
|
165
|
+
steps: int = 20,
|
|
166
|
+
cfg: float = 7.0,
|
|
167
|
+
seed: int | None = None,
|
|
168
|
+
sampler_name: str = "euler",
|
|
169
|
+
scheduler: str = "normal",
|
|
170
|
+
model: str | None = None,
|
|
171
|
+
out: Path | None = None,
|
|
172
|
+
resize_to: tuple[int, int] | None = (512, 512),
|
|
173
|
+
timeout: float = 300.0,
|
|
174
|
+
) -> Path:
|
|
175
|
+
"""img2img one-shot."""
|
|
176
|
+
source = Path(source).expanduser().resolve()
|
|
177
|
+
if not source.is_file():
|
|
178
|
+
raise FileNotFoundError(f"Source image not found: {source}")
|
|
179
|
+
|
|
180
|
+
uid = uuid.uuid4().hex[:8]
|
|
181
|
+
staged_name = f"edit_in_{uid}.png"
|
|
182
|
+
self._stage_image(source, staged_name, resize_to=resize_to)
|
|
183
|
+
|
|
184
|
+
ckpt = model or self.cfg.default_ckpt
|
|
185
|
+
wf = build_img2img_workflow(
|
|
186
|
+
image_name=staged_name,
|
|
187
|
+
prompt=prompt,
|
|
188
|
+
negative_prompt=negative_prompt,
|
|
189
|
+
steps=steps,
|
|
190
|
+
cfg=cfg,
|
|
191
|
+
seed=seed,
|
|
192
|
+
sampler_name=sampler_name,
|
|
193
|
+
scheduler=scheduler,
|
|
194
|
+
denoise=denoise,
|
|
195
|
+
ckpt_name=ckpt,
|
|
196
|
+
)
|
|
197
|
+
images = self.submit_and_wait(wf, timeout=timeout)
|
|
198
|
+
fn, sub = images[0]
|
|
199
|
+
return self.fetch_image(fn, sub, dest=out)
|
|
200
|
+
|
|
201
|
+
def inpaint(
|
|
202
|
+
self,
|
|
203
|
+
source: Path,
|
|
204
|
+
mask: Path,
|
|
205
|
+
prompt: str,
|
|
206
|
+
negative_prompt: str = "",
|
|
207
|
+
denoise: float = 1.0,
|
|
208
|
+
steps: int = 25,
|
|
209
|
+
cfg: float = 7.0,
|
|
210
|
+
seed: int | None = None,
|
|
211
|
+
sampler_name: str = "euler",
|
|
212
|
+
scheduler: str = "normal",
|
|
213
|
+
model: str | None = None,
|
|
214
|
+
out: Path | None = None,
|
|
215
|
+
resize_to: tuple[int, int] | None = (512, 512),
|
|
216
|
+
timeout: float = 300.0,
|
|
217
|
+
) -> Path:
|
|
218
|
+
"""Inpainting one-shot."""
|
|
219
|
+
source = Path(source).expanduser().resolve()
|
|
220
|
+
mask = Path(mask).expanduser().resolve()
|
|
221
|
+
if not source.is_file():
|
|
222
|
+
raise FileNotFoundError(f"Source image not found: {source}")
|
|
223
|
+
if not mask.is_file():
|
|
224
|
+
raise FileNotFoundError(f"Mask image not found: {mask}")
|
|
225
|
+
|
|
226
|
+
uid = uuid.uuid4().hex[:8]
|
|
227
|
+
staged_src = f"inpaint_src_{uid}.png"
|
|
228
|
+
staged_mask = f"inpaint_mask_{uid}.png"
|
|
229
|
+
self._stage_image(source, staged_src, resize_to=resize_to)
|
|
230
|
+
self._stage_image(mask, staged_mask, resize_to=resize_to)
|
|
231
|
+
|
|
232
|
+
ckpt = model or self.cfg.default_inpaint_ckpt
|
|
233
|
+
wf = build_inpaint_workflow(
|
|
234
|
+
image_name=staged_src,
|
|
235
|
+
mask_name=staged_mask,
|
|
236
|
+
prompt=prompt,
|
|
237
|
+
negative_prompt=negative_prompt,
|
|
238
|
+
steps=steps,
|
|
239
|
+
cfg=cfg,
|
|
240
|
+
seed=seed,
|
|
241
|
+
sampler_name=sampler_name,
|
|
242
|
+
scheduler=scheduler,
|
|
243
|
+
denoise=denoise,
|
|
244
|
+
ckpt_name=ckpt,
|
|
245
|
+
)
|
|
246
|
+
images = self.submit_and_wait(wf, timeout=timeout)
|
|
247
|
+
fn, sub = images[0]
|
|
248
|
+
return self.fetch_image(fn, sub, dest=out)
|
confyui/config.py
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Central configuration for confyui.
|
|
2
|
+
|
|
3
|
+
Every value is overridable through environment variables; defaults expand
|
|
4
|
+
$HOME at runtime so no absolute path is ever hardcoded.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import os
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _env_path(name: str, default: Path) -> Path:
|
|
15
|
+
val = os.environ.get(name)
|
|
16
|
+
if val:
|
|
17
|
+
return Path(val).expanduser()
|
|
18
|
+
return default
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _env_int(name: str, default: int) -> int:
|
|
22
|
+
val = os.environ.get(name)
|
|
23
|
+
if val:
|
|
24
|
+
try:
|
|
25
|
+
return int(val)
|
|
26
|
+
except ValueError:
|
|
27
|
+
return default
|
|
28
|
+
return default
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Config:
|
|
33
|
+
"""Runtime configuration for the confyui daemon and CLI."""
|
|
34
|
+
|
|
35
|
+
comfyui_dir: Path
|
|
36
|
+
python_bin: Path
|
|
37
|
+
host: str
|
|
38
|
+
port: int
|
|
39
|
+
idle_timeout: int
|
|
40
|
+
spawn_timeout: int
|
|
41
|
+
default_ckpt: str
|
|
42
|
+
default_inpaint_ckpt: str
|
|
43
|
+
state_dir: Path
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def base_url(self) -> str:
|
|
47
|
+
return f"http://{self.host}:{self.port}"
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def pid_file(self) -> Path:
|
|
51
|
+
return self.state_dir / "comfyui.pid"
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def log_file(self) -> Path:
|
|
55
|
+
return self.state_dir / "comfyui.log"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def input_dir(self) -> Path:
|
|
59
|
+
return self.comfyui_dir / "input"
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def output_dir(self) -> Path:
|
|
63
|
+
return self.comfyui_dir / "output"
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def checkpoints_dir(self) -> Path:
|
|
67
|
+
return self.comfyui_dir / "models" / "checkpoints"
|
|
68
|
+
|
|
69
|
+
@classmethod
|
|
70
|
+
def from_env(cls) -> Config:
|
|
71
|
+
home = Path.home()
|
|
72
|
+
# Default ComfyUI runtime checkout (overridable via CONFYUI_DIR)
|
|
73
|
+
comfyui_dir = _env_path(
|
|
74
|
+
"CONFYUI_DIR",
|
|
75
|
+
home / ".local" / "share" / "confyui" / "ComfyUI",
|
|
76
|
+
)
|
|
77
|
+
# Default Python is the one inside the ComfyUI venv if it exists,
|
|
78
|
+
# otherwise falls back to current python environment.
|
|
79
|
+
default_py = comfyui_dir / ".venv" / "bin" / "python"
|
|
80
|
+
python_bin = _env_path("CONFYUI_PYTHON", default_py)
|
|
81
|
+
|
|
82
|
+
state_dir = _env_path(
|
|
83
|
+
"CONFYUI_STATE_DIR",
|
|
84
|
+
home / ".cache" / "confyui",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
return cls(
|
|
88
|
+
comfyui_dir=comfyui_dir,
|
|
89
|
+
python_bin=python_bin,
|
|
90
|
+
host=os.environ.get("CONFYUI_HOST", "127.0.0.1"),
|
|
91
|
+
port=_env_int("CONFYUI_PORT", 8188),
|
|
92
|
+
idle_timeout=_env_int("CONFYUI_IDLE_TIMEOUT", 1800),
|
|
93
|
+
spawn_timeout=_env_int("CONFYUI_SPAWN_TIMEOUT", 90),
|
|
94
|
+
default_ckpt=os.environ.get(
|
|
95
|
+
"CONFYUI_DEFAULT_CKPT",
|
|
96
|
+
"Realistic_Vision_V5.1.safetensors",
|
|
97
|
+
),
|
|
98
|
+
default_inpaint_ckpt=os.environ.get(
|
|
99
|
+
"CONFYUI_INPAINT_CKPT",
|
|
100
|
+
"Realistic_Vision_V5.1_fp16-no-ema-inpainting.safetensors",
|
|
101
|
+
),
|
|
102
|
+
state_dir=state_dir,
|
|
103
|
+
)
|
confyui/server.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""ComfyUI process lifecycle manager (daemon lazy start + idle watchdog).
|
|
2
|
+
|
|
3
|
+
Pattern:
|
|
4
|
+
- First generation/edit call checks if ComfyUI is up on `http://{host}:{port}`.
|
|
5
|
+
- If not running, spawns a detached supervisor process in the background.
|
|
6
|
+
- The supervisor runs ComfyUI as a child and monitors activity.
|
|
7
|
+
- A heartbeat file is touched on every request (`~/.cache/confyui/last_activity`).
|
|
8
|
+
- If no heartbeat is received for `idle_timeout` seconds, the supervisor terminates
|
|
9
|
+
ComfyUI and exits, releasing RAM and VRAM automatically.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
import signal
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import httpx
|
|
23
|
+
|
|
24
|
+
from confyui.config import Config
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _read_pid(path: Path) -> int | None:
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
return None
|
|
30
|
+
try:
|
|
31
|
+
return int(path.read_text().strip())
|
|
32
|
+
except (ValueError, OSError):
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _pid_alive(pid: int) -> bool:
|
|
37
|
+
try:
|
|
38
|
+
os.kill(pid, 0)
|
|
39
|
+
return True
|
|
40
|
+
except (OSError, ProcessLookupError):
|
|
41
|
+
return False
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ServerManager:
|
|
45
|
+
"""Manages the background ComfyUI instance."""
|
|
46
|
+
|
|
47
|
+
def __init__(self, config: Config | None = None) -> None:
|
|
48
|
+
self.cfg = config or Config.from_env()
|
|
49
|
+
self.cfg.state_dir.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
self._activity_file = self.cfg.state_dir / "last_activity"
|
|
51
|
+
|
|
52
|
+
def touch_activity(self) -> None:
|
|
53
|
+
"""Record activity so the idle watchdog resets its timer."""
|
|
54
|
+
try:
|
|
55
|
+
self._activity_file.write_text(str(int(time.time())))
|
|
56
|
+
except OSError:
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
def is_up(self) -> bool:
|
|
60
|
+
"""Check if ComfyUI HTTP server is answering."""
|
|
61
|
+
try:
|
|
62
|
+
r = httpx.get(f"{self.cfg.base_url}/system_stats", timeout=1.5)
|
|
63
|
+
return r.status_code == 200
|
|
64
|
+
except (httpx.RequestError, OSError):
|
|
65
|
+
return False
|
|
66
|
+
|
|
67
|
+
def status(self) -> dict[str, Any]:
|
|
68
|
+
pid = _read_pid(self.cfg.pid_file)
|
|
69
|
+
alive = pid is not None and _pid_alive(pid)
|
|
70
|
+
up = self.is_up()
|
|
71
|
+
|
|
72
|
+
idle_remaining = None
|
|
73
|
+
if alive and self._activity_file.is_file():
|
|
74
|
+
try:
|
|
75
|
+
last = int(self._activity_file.read_text().strip())
|
|
76
|
+
elapsed = int(time.time()) - last
|
|
77
|
+
idle_remaining = max(0, self.cfg.idle_timeout - elapsed)
|
|
78
|
+
except (ValueError, OSError):
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
"running": alive,
|
|
83
|
+
"http_up": up,
|
|
84
|
+
"pid": pid,
|
|
85
|
+
"base_url": self.cfg.base_url,
|
|
86
|
+
"idle_timeout_sec": self.cfg.idle_timeout,
|
|
87
|
+
"idle_remaining_sec": idle_remaining,
|
|
88
|
+
"comfyui_dir": str(self.cfg.comfyui_dir),
|
|
89
|
+
"log_file": str(self.cfg.log_file),
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
def stop(self) -> bool:
|
|
93
|
+
"""Stop the supervisor and ComfyUI."""
|
|
94
|
+
pid = _read_pid(self.cfg.pid_file)
|
|
95
|
+
if pid is None or not _pid_alive(pid):
|
|
96
|
+
# Clean up stale pid file if present
|
|
97
|
+
if self.cfg.pid_file.is_file():
|
|
98
|
+
self.cfg.pid_file.unlink(missing_ok=True)
|
|
99
|
+
return False
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
# Terminate the supervisor's process group
|
|
103
|
+
os.killpg(os.getpgid(pid), signal.SIGTERM)
|
|
104
|
+
except (OSError, ProcessLookupError):
|
|
105
|
+
try:
|
|
106
|
+
os.kill(pid, signal.SIGTERM)
|
|
107
|
+
except (OSError, ProcessLookupError):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
# Wait briefly for shutdown
|
|
111
|
+
for _ in range(20):
|
|
112
|
+
if not _pid_alive(pid):
|
|
113
|
+
break
|
|
114
|
+
time.sleep(0.2)
|
|
115
|
+
|
|
116
|
+
self.cfg.pid_file.unlink(missing_ok=True)
|
|
117
|
+
return True
|
|
118
|
+
|
|
119
|
+
def ensure_running(self) -> None:
|
|
120
|
+
"""Make sure ComfyUI is up; if not, spawn it and wait for readiness."""
|
|
121
|
+
if self.is_up():
|
|
122
|
+
self.touch_activity()
|
|
123
|
+
return
|
|
124
|
+
|
|
125
|
+
if not self.cfg.comfyui_dir.is_dir():
|
|
126
|
+
raise FileNotFoundError(
|
|
127
|
+
f"ComfyUI checkout not found at {self.cfg.comfyui_dir}. Set CONFYUI_DIR to the checkout directory.",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
if not self.cfg.python_bin.is_file():
|
|
131
|
+
raise FileNotFoundError(
|
|
132
|
+
f"Python binary not found at {self.cfg.python_bin}. Set CONFYUI_PYTHON to the venv python.",
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Launch the supervisor process detached
|
|
136
|
+
self.touch_activity()
|
|
137
|
+
with open(self.cfg.log_file, "a", encoding="utf-8") as log_fp:
|
|
138
|
+
# Command to run our internal supervisor loop
|
|
139
|
+
cmd = [
|
|
140
|
+
sys.executable,
|
|
141
|
+
"-m",
|
|
142
|
+
"confyui.server",
|
|
143
|
+
"--daemon-supervisor",
|
|
144
|
+
"--comfyui-dir",
|
|
145
|
+
str(self.cfg.comfyui_dir),
|
|
146
|
+
"--python-bin",
|
|
147
|
+
str(self.cfg.python_bin),
|
|
148
|
+
"--host",
|
|
149
|
+
self.cfg.host,
|
|
150
|
+
"--port",
|
|
151
|
+
str(self.cfg.port),
|
|
152
|
+
"--idle-timeout",
|
|
153
|
+
str(self.cfg.idle_timeout),
|
|
154
|
+
"--state-dir",
|
|
155
|
+
str(self.cfg.state_dir),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
proc = subprocess.Popen(
|
|
159
|
+
cmd,
|
|
160
|
+
stdout=log_fp,
|
|
161
|
+
stderr=subprocess.STDOUT,
|
|
162
|
+
start_new_session=True,
|
|
163
|
+
close_fds=True,
|
|
164
|
+
)
|
|
165
|
+
self.cfg.pid_file.write_text(str(proc.pid))
|
|
166
|
+
|
|
167
|
+
# Wait for HTTP readiness
|
|
168
|
+
start = time.time()
|
|
169
|
+
deadline = start + self.cfg.spawn_timeout
|
|
170
|
+
while time.time() < deadline:
|
|
171
|
+
if self.is_up():
|
|
172
|
+
self.touch_activity()
|
|
173
|
+
return
|
|
174
|
+
if not _pid_alive(proc.pid):
|
|
175
|
+
raise RuntimeError(
|
|
176
|
+
f"ComfyUI supervisor died during startup. Check {self.cfg.log_file}",
|
|
177
|
+
)
|
|
178
|
+
time.sleep(0.5)
|
|
179
|
+
|
|
180
|
+
raise TimeoutError(
|
|
181
|
+
f"ComfyUI failed to respond on {self.cfg.base_url} after {self.cfg.spawn_timeout}s. "
|
|
182
|
+
f"Check {self.cfg.log_file}",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ── Supervisor process (runs in the background) ──────────────────────────────
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _run_supervisor(args: list[str]) -> None:
|
|
190
|
+
"""Entry point for the detached supervisor process."""
|
|
191
|
+
import argparse
|
|
192
|
+
|
|
193
|
+
parser = argparse.ArgumentParser()
|
|
194
|
+
parser.add_argument("--daemon-supervisor", action="store_true")
|
|
195
|
+
parser.add_argument("--comfyui-dir", required=True)
|
|
196
|
+
parser.add_argument("--python-bin", required=True)
|
|
197
|
+
parser.add_argument("--host", default="127.0.0.1")
|
|
198
|
+
parser.add_argument("--port", type=int, default=8188)
|
|
199
|
+
parser.add_argument("--idle-timeout", type=int, default=1800)
|
|
200
|
+
parser.add_argument("--state-dir", required=True)
|
|
201
|
+
opts = parser.parse_args(args)
|
|
202
|
+
|
|
203
|
+
state_dir = Path(opts.state_dir)
|
|
204
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
205
|
+
activity_file = state_dir / "last_activity"
|
|
206
|
+
activity_file.write_text(str(int(time.time())))
|
|
207
|
+
|
|
208
|
+
# Spawn ComfyUI as child
|
|
209
|
+
comfy_cmd = [
|
|
210
|
+
opts.python_bin,
|
|
211
|
+
"main.py",
|
|
212
|
+
"--listen",
|
|
213
|
+
opts.host,
|
|
214
|
+
"--port",
|
|
215
|
+
str(opts.port),
|
|
216
|
+
"--disable-auto-launch",
|
|
217
|
+
]
|
|
218
|
+
child = subprocess.Popen(
|
|
219
|
+
comfy_cmd,
|
|
220
|
+
cwd=opts.comfyui_dir,
|
|
221
|
+
stdout=subprocess.PIPE,
|
|
222
|
+
stderr=subprocess.STDOUT,
|
|
223
|
+
text=True,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
def _cleanup(*_unused: Any) -> None:
|
|
227
|
+
try:
|
|
228
|
+
child.terminate()
|
|
229
|
+
child.wait(timeout=10)
|
|
230
|
+
except (subprocess.SubprocessError, OSError):
|
|
231
|
+
try:
|
|
232
|
+
child.kill()
|
|
233
|
+
except (subprocess.SubprocessError, OSError):
|
|
234
|
+
pass
|
|
235
|
+
sys.exit(0)
|
|
236
|
+
|
|
237
|
+
signal.signal(signal.SIGTERM, _cleanup)
|
|
238
|
+
signal.signal(signal.SIGINT, _cleanup)
|
|
239
|
+
|
|
240
|
+
# Watchdog loop
|
|
241
|
+
while True:
|
|
242
|
+
ret = child.poll()
|
|
243
|
+
if ret is not None:
|
|
244
|
+
# Child exited on its own
|
|
245
|
+
sys.exit(ret)
|
|
246
|
+
|
|
247
|
+
now = int(time.time())
|
|
248
|
+
try:
|
|
249
|
+
last = int(activity_file.read_text().strip())
|
|
250
|
+
except (ValueError, OSError):
|
|
251
|
+
last = now
|
|
252
|
+
|
|
253
|
+
if (now - last) >= opts.idle_timeout:
|
|
254
|
+
print(
|
|
255
|
+
f"[confyui-supervisor] Idle timeout ({opts.idle_timeout}s) reached without activity. Shutting down.",
|
|
256
|
+
flush=True,
|
|
257
|
+
)
|
|
258
|
+
_cleanup()
|
|
259
|
+
|
|
260
|
+
time.sleep(10)
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
_run_supervisor(sys.argv[1:])
|
confyui/workflows.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""API graph builders for ComfyUI.
|
|
2
|
+
|
|
3
|
+
Generates the exact node-link JSON payload expected by the ComfyUI POST /prompt
|
|
4
|
+
endpoint for txt2img, img2img, and inpainting.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import random
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _resolve_seed(seed: int | None) -> int:
|
|
14
|
+
if seed is None or seed < 0:
|
|
15
|
+
return random.randint(1, 2**31 - 1)
|
|
16
|
+
return seed
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_txt2img_workflow(
|
|
20
|
+
prompt: str,
|
|
21
|
+
negative_prompt: str = "",
|
|
22
|
+
width: int = 512,
|
|
23
|
+
height: int = 512,
|
|
24
|
+
batch_size: int = 1,
|
|
25
|
+
steps: int = 20,
|
|
26
|
+
cfg: float = 7.0,
|
|
27
|
+
seed: int | None = None,
|
|
28
|
+
sampler_name: str = "euler",
|
|
29
|
+
scheduler: str = "normal",
|
|
30
|
+
denoise: float = 1.0,
|
|
31
|
+
ckpt_name: str = "Realistic_Vision_V5.1.safetensors",
|
|
32
|
+
filename_prefix: str = "confyui_txt2img",
|
|
33
|
+
) -> dict[str, Any]:
|
|
34
|
+
"""Standard txt2img: CheckpointLoader -> CLIPEncode (pos/neg) -> Latent -> KSampler -> VAEDecode -> SaveImage."""
|
|
35
|
+
s = _resolve_seed(seed)
|
|
36
|
+
return {
|
|
37
|
+
"4": {
|
|
38
|
+
"class_type": "CheckpointLoaderSimple",
|
|
39
|
+
"inputs": {"ckpt_name": ckpt_name},
|
|
40
|
+
},
|
|
41
|
+
"5": {
|
|
42
|
+
"class_type": "EmptyLatentImage",
|
|
43
|
+
"inputs": {"width": width, "height": height, "batch_size": batch_size},
|
|
44
|
+
},
|
|
45
|
+
"6": {
|
|
46
|
+
"class_type": "CLIPTextEncode",
|
|
47
|
+
"inputs": {"text": prompt, "clip": ["4", 1]},
|
|
48
|
+
},
|
|
49
|
+
"7": {
|
|
50
|
+
"class_type": "CLIPTextEncode",
|
|
51
|
+
"inputs": {"text": negative_prompt, "clip": ["4", 1]},
|
|
52
|
+
},
|
|
53
|
+
"3": {
|
|
54
|
+
"class_type": "KSampler",
|
|
55
|
+
"inputs": {
|
|
56
|
+
"seed": s,
|
|
57
|
+
"steps": steps,
|
|
58
|
+
"cfg": cfg,
|
|
59
|
+
"sampler_name": sampler_name,
|
|
60
|
+
"scheduler": scheduler,
|
|
61
|
+
"denoise": denoise,
|
|
62
|
+
"model": ["4", 0],
|
|
63
|
+
"positive": ["6", 0],
|
|
64
|
+
"negative": ["7", 0],
|
|
65
|
+
"latent_image": ["5", 0],
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
"8": {
|
|
69
|
+
"class_type": "VAEDecode",
|
|
70
|
+
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
|
71
|
+
},
|
|
72
|
+
"9": {
|
|
73
|
+
"class_type": "SaveImage",
|
|
74
|
+
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
|
|
75
|
+
},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def build_img2img_workflow(
|
|
80
|
+
image_name: str,
|
|
81
|
+
prompt: str,
|
|
82
|
+
negative_prompt: str = "",
|
|
83
|
+
steps: int = 20,
|
|
84
|
+
cfg: float = 7.0,
|
|
85
|
+
seed: int | None = None,
|
|
86
|
+
sampler_name: str = "euler",
|
|
87
|
+
scheduler: str = "normal",
|
|
88
|
+
denoise: float = 0.55,
|
|
89
|
+
ckpt_name: str = "Realistic_Vision_V5.1.safetensors",
|
|
90
|
+
filename_prefix: str = "confyui_img2img",
|
|
91
|
+
) -> dict[str, Any]:
|
|
92
|
+
"""img2img: LoadImage -> VAEEncode -> KSampler(denoise<1.0) -> VAEDecode -> SaveImage."""
|
|
93
|
+
s = _resolve_seed(seed)
|
|
94
|
+
return {
|
|
95
|
+
"4": {
|
|
96
|
+
"class_type": "CheckpointLoaderSimple",
|
|
97
|
+
"inputs": {"ckpt_name": ckpt_name},
|
|
98
|
+
},
|
|
99
|
+
"10": {
|
|
100
|
+
"class_type": "LoadImage",
|
|
101
|
+
"inputs": {"image": image_name},
|
|
102
|
+
},
|
|
103
|
+
"11": {
|
|
104
|
+
"class_type": "VAEEncode",
|
|
105
|
+
"inputs": {"pixels": ["10", 0], "vae": ["4", 2]},
|
|
106
|
+
},
|
|
107
|
+
"6": {
|
|
108
|
+
"class_type": "CLIPTextEncode",
|
|
109
|
+
"inputs": {"text": prompt, "clip": ["4", 1]},
|
|
110
|
+
},
|
|
111
|
+
"7": {
|
|
112
|
+
"class_type": "CLIPTextEncode",
|
|
113
|
+
"inputs": {"text": negative_prompt, "clip": ["4", 1]},
|
|
114
|
+
},
|
|
115
|
+
"3": {
|
|
116
|
+
"class_type": "KSampler",
|
|
117
|
+
"inputs": {
|
|
118
|
+
"seed": s,
|
|
119
|
+
"steps": steps,
|
|
120
|
+
"cfg": cfg,
|
|
121
|
+
"sampler_name": sampler_name,
|
|
122
|
+
"scheduler": scheduler,
|
|
123
|
+
"denoise": denoise,
|
|
124
|
+
"model": ["4", 0],
|
|
125
|
+
"positive": ["6", 0],
|
|
126
|
+
"negative": ["7", 0],
|
|
127
|
+
"latent_image": ["11", 0],
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
"8": {
|
|
131
|
+
"class_type": "VAEDecode",
|
|
132
|
+
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
|
133
|
+
},
|
|
134
|
+
"9": {
|
|
135
|
+
"class_type": "SaveImage",
|
|
136
|
+
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
|
|
137
|
+
},
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_inpaint_workflow(
|
|
142
|
+
image_name: str,
|
|
143
|
+
mask_name: str,
|
|
144
|
+
prompt: str,
|
|
145
|
+
negative_prompt: str = "",
|
|
146
|
+
steps: int = 25,
|
|
147
|
+
cfg: float = 7.0,
|
|
148
|
+
seed: int | None = None,
|
|
149
|
+
sampler_name: str = "euler",
|
|
150
|
+
scheduler: str = "normal",
|
|
151
|
+
denoise: float = 1.0,
|
|
152
|
+
ckpt_name: str = "Realistic_Vision_V5.1_fp16-no-ema-inpainting.safetensors",
|
|
153
|
+
filename_prefix: str = "confyui_inpaint",
|
|
154
|
+
) -> dict[str, Any]:
|
|
155
|
+
"""Inpainting: LoadImage + LoadImageMask -> VAEEncode -> SetLatentNoiseMask -> KSampler -> Decode -> Save."""
|
|
156
|
+
s = _resolve_seed(seed)
|
|
157
|
+
return {
|
|
158
|
+
"4": {
|
|
159
|
+
"class_type": "CheckpointLoaderSimple",
|
|
160
|
+
"inputs": {"ckpt_name": ckpt_name},
|
|
161
|
+
},
|
|
162
|
+
"10": {
|
|
163
|
+
"class_type": "LoadImage",
|
|
164
|
+
"inputs": {"image": image_name},
|
|
165
|
+
},
|
|
166
|
+
"12": {
|
|
167
|
+
"class_type": "LoadImage",
|
|
168
|
+
"inputs": {"image": mask_name},
|
|
169
|
+
},
|
|
170
|
+
"11": {
|
|
171
|
+
"class_type": "VAEEncode",
|
|
172
|
+
"inputs": {"pixels": ["10", 0], "vae": ["4", 2]},
|
|
173
|
+
},
|
|
174
|
+
"13": {
|
|
175
|
+
"class_type": "SetLatentNoiseMask",
|
|
176
|
+
"inputs": {
|
|
177
|
+
"samples": ["11", 0],
|
|
178
|
+
"mask": ["12", 1],
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
"6": {
|
|
182
|
+
"class_type": "CLIPTextEncode",
|
|
183
|
+
"inputs": {"text": prompt, "clip": ["4", 1]},
|
|
184
|
+
},
|
|
185
|
+
"7": {
|
|
186
|
+
"class_type": "CLIPTextEncode",
|
|
187
|
+
"inputs": {"text": negative_prompt, "clip": ["4", 1]},
|
|
188
|
+
},
|
|
189
|
+
"3": {
|
|
190
|
+
"class_type": "KSampler",
|
|
191
|
+
"inputs": {
|
|
192
|
+
"seed": s,
|
|
193
|
+
"steps": steps,
|
|
194
|
+
"cfg": cfg,
|
|
195
|
+
"sampler_name": sampler_name,
|
|
196
|
+
"scheduler": scheduler,
|
|
197
|
+
"denoise": denoise,
|
|
198
|
+
"model": ["4", 0],
|
|
199
|
+
"positive": ["6", 0],
|
|
200
|
+
"negative": ["7", 0],
|
|
201
|
+
"latent_image": ["13", 0],
|
|
202
|
+
},
|
|
203
|
+
},
|
|
204
|
+
"8": {
|
|
205
|
+
"class_type": "VAEDecode",
|
|
206
|
+
"inputs": {"samples": ["3", 0], "vae": ["4", 2]},
|
|
207
|
+
},
|
|
208
|
+
"9": {
|
|
209
|
+
"class_type": "SaveImage",
|
|
210
|
+
"inputs": {"filename_prefix": filename_prefix, "images": ["8", 0]},
|
|
211
|
+
},
|
|
212
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: confyui
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: KpihX sovereign local image generation and editing daemon over ComfyUI (txt2img, img2img, inpaint; XPU-first)
|
|
5
|
+
Author: KpihX
|
|
6
|
+
Author-email: KpihX <kapoivha@gmail.com>
|
|
7
|
+
Requires-Dist: fastapi>=0.159.1
|
|
8
|
+
Requires-Dist: httpx>=0.28.1
|
|
9
|
+
Requires-Dist: huggingface-hub>=2.2.0
|
|
10
|
+
Requires-Dist: pillow>=13.0.0
|
|
11
|
+
Requires-Dist: psutil>=7.2.2
|
|
12
|
+
Requires-Dist: python-dotenv>=1.2.4
|
|
13
|
+
Requires-Dist: rich>=15.0.0
|
|
14
|
+
Requires-Dist: typer>=0.27.3
|
|
15
|
+
Requires-Dist: uvicorn>=0.54.0
|
|
16
|
+
Requires-Dist: torch>=2.8 ; extra == 'cpu'
|
|
17
|
+
Requires-Dist: torchvision>=0.23 ; extra == 'cpu'
|
|
18
|
+
Requires-Dist: torch>=2.8 ; extra == 'cuda'
|
|
19
|
+
Requires-Dist: torchvision>=0.23 ; extra == 'cuda'
|
|
20
|
+
Requires-Dist: transformers>=4.56 ; extra == 'cuda'
|
|
21
|
+
Requires-Dist: accelerate>=1.10.1 ; extra == 'cuda'
|
|
22
|
+
Requires-Dist: huggingface-hub>=0.34.4 ; extra == 'cuda'
|
|
23
|
+
Requires-Dist: safetensors>=0.6.2 ; extra == 'cuda'
|
|
24
|
+
Requires-Dist: torch>=2.8 ; extra == 'xpu'
|
|
25
|
+
Requires-Dist: torchvision>=0.23 ; extra == 'xpu'
|
|
26
|
+
Requires-Dist: transformers>=4.56 ; extra == 'xpu'
|
|
27
|
+
Requires-Dist: accelerate>=1.10.1 ; extra == 'xpu'
|
|
28
|
+
Requires-Dist: huggingface-hub>=0.34.4 ; extra == 'xpu'
|
|
29
|
+
Requires-Dist: safetensors>=0.6.2 ; extra == 'xpu'
|
|
30
|
+
Requires-Python: >=3.12
|
|
31
|
+
Provides-Extra: cpu
|
|
32
|
+
Provides-Extra: cuda
|
|
33
|
+
Provides-Extra: xpu
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# confyui
|
|
37
|
+
|
|
38
|
+
KπX sovereign local image generation and editing daemon over ComfyUI.
|
|
39
|
+
|
|
40
|
+
Hardware-accelerated on Intel Arc (XPU) via PyTorch XPU wheels, with portable CPU and CUDA extras.
|
|
41
|
+
|
|
42
|
+
## Philosophy
|
|
43
|
+
|
|
44
|
+
- **0 Magic:** The CLI talks to ComfyUI's standard REST API via structured JSON graphs.
|
|
45
|
+
- **0 Manual Start:** The first call (`generate`, `edit`, `inpaint`) automatically starts ComfyUI in the background.
|
|
46
|
+
- **0 Wasted RAM:** An idle watchdog shuts ComfyUI down after `CONFYUI_IDLE_TIMEOUT` seconds of inactivity (default: 30 minutes).
|
|
47
|
+
- **0 Hardcoding:** Paths expand `$HOME` at runtime and are 100% overridable via environment variables.
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Installation
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
# Clone or navigate to the repository
|
|
55
|
+
cd ~/Labs/KpihX-Labs/AI/confyui
|
|
56
|
+
|
|
57
|
+
# Intel Arc workstation (XPU)
|
|
58
|
+
make sync-xpu
|
|
59
|
+
# or: uv sync --extra xpu
|
|
60
|
+
|
|
61
|
+
# Generic CPU fallback
|
|
62
|
+
make sync-cpu
|
|
63
|
+
|
|
64
|
+
# NVIDIA CUDA host
|
|
65
|
+
make sync-cuda
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
---
|
|
69
|
+
|
|
70
|
+
## Environment Variables
|
|
71
|
+
|
|
72
|
+
| Variable | Default | Description |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `CONFYUI_DIR` | `~/RAW/comfyui-local/ComfyUI` | Path to the ComfyUI checkout |
|
|
75
|
+
| `CONFYUI_PYTHON` | `$CONFYUI_DIR/.venv/bin/python` | Python interpreter used to run ComfyUI |
|
|
76
|
+
| `CONFYUI_HOST` | `127.0.0.1` | Bind address |
|
|
77
|
+
| `CONFYUI_PORT` | `8188` | Port |
|
|
78
|
+
| `CONFYUI_IDLE_TIMEOUT` | `1800` | Seconds of inactivity before auto-shutdown |
|
|
79
|
+
| `CONFYUI_SPAWN_TIMEOUT` | `90` | Max seconds to wait for daemon readiness |
|
|
80
|
+
| `CONFYUI_STATE_DIR` | `~/.cache/confyui` | Directory for PID, logs, and heartbeat |
|
|
81
|
+
| `CONFYUI_DEFAULT_CKPT` | `Realistic_Vision_V5.1_noVAE.safetensors` | Default checkpoint for txt2img & img2img |
|
|
82
|
+
| `CONFYUI_INPAINT_CKPT` | `Realistic_Vision_V5.1_fp16-no-ema-inpainting.safetensors` | Default inpainting checkpoint |
|
|
83
|
+
|
|
84
|
+
---
|
|
85
|
+
|
|
86
|
+
## Usage
|
|
87
|
+
|
|
88
|
+
### 1. Text-to-Image (`generate`)
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
# Minimal
|
|
92
|
+
uv run confyui generate "a red panda wearing a top hat on the moon, cinematic lighting"
|
|
93
|
+
|
|
94
|
+
# Full options
|
|
95
|
+
uv run confyui generate "a red panda wearing a top hat on the moon" \
|
|
96
|
+
--negative "blurry, low quality, artifacts" \
|
|
97
|
+
--width 512 \
|
|
98
|
+
--height 512 \
|
|
99
|
+
--steps 20 \
|
|
100
|
+
--cfg 7.0 \
|
|
101
|
+
--seed 42 \
|
|
102
|
+
--sampler euler \
|
|
103
|
+
--scheduler normal \
|
|
104
|
+
--model "Realistic_Vision_V5.1_noVAE.safetensors" \
|
|
105
|
+
--out panda_moon.png
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### 2. Image-to-Image (`edit`)
|
|
109
|
+
|
|
110
|
+
Modifies an existing image based on a prompt. The source image is kept and only altered proportionally to `--denoise` (0.0 = unchanged, 0.45-0.60 = balanced modification, 1.0 = total repaint):
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
uv run confyui edit panda_moon.png "make the red panda wear a golden astronaut suit" \
|
|
114
|
+
--denoise 0.50 \
|
|
115
|
+
--steps 20 \
|
|
116
|
+
--out panda_astronaut.png
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### 3. Inpainting (`inpaint`)
|
|
120
|
+
|
|
121
|
+
Repaints only the masked region (white pixels in the mask are regenerated, black pixels preserved):
|
|
122
|
+
|
|
123
|
+
```bash
|
|
124
|
+
uv run confyui inpaint portrait.png torso_mask.png "she wears a long red silk dress" \
|
|
125
|
+
--denoise 1.0 \
|
|
126
|
+
--steps 25 \
|
|
127
|
+
--out portrait_red_dress.png
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
### 4. Daemon Management
|
|
131
|
+
|
|
132
|
+
```bash
|
|
133
|
+
# Inspect status, uptime, PID, and idle time remaining
|
|
134
|
+
uv run confyui status
|
|
135
|
+
|
|
136
|
+
# List installed checkpoints
|
|
137
|
+
uv run confyui models
|
|
138
|
+
|
|
139
|
+
# Stop the daemon immediately to free RAM and VRAM
|
|
140
|
+
uv run confyui stop
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Development
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
make help # Show available targets
|
|
149
|
+
make check # Lint (ruff), format check, compile, pyright, pytest
|
|
150
|
+
make smoke # Fast sanity check of CLI and imports
|
|
151
|
+
make demo # Run a real 12-step test generation
|
|
152
|
+
```
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
confyui/__init__.py,sha256=luOnJjwdG0u0yt2pJ8z22JC4gxCqVaDGo8EYLc6PU3I,403
|
|
2
|
+
confyui/cli.py,sha256=G-5I-xBiqFAaXQPbtM14yDnajVsFeWXz5pvI6xqb_30,8004
|
|
3
|
+
confyui/client.py,sha256=jlZDigK_1Q03G0_NXOQSwi79gQ4-OGcI-wv2kCnZBLA,8450
|
|
4
|
+
confyui/config.py,sha256=RfHwnzDcLEK_QrOH0yNOtZ5bHqyEOwQxJgX-LBIXik4,2869
|
|
5
|
+
confyui/server.py,sha256=VVndWUfpTPHEAhNup6IhD_hPIjzzWbW6xYILQi1WOjQ,8354
|
|
6
|
+
confyui/workflows.py,sha256=sre2ZUmL8cUh25GJIzTAanlwp0KqTm-0OKdgPf8IGss,6415
|
|
7
|
+
confyui-0.1.0.dist-info/WHEEL,sha256=5XVLiN68MV2VpIWvmvgvvELLyDEnIzAqfKmdusfYfHI,80
|
|
8
|
+
confyui-0.1.0.dist-info/entry_points.txt,sha256=b_4i9zeIYa6-uhR85tS-TXnK2Jbw6rVcson32GHIYNk,45
|
|
9
|
+
confyui-0.1.0.dist-info/METADATA,sha256=wQqYwRYuNarlULRKphD03XPHKWeJG-cKThTyEjOGABA,4736
|
|
10
|
+
confyui-0.1.0.dist-info/RECORD,,
|