sdmux 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.
- sdmux/__init__.py +65 -0
- sdmux/backends/__init__.py +17 -0
- sdmux/backends/a1111.py +308 -0
- sdmux/backends/base.py +270 -0
- sdmux/backends/comfyui.py +611 -0
- sdmux/backends/invokeai.py +340 -0
- sdmux/backends/swarmui.py +284 -0
- sdmux/cli.py +218 -0
- sdmux/comfygraph.py +392 -0
- sdmux/errors.py +77 -0
- sdmux/modelinfo/__init__.py +265 -0
- sdmux/modelinfo/civitai.py +123 -0
- sdmux/modelinfo/family.py +375 -0
- sdmux/modelinfo/headers.py +344 -0
- sdmux/modelinfo/lookups.py +254 -0
- sdmux/modelinfo/profiles.py +106 -0
- sdmux/modelinfo/rules.py +331 -0
- sdmux/names.py +254 -0
- sdmux/probe.py +140 -0
- sdmux/resources/profiles.json +53 -0
- sdmux/transport.py +268 -0
- sdmux/types.py +179 -0
- sdmux-0.1.0.dist-info/METADATA +274 -0
- sdmux-0.1.0.dist-info/RECORD +28 -0
- sdmux-0.1.0.dist-info/WHEEL +5 -0
- sdmux-0.1.0.dist-info/entry_points.txt +2 -0
- sdmux-0.1.0.dist-info/licenses/LICENSE +21 -0
- sdmux-0.1.0.dist-info/top_level.txt +1 -0
sdmux/__init__.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
sdmux: one client for every local image generation backend, with checkpoint identification built in.
|
|
3
|
+
|
|
4
|
+
import sdmux
|
|
5
|
+
|
|
6
|
+
sd = sdmux.connect("http://127.0.0.1:8188") # detects ComfyUI, A1111, Forge, SD.Next, SwarmUI or InvokeAI
|
|
7
|
+
r = sd.txt2img("1girl, smile <lora:detail:0.6>", model="waiIllustrious", steps=28, cfg=6,
|
|
8
|
+
sampler="Euler a", scheduler="Karras", seed=42, clip_skip=2)
|
|
9
|
+
r.image.save("out.png")
|
|
10
|
+
|
|
11
|
+
info = sdmux.identify("model.safetensors") # reads only the header
|
|
12
|
+
info.architecture, info.family, info.profile
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
__version__ = "0.1.0"
|
|
16
|
+
|
|
17
|
+
from .errors import AuthRefused, Cancelled, NotReady, Rejected, SdmuxError, TimedOut, Unreachable, Unsupported # noqa: E402
|
|
18
|
+
from .transport import Options # noqa: E402
|
|
19
|
+
from .types import Model, Progress, Request, Result # noqa: E402
|
|
20
|
+
from .comfygraph import Graph, GraphError, NodeSpec # noqa: E402
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def connect(url: str, kind: str | None = None, options: Options | None = None, defaults: str = "profile",
|
|
24
|
+
**option_overrides):
|
|
25
|
+
"""
|
|
26
|
+
Connect to the backend at url and return its client.
|
|
27
|
+
|
|
28
|
+
kind skips detection ("a1111", "forge", "reforge", "sdnext", "comfyui", "swarmui", "invokeai").
|
|
29
|
+
Connection options: username, password, token, headers, connect_timeout, timeout, job_timeout,
|
|
30
|
+
retries, verify_tls, poll_interval. Credentials in the URL (https://user:pass@host) work too.
|
|
31
|
+
|
|
32
|
+
defaults: "profile" (default) fills settings a call leaves out from the model's profile; "fixed" uses
|
|
33
|
+
sdmux's fixed size/steps/cfg and leaves sampler, scheduler and clip skip to the server.
|
|
34
|
+
"""
|
|
35
|
+
from .backends import BACKENDS
|
|
36
|
+
from .probe import detect
|
|
37
|
+
|
|
38
|
+
if kind is None:
|
|
39
|
+
kind = detect(url, options, **option_overrides).kind
|
|
40
|
+
try:
|
|
41
|
+
cls = BACKENDS[kind.lower()]
|
|
42
|
+
except KeyError:
|
|
43
|
+
raise ValueError(f"Unknown backend kind {kind!r}. Known: {', '.join(sorted(BACKENDS))}") from None
|
|
44
|
+
return cls(url, options, defaults=defaults, **option_overrides)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detect(url: str, **option_overrides):
|
|
48
|
+
"""Which backend answers at url. Returns a Detection (kind, evidence, version, tried)."""
|
|
49
|
+
from .probe import detect as _detect
|
|
50
|
+
|
|
51
|
+
return _detect(url, **option_overrides)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def identify(path, online: bool = False, **kwargs):
|
|
55
|
+
"""Identify a model file from its header (and sidecars, metadata, name; Civitai when online=True)."""
|
|
56
|
+
from .modelinfo import identify as _identify
|
|
57
|
+
|
|
58
|
+
return _identify(path, online=online, **kwargs)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = [
|
|
62
|
+
"connect", "detect", "identify", "Options", "Graph", "GraphError", "NodeSpec", "Request", "Result", "Progress", "Model",
|
|
63
|
+
"SdmuxError", "Unreachable", "TimedOut", "AuthRefused", "NotReady", "Rejected", "Cancelled", "Unsupported",
|
|
64
|
+
"__version__",
|
|
65
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from .a1111 import A1111, Forge, SDNext
|
|
2
|
+
from .base import Backend
|
|
3
|
+
from .comfyui import ComfyUI
|
|
4
|
+
from .invokeai import InvokeAI
|
|
5
|
+
from .swarmui import SwarmUI
|
|
6
|
+
|
|
7
|
+
BACKENDS: dict[str, type[Backend]] = {
|
|
8
|
+
"a1111": A1111,
|
|
9
|
+
"forge": Forge,
|
|
10
|
+
"reforge": Forge,
|
|
11
|
+
"sdnext": SDNext,
|
|
12
|
+
"comfyui": ComfyUI,
|
|
13
|
+
"swarmui": SwarmUI,
|
|
14
|
+
"invokeai": InvokeAI,
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
__all__ = ["Backend", "A1111", "Forge", "SDNext", "ComfyUI", "SwarmUI", "InvokeAI", "BACKENDS"]
|
sdmux/backends/a1111.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AUTOMATIC1111, Forge / reForge and SD.Next, through the /sdapi/v1 API (A1111 and Forge need --api).
|
|
3
|
+
|
|
4
|
+
Native escape hatch: api(path, payload) posts any body to any /sdapi/v1 route, and
|
|
5
|
+
generate() merges Request.extra into the payload, so every field the WebUI accepts
|
|
6
|
+
(alwayson_scripts, hires-fix, refiner, ...) is reachable.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
|
|
14
|
+
from ..errors import Cancelled, Rejected, SdmuxError
|
|
15
|
+
from ..names import canonical_scheduler, extract_loras, lora_tags, pick_native, sampler_id
|
|
16
|
+
from ..types import IMG2IMG, INPAINT, TXT2IMG, Model, Progress, Request, Result, load_image, to_b64
|
|
17
|
+
from .base import Backend
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class A1111(Backend):
|
|
21
|
+
kind = "a1111"
|
|
22
|
+
label = "AUTOMATIC1111"
|
|
23
|
+
default_url = "http://127.0.0.1:7860"
|
|
24
|
+
|
|
25
|
+
#: When False (sdmux's choice), a model chosen with model= stays loaded after the call, like picking it
|
|
26
|
+
#: in the UI. A1111's own default (True) switches back after every call, reloading weights twice.
|
|
27
|
+
restore_settings = False
|
|
28
|
+
|
|
29
|
+
def __init__(self, url=None, options=None, **kw) -> None:
|
|
30
|
+
super().__init__(url, options, **kw)
|
|
31
|
+
self._schedulers: list[str] | None = None
|
|
32
|
+
|
|
33
|
+
# Listings -------------------------------------------------------------
|
|
34
|
+
def models(self) -> list[Model]:
|
|
35
|
+
return [
|
|
36
|
+
Model(
|
|
37
|
+
name=m.get("model_name") or m.get("title"),
|
|
38
|
+
title=m.get("title"),
|
|
39
|
+
path=m.get("filename"),
|
|
40
|
+
hash=m.get("hash"),
|
|
41
|
+
sha256=m.get("sha256"),
|
|
42
|
+
raw=m,
|
|
43
|
+
)
|
|
44
|
+
for m in self.http.get_json("/sdapi/v1/sd-models")
|
|
45
|
+
]
|
|
46
|
+
|
|
47
|
+
def loras(self) -> list[Model]:
|
|
48
|
+
response = self.http.request("GET", "/sdapi/v1/loras")
|
|
49
|
+
if response.status_code == 404:
|
|
50
|
+
return []
|
|
51
|
+
items = self.http.json(self.http.check(response, "GET /sdapi/v1/loras"))
|
|
52
|
+
loras = []
|
|
53
|
+
for lora in items:
|
|
54
|
+
metadata = lora.get("metadata") or {}
|
|
55
|
+
loras.append(
|
|
56
|
+
Model(
|
|
57
|
+
name=lora.get("name") or lora.get("alias"),
|
|
58
|
+
title=lora.get("alias"),
|
|
59
|
+
path=lora.get("path"),
|
|
60
|
+
sha256=metadata.get("sshs_model_hash") if isinstance(metadata, dict) else None,
|
|
61
|
+
kind="lora",
|
|
62
|
+
raw=lora,
|
|
63
|
+
)
|
|
64
|
+
)
|
|
65
|
+
return loras
|
|
66
|
+
|
|
67
|
+
def samplers(self) -> list[str]:
|
|
68
|
+
return [s["name"] for s in self.http.get_json("/sdapi/v1/samplers")]
|
|
69
|
+
|
|
70
|
+
def schedulers(self) -> list[str]:
|
|
71
|
+
"""Scheduler labels. [] where there is no /schedulers route (A1111 before 1.10, SD.Next)."""
|
|
72
|
+
if self._schedulers is None:
|
|
73
|
+
response = self.http.request("GET", "/sdapi/v1/schedulers")
|
|
74
|
+
if response.status_code >= 400:
|
|
75
|
+
self._schedulers = [] # 404 before A1111 1.10
|
|
76
|
+
else:
|
|
77
|
+
items = self.http.json(self.http.check(response, "GET /sdapi/v1/schedulers"))
|
|
78
|
+
self._schedulers = [s.get("label") or s.get("name") for s in items]
|
|
79
|
+
return list(self._schedulers)
|
|
80
|
+
|
|
81
|
+
def info(self) -> dict:
|
|
82
|
+
info = {"backend": self.kind, "url": self.url}
|
|
83
|
+
response = self.http.request("GET", "/sdapi/v1/version", timeout=10)
|
|
84
|
+
if response.status_code == 200:
|
|
85
|
+
try:
|
|
86
|
+
info["version"] = response.json()
|
|
87
|
+
except ValueError:
|
|
88
|
+
pass
|
|
89
|
+
try:
|
|
90
|
+
options = self.http.get_json("/sdapi/v1/options", timeout=10)
|
|
91
|
+
info["model"] = options.get("sd_model_checkpoint")
|
|
92
|
+
except Rejected:
|
|
93
|
+
pass
|
|
94
|
+
return info
|
|
95
|
+
|
|
96
|
+
def current_model(self) -> str | None:
|
|
97
|
+
"""The checkpoint loaded on the server, which a request without model= runs on."""
|
|
98
|
+
try:
|
|
99
|
+
return self.http.get_json("/sdapi/v1/options", timeout=30).get("sd_model_checkpoint") or None
|
|
100
|
+
except SdmuxError:
|
|
101
|
+
return None
|
|
102
|
+
|
|
103
|
+
# Native ---------------------------------------------------------------
|
|
104
|
+
def api(self, path: str, payload: dict | None = None, method: str = "POST", timeout: float | None = None):
|
|
105
|
+
"""Call any /sdapi/v1 route (path may omit the prefix) and return its JSON."""
|
|
106
|
+
if not path.startswith("/"):
|
|
107
|
+
path = "/sdapi/v1/" + path
|
|
108
|
+
response = self.http.request(method, path, json=payload, timeout=timeout)
|
|
109
|
+
return self.http.json(self.http.check(response, f"{method} {path}"))
|
|
110
|
+
|
|
111
|
+
# Generation -----------------------------------------------------------
|
|
112
|
+
def _sampler_fields(self, request: Request) -> dict:
|
|
113
|
+
from ..names import canonical_sampler
|
|
114
|
+
|
|
115
|
+
fields = {}
|
|
116
|
+
folded = None
|
|
117
|
+
if request.sampler:
|
|
118
|
+
_, folded = canonical_sampler(request.sampler)
|
|
119
|
+
native = pick_native(request.sampler, self.samplers(), sampler_id)
|
|
120
|
+
fields["sampler_name"] = native or request.sampler
|
|
121
|
+
scheduler = request.scheduler or folded
|
|
122
|
+
if scheduler:
|
|
123
|
+
schedulers = self.schedulers()
|
|
124
|
+
if schedulers:
|
|
125
|
+
fields["scheduler"] = pick_native(scheduler, schedulers, canonical_scheduler) or scheduler
|
|
126
|
+
elif request.sampler:
|
|
127
|
+
# No scheduler field (old A1111): A1111 reads a scheduler glued to the sampler name.
|
|
128
|
+
if canonical_scheduler(scheduler) not in ("automatic", "normal") and "sampler_name" in fields:
|
|
129
|
+
from ..names import a1111_scheduler_label
|
|
130
|
+
|
|
131
|
+
fields["sampler_name"] = f"{fields['sampler_name']} {a1111_scheduler_label(scheduler)}"
|
|
132
|
+
return fields
|
|
133
|
+
|
|
134
|
+
def build_payload(self, request: Request) -> dict:
|
|
135
|
+
"""The /sdapi/v1/txt2img or /img2img body for a request (LoRA tags stay in the prompt)."""
|
|
136
|
+
prompt, loras = extract_loras(request.prompt)
|
|
137
|
+
if loras:
|
|
138
|
+
available = self.loras()
|
|
139
|
+
_, matched = self.resolve_loras(request.prompt, available)
|
|
140
|
+
prompt = " ".join(p for p in (prompt, lora_tags([(self.lora_name(m), w) for m, w in matched])) if p)
|
|
141
|
+
overrides: dict = {}
|
|
142
|
+
if request.model:
|
|
143
|
+
overrides["sd_model_checkpoint"] = self.resolve_model(request.model).title or request.model
|
|
144
|
+
if request.clip_skip:
|
|
145
|
+
overrides["CLIP_stop_at_last_layers"] = int(request.clip_skip)
|
|
146
|
+
if request.vae:
|
|
147
|
+
overrides["sd_vae"] = request.vae
|
|
148
|
+
payload = {
|
|
149
|
+
"prompt": prompt,
|
|
150
|
+
"negative_prompt": request.negative,
|
|
151
|
+
"width": request.width,
|
|
152
|
+
"height": request.height,
|
|
153
|
+
"steps": request.steps,
|
|
154
|
+
"cfg_scale": request.cfg,
|
|
155
|
+
"seed": request.seed if request.seed is not None else -1,
|
|
156
|
+
"batch_size": request.batch_size,
|
|
157
|
+
"n_iter": 1,
|
|
158
|
+
"send_images": True,
|
|
159
|
+
"save_images": False,
|
|
160
|
+
}
|
|
161
|
+
sampler_fields = self._sampler_fields(request)
|
|
162
|
+
overrides.update(sampler_fields.pop("override_settings", {}))
|
|
163
|
+
payload.update(sampler_fields)
|
|
164
|
+
if overrides:
|
|
165
|
+
payload["override_settings"] = overrides
|
|
166
|
+
payload["override_settings_restore_afterwards"] = self.restore_settings
|
|
167
|
+
if request.mode in (IMG2IMG, INPAINT):
|
|
168
|
+
payload["init_images"] = [to_b64(request.image.convert("RGB"))]
|
|
169
|
+
payload["denoising_strength"] = request.denoise
|
|
170
|
+
payload["resize_mode"] = 0
|
|
171
|
+
if request.mode == INPAINT:
|
|
172
|
+
payload.update(
|
|
173
|
+
{
|
|
174
|
+
"mask": to_b64(request.mask.convert("L")),
|
|
175
|
+
"mask_blur": request.mask_blur,
|
|
176
|
+
"inpainting_fill": 1, # start from the original pixels under the mask
|
|
177
|
+
"inpaint_full_res": request.inpaint_full_res,
|
|
178
|
+
"inpaint_full_res_padding": request.inpaint_padding,
|
|
179
|
+
"inpainting_mask_invert": 0, # white = repaint
|
|
180
|
+
}
|
|
181
|
+
)
|
|
182
|
+
payload.update(request.extra or {})
|
|
183
|
+
return payload
|
|
184
|
+
|
|
185
|
+
def _generate(self, request: Request) -> Result:
|
|
186
|
+
payload = self.build_payload(request)
|
|
187
|
+
route = "/sdapi/v1/txt2img" if request.mode == TXT2IMG else "/sdapi/v1/img2img"
|
|
188
|
+
with self._lock:
|
|
189
|
+
response = self.http.request("POST", route, json=payload, timeout=self.options.job_timeout)
|
|
190
|
+
if response.status_code >= 400:
|
|
191
|
+
raise Rejected(f"{self.label} rejected the request ({response.status_code}): {_error_text(response)}",
|
|
192
|
+
backend=self.kind, details=response.text)
|
|
193
|
+
body = self.http.json(response)
|
|
194
|
+
images_b64 = body.get("images") or []
|
|
195
|
+
try:
|
|
196
|
+
info = json.loads(body.get("info") or "{}")
|
|
197
|
+
except (TypeError, ValueError):
|
|
198
|
+
info = {}
|
|
199
|
+
if not images_b64:
|
|
200
|
+
if self._cancel.is_set() or info.get("interrupted"):
|
|
201
|
+
raise Cancelled("Interrupted.", backend=self.kind)
|
|
202
|
+
raise Rejected(f"{self.label} returned no image.", backend=self.kind, details=body)
|
|
203
|
+
infotexts = info.get("infotexts") or []
|
|
204
|
+
seeds = info.get("all_seeds") or ([info["seed"]] if "seed" in info else [])
|
|
205
|
+
images = []
|
|
206
|
+
# Extensions may append extra images (masks, grids) beyond the batch; keep only the batch.
|
|
207
|
+
for index, data in enumerate(images_b64[: max(request.batch_size, 1)]):
|
|
208
|
+
image = load_image(data)
|
|
209
|
+
if index < len(infotexts):
|
|
210
|
+
image.info["parameters"] = infotexts[index]
|
|
211
|
+
images.append(image)
|
|
212
|
+
if self._cancel.is_set():
|
|
213
|
+
# A1111 answers an interrupted job with the partially denoised image; it is kept in details.
|
|
214
|
+
raise Cancelled("Interrupted.", backend=self.kind, details={"partial_images": images, "info": info})
|
|
215
|
+
return Result(
|
|
216
|
+
images=images,
|
|
217
|
+
seed=seeds[0] if seeds else info.get("seed"),
|
|
218
|
+
seeds=list(seeds),
|
|
219
|
+
parameters=infotexts[0] if infotexts else "",
|
|
220
|
+
backend=self.kind,
|
|
221
|
+
raw={"info": info, "parameters": body.get("parameters")},
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
# Control --------------------------------------------------------------
|
|
225
|
+
def progress(self) -> Progress:
|
|
226
|
+
r = self.http.get_json("/sdapi/v1/progress", params={"skip_current_image": "true"}, timeout=10)
|
|
227
|
+
state = r.get("state") or {}
|
|
228
|
+
active = bool(state.get("job")) or bool(state.get("job_count"))
|
|
229
|
+
step, steps = state.get("sampling_step") or 0, state.get("sampling_steps") or 0
|
|
230
|
+
eta = r.get("eta_relative")
|
|
231
|
+
return Progress(
|
|
232
|
+
fraction=float(r.get("progress") or 0.0) if active else 0.0,
|
|
233
|
+
step=step if active else None,
|
|
234
|
+
steps=steps if active else None,
|
|
235
|
+
eta=round(eta, 1) if active and eta and eta > 0 else None,
|
|
236
|
+
active=active,
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
def interrupt(self) -> None:
|
|
240
|
+
self._cancel.set()
|
|
241
|
+
self.http.check(self.http.request("POST", "/sdapi/v1/interrupt", timeout=10), "interrupt")
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _error_text(response) -> str:
|
|
245
|
+
try:
|
|
246
|
+
body = response.json()
|
|
247
|
+
except ValueError:
|
|
248
|
+
return response.text[:1000]
|
|
249
|
+
if isinstance(body, dict):
|
|
250
|
+
return str(body.get("errors") or body.get("detail") or body.get("error") or body)[:1000]
|
|
251
|
+
return str(body)[:1000]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class Forge(A1111):
|
|
255
|
+
"""Stable Diffusion WebUI Forge and reForge: the A1111 API with different built-in scripts."""
|
|
256
|
+
|
|
257
|
+
kind = "forge"
|
|
258
|
+
label = "Forge"
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class SDNext(A1111):
|
|
262
|
+
"""
|
|
263
|
+
SD.Next: the same /sdapi/v1 routes; it serves the API without --api.
|
|
264
|
+
|
|
265
|
+
SD.Next runs diffusers schedulers: the sampler list holds them all, and the A1111 scheduler is
|
|
266
|
+
approximated by its "Sigma algorithm" setting (schedulers_sigma: default, karras, exponential,
|
|
267
|
+
betas, lambdas, flowmatch). Its /sdapi/v1/schedulers lists samplers, not schedulers.
|
|
268
|
+
"""
|
|
269
|
+
|
|
270
|
+
kind = "sdnext"
|
|
271
|
+
label = "SD.Next"
|
|
272
|
+
SIGMAS = ("default", "karras", "betas", "exponential", "lambdas", "flowmatch")
|
|
273
|
+
|
|
274
|
+
def schedulers(self) -> list[str]:
|
|
275
|
+
return list(self.SIGMAS)
|
|
276
|
+
|
|
277
|
+
def _sampler_fields(self, request: Request) -> dict:
|
|
278
|
+
from ..names import canonical_sampler
|
|
279
|
+
|
|
280
|
+
fields: dict = {}
|
|
281
|
+
folded = None
|
|
282
|
+
if request.sampler:
|
|
283
|
+
_, folded = canonical_sampler(request.sampler)
|
|
284
|
+
fields["sampler_name"] = pick_native(request.sampler, self.samplers(), sampler_id) or request.sampler
|
|
285
|
+
scheduler = request.scheduler or folded
|
|
286
|
+
if scheduler:
|
|
287
|
+
cid = canonical_scheduler(scheduler)
|
|
288
|
+
sigma = cid if cid in self.SIGMAS else ("betas" if cid == "beta" else "default")
|
|
289
|
+
fields["override_settings"] = {"schedulers_sigma": sigma}
|
|
290
|
+
return fields
|
|
291
|
+
|
|
292
|
+
def build_payload(self, request: Request) -> dict:
|
|
293
|
+
payload = super().build_payload(request)
|
|
294
|
+
if request.clip_skip:
|
|
295
|
+
payload["clip_skip"] = int(request.clip_skip)
|
|
296
|
+
# SD.Next ignores sd_model_checkpoint in override_settings when no model is loaded yet
|
|
297
|
+
# ("Aborted: op=process model not loaded"), so the model is switched through /options instead.
|
|
298
|
+
overrides = payload.get("override_settings") or {}
|
|
299
|
+
wanted = overrides.pop("sd_model_checkpoint", None)
|
|
300
|
+
if wanted:
|
|
301
|
+
current = self.http.get_json("/sdapi/v1/options", timeout=30).get("sd_model_checkpoint") or ""
|
|
302
|
+
if wanted not in (current, current.split(" [")[0]):
|
|
303
|
+
self.http.check(self.http.request("POST", "/sdapi/v1/options", json={"sd_model_checkpoint": wanted},
|
|
304
|
+
timeout=self.options.job_timeout), "model switch")
|
|
305
|
+
if not overrides:
|
|
306
|
+
payload.pop("override_settings", None)
|
|
307
|
+
payload.pop("override_settings_restore_afterwards", None)
|
|
308
|
+
return payload
|
sdmux/backends/base.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
"""The interface every backend implements, and the parts they share."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from ..errors import NotReady, Rejected
|
|
10
|
+
from ..errors import SdmuxError
|
|
11
|
+
from ..names import canonical_scheduler, extract_loras, lora_stem, match_choice
|
|
12
|
+
from ..transport import Options, Transport
|
|
13
|
+
from ..types import IMG2IMG, INPAINT, TXT2IMG, Model, Progress, Request, Result, load_image
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..modelinfo import ModelInfo, Profile
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Backend:
|
|
20
|
+
"""
|
|
21
|
+
A connection to one image generation server.
|
|
22
|
+
|
|
23
|
+
Listings: models(), loras(), samplers(), schedulers(), info().
|
|
24
|
+
Generation: txt2img(), img2img(), inpaint(), or generate(Request). They raise on failure.
|
|
25
|
+
Control: progress(), interrupt().
|
|
26
|
+
Identification: identify(model), profile(model).
|
|
27
|
+
|
|
28
|
+
Every backend also exposes its native request path (see each subclass) so nothing the
|
|
29
|
+
unified surface lacks blocks you.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
kind = ""
|
|
33
|
+
label = ""
|
|
34
|
+
default_url = "http://127.0.0.1:7860"
|
|
35
|
+
|
|
36
|
+
#: Fixed values for settings neither the caller nor the model profile provides.
|
|
37
|
+
FALLBACK = {"width": 512, "height": 512, "steps": 20, "cfg": 7.0}
|
|
38
|
+
|
|
39
|
+
def __init__(self, url: str | None = None, options: Options | None = None, defaults: str = "profile",
|
|
40
|
+
**option_overrides) -> None:
|
|
41
|
+
"""
|
|
42
|
+
defaults: "profile" (settings a call leaves out come from the model's profile: size, steps, cfg,
|
|
43
|
+
sampler, scheduler, clip skip) or "fixed" (sdmux's FALLBACK size, steps and cfg; sampler, scheduler
|
|
44
|
+
and clip skip are left to the server). Whatever is still missing after the profile gets FALLBACK.
|
|
45
|
+
"""
|
|
46
|
+
if defaults not in ("profile", "fixed"):
|
|
47
|
+
raise ValueError("defaults must be 'profile' or 'fixed'.")
|
|
48
|
+
self.defaults = defaults
|
|
49
|
+
self._profiles: dict = {}
|
|
50
|
+
self.options = Options.build(options, **option_overrides)
|
|
51
|
+
self.http = Transport(url or self.default_url, self.options, self.label)
|
|
52
|
+
self.options = self.http.options
|
|
53
|
+
self._lock = threading.Lock()
|
|
54
|
+
self._cancel = threading.Event()
|
|
55
|
+
|
|
56
|
+
# Basics ---------------------------------------------------------------
|
|
57
|
+
@property
|
|
58
|
+
def url(self) -> str:
|
|
59
|
+
return self.http.base_url
|
|
60
|
+
|
|
61
|
+
def __repr__(self) -> str:
|
|
62
|
+
return f"<sdmux.{type(self).__name__} {self.kind} {self.url}>"
|
|
63
|
+
|
|
64
|
+
def close(self) -> None:
|
|
65
|
+
self.http.close()
|
|
66
|
+
|
|
67
|
+
def __enter__(self):
|
|
68
|
+
return self
|
|
69
|
+
|
|
70
|
+
def __exit__(self, *exc):
|
|
71
|
+
self.close()
|
|
72
|
+
|
|
73
|
+
# Listings -------------------------------------------------------------
|
|
74
|
+
def models(self) -> list[Model]:
|
|
75
|
+
raise NotImplementedError
|
|
76
|
+
|
|
77
|
+
def loras(self) -> list[Model]:
|
|
78
|
+
return []
|
|
79
|
+
|
|
80
|
+
def samplers(self) -> list[str]:
|
|
81
|
+
return []
|
|
82
|
+
|
|
83
|
+
def schedulers(self) -> list[str]:
|
|
84
|
+
return []
|
|
85
|
+
|
|
86
|
+
def info(self) -> dict:
|
|
87
|
+
return {"backend": self.kind, "url": self.url}
|
|
88
|
+
|
|
89
|
+
def ready(self) -> dict:
|
|
90
|
+
"""
|
|
91
|
+
A slim readiness check: {"ready": bool, "problems": [str], "info": {...}}.
|
|
92
|
+
|
|
93
|
+
Raises Unreachable / AuthRefused when the server cannot be talked to at all.
|
|
94
|
+
"""
|
|
95
|
+
problems = []
|
|
96
|
+
info = self.info()
|
|
97
|
+
if not self.models():
|
|
98
|
+
problems.append(f"{self.label} lists no models.")
|
|
99
|
+
return {"ready": not problems, "problems": problems, "info": info}
|
|
100
|
+
|
|
101
|
+
# Generation -----------------------------------------------------------
|
|
102
|
+
def txt2img(self, prompt: str, negative: str = "", **kwargs) -> Result:
|
|
103
|
+
return self.generate(Request(prompt=prompt, negative=negative, mode=TXT2IMG, **kwargs))
|
|
104
|
+
|
|
105
|
+
def img2img(self, image, prompt: str, negative: str = "", denoise: float = 0.75, **kwargs) -> Result:
|
|
106
|
+
return self.generate(Request(prompt=prompt, negative=negative, mode=IMG2IMG, image=image, denoise=denoise, **kwargs))
|
|
107
|
+
|
|
108
|
+
def inpaint(self, image, mask, prompt: str, negative: str = "", denoise: float = 0.75, **kwargs) -> Result:
|
|
109
|
+
"""White mask pixels are repainted, black ones kept."""
|
|
110
|
+
return self.generate(
|
|
111
|
+
Request(prompt=prompt, negative=negative, mode=INPAINT, image=image, mask=mask, denoise=denoise, **kwargs)
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def generate(self, request: Request) -> Result:
|
|
115
|
+
request = self._prepare(request)
|
|
116
|
+
filled = self._fill_defaults(request)
|
|
117
|
+
self._cancel.clear()
|
|
118
|
+
result = self._generate(request)
|
|
119
|
+
result.defaults = filled
|
|
120
|
+
return result
|
|
121
|
+
|
|
122
|
+
def current_model(self) -> str | None:
|
|
123
|
+
"""The model a request without model= runs on. Most backends use the first listed one."""
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def _profile_for(self, model):
|
|
127
|
+
key = model.name if isinstance(model, Model) else (model or self.current_model() or "")
|
|
128
|
+
if key not in self._profiles:
|
|
129
|
+
try:
|
|
130
|
+
resolved = self.resolve_model(model or self.current_model())
|
|
131
|
+
self._profiles[key] = (resolved.name, self.identify(resolved).profile)
|
|
132
|
+
except (SdmuxError, OSError, ValueError):
|
|
133
|
+
self._profiles[key] = (None, None)
|
|
134
|
+
return self._profiles[key]
|
|
135
|
+
|
|
136
|
+
def _fill_defaults(self, request: Request) -> dict:
|
|
137
|
+
"""Fill settings the caller left as None; returns {field: (value, source)}."""
|
|
138
|
+
filled: dict = {}
|
|
139
|
+
|
|
140
|
+
def put(name, value, source):
|
|
141
|
+
if getattr(request, name) is None and value is not None:
|
|
142
|
+
setattr(request, name, value)
|
|
143
|
+
filled[name] = (value, source)
|
|
144
|
+
|
|
145
|
+
if self.defaults == "profile":
|
|
146
|
+
name, profile = self._profile_for(request.model)
|
|
147
|
+
if profile is not None:
|
|
148
|
+
source = f"profile:{name}"
|
|
149
|
+
if request.mode == TXT2IMG:
|
|
150
|
+
put("width", profile.width, source)
|
|
151
|
+
put("height", profile.height, source)
|
|
152
|
+
put("steps", profile.steps, source)
|
|
153
|
+
put("cfg", profile.cfg, source)
|
|
154
|
+
if request.sampler is None and request.scheduler is None:
|
|
155
|
+
put("sampler", profile.sampler, source)
|
|
156
|
+
scheduler = profile.scheduler if profile.scheduler and profile.scheduler.lower() != "automatic" else None
|
|
157
|
+
put("scheduler", scheduler, source)
|
|
158
|
+
put("clip_skip", profile.clip_skip, source)
|
|
159
|
+
# Size, steps and cfg are always sent, so whatever is still missing gets sdmux's fixed values.
|
|
160
|
+
for name, value in self.FALLBACK.items():
|
|
161
|
+
put(name, value, "fallback")
|
|
162
|
+
return filled
|
|
163
|
+
|
|
164
|
+
def _generate(self, request: Request) -> Result:
|
|
165
|
+
raise NotImplementedError
|
|
166
|
+
|
|
167
|
+
def _prepare(self, request: Request) -> Request:
|
|
168
|
+
"""Validate the request and load its images once, so backends receive PIL images."""
|
|
169
|
+
if request.mode not in (TXT2IMG, IMG2IMG, INPAINT):
|
|
170
|
+
raise ValueError(f"Unknown mode {request.mode!r}")
|
|
171
|
+
if request.mode in (IMG2IMG, INPAINT):
|
|
172
|
+
if request.image is None:
|
|
173
|
+
raise ValueError(f"{request.mode} needs an image.")
|
|
174
|
+
request.image = load_image(request.image)
|
|
175
|
+
if request.width is None:
|
|
176
|
+
request.width = request.image.width
|
|
177
|
+
if request.height is None:
|
|
178
|
+
request.height = request.image.height
|
|
179
|
+
if request.mode == INPAINT:
|
|
180
|
+
if request.mask is None:
|
|
181
|
+
raise ValueError("inpaint needs a mask (white = repaint).")
|
|
182
|
+
request.mask = load_image(request.mask)
|
|
183
|
+
if request.scheduler and canonical_scheduler(request.scheduler) == "automatic":
|
|
184
|
+
request.scheduler = None # the backend's own default
|
|
185
|
+
if request.patch is not None and self.kind != "comfyui":
|
|
186
|
+
from ..errors import Unsupported
|
|
187
|
+
|
|
188
|
+
raise Unsupported(f"patch= edits a ComfyUI graph; {self.label} has none.", backend=self.kind)
|
|
189
|
+
if request.batch_size < 1:
|
|
190
|
+
raise ValueError("batch_size must be at least 1.")
|
|
191
|
+
return request
|
|
192
|
+
|
|
193
|
+
# Control --------------------------------------------------------------
|
|
194
|
+
def progress(self) -> Progress:
|
|
195
|
+
return Progress()
|
|
196
|
+
|
|
197
|
+
def interrupt(self) -> None:
|
|
198
|
+
raise NotImplementedError
|
|
199
|
+
|
|
200
|
+
# Shared helpers -------------------------------------------------------
|
|
201
|
+
def resolve_model(self, wanted: "str | Model | None", models: list[Model] | None = None) -> Model:
|
|
202
|
+
"""The listed model matching wanted (loosely), or the first one when wanted is None. A Model passes through."""
|
|
203
|
+
if isinstance(wanted, Model):
|
|
204
|
+
return wanted
|
|
205
|
+
models = self.models() if models is None else models
|
|
206
|
+
if not models:
|
|
207
|
+
raise NotReady(f"{self.label} lists no models.", backend=self.kind)
|
|
208
|
+
if not wanted:
|
|
209
|
+
return models[0]
|
|
210
|
+
for attr in ("name", "title", "path"):
|
|
211
|
+
names = [getattr(m, attr) for m in models if getattr(m, attr)]
|
|
212
|
+
found = match_choice(wanted, names)
|
|
213
|
+
if found:
|
|
214
|
+
return next(m for m in models if getattr(m, attr) == found)
|
|
215
|
+
raise Rejected(
|
|
216
|
+
f"{self.label} has no model matching {wanted!r}. It lists: "
|
|
217
|
+
+ ", ".join(m.name for m in models[:20]) + (" ..." if len(models) > 20 else ""),
|
|
218
|
+
backend=self.kind,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def resolve_loras(self, prompt: str, available: list[Model] | None = None) -> tuple[str, list[tuple[Model, float]]]:
|
|
222
|
+
"""Strip <lora:...> tags from prompt and match each against the backend's LoRAs. Unknown LoRAs raise Rejected."""
|
|
223
|
+
cleaned, wanted = extract_loras(prompt)
|
|
224
|
+
if not wanted:
|
|
225
|
+
return cleaned, []
|
|
226
|
+
available = self.loras() if available is None else available
|
|
227
|
+
found = []
|
|
228
|
+
for name, weight in wanted:
|
|
229
|
+
match = None
|
|
230
|
+
for attr in ("name", "title", "path"):
|
|
231
|
+
names = [getattr(m, attr) for m in available if getattr(m, attr)]
|
|
232
|
+
hit = match_choice(name, names)
|
|
233
|
+
if hit:
|
|
234
|
+
match = next(m for m in available if getattr(m, attr) == hit)
|
|
235
|
+
break
|
|
236
|
+
if match is None:
|
|
237
|
+
raise Rejected(f"{self.label} has no LoRA matching {name!r}.", backend=self.kind)
|
|
238
|
+
found.append((match, weight))
|
|
239
|
+
return cleaned, found
|
|
240
|
+
|
|
241
|
+
@staticmethod
|
|
242
|
+
def lora_name(model: Model) -> str:
|
|
243
|
+
return lora_stem(model.name)
|
|
244
|
+
|
|
245
|
+
# Identification -------------------------------------------------------
|
|
246
|
+
def local_path(self, model: Model) -> str | None:
|
|
247
|
+
"""The model's file on this machine, when the backend reports a path that exists here."""
|
|
248
|
+
if model.path and os.path.isfile(model.path):
|
|
249
|
+
return model.path
|
|
250
|
+
return None
|
|
251
|
+
|
|
252
|
+
def identify(self, model: str | Model, online: bool = False) -> "ModelInfo":
|
|
253
|
+
"""
|
|
254
|
+
identify() for a listed model. Reads the file when it is reachable from this machine,
|
|
255
|
+
otherwise works from the name and whatever the backend reports about it.
|
|
256
|
+
"""
|
|
257
|
+
from ..modelinfo import identify, identify_name
|
|
258
|
+
|
|
259
|
+
if not isinstance(model, Model):
|
|
260
|
+
model = self.resolve_model(model)
|
|
261
|
+
path = self.local_path(model)
|
|
262
|
+
if path:
|
|
263
|
+
return identify(path, online=online)
|
|
264
|
+
return identify_name(model.name, architecture=model.architecture, kind=model.kind, sha256=model.sha256, online=online)
|
|
265
|
+
|
|
266
|
+
def profile(self, model: str | Model | None = None) -> "Profile":
|
|
267
|
+
"""Prompt dialect and starting defaults for a model this backend lists."""
|
|
268
|
+
if model is None or not isinstance(model, Model):
|
|
269
|
+
model = self.resolve_model(model)
|
|
270
|
+
return self.identify(model).profile
|