chandra 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.
- chandra/__init__.py +30 -0
- chandra/analyze.py +451 -0
- chandra/cli.py +68 -0
- chandra/concordance.py +233 -0
- chandra/hashing.py +144 -0
- chandra/inputs.py +51 -0
- chandra/palimpsest.py +211 -0
- chandra/pngchunks.py +265 -0
- chandra/rosetta.py +256 -0
- chandra/synthesize.py +115 -0
- chandra/xmp.py +56 -0
- chandra-0.1.0.dist-info/METADATA +274 -0
- chandra-0.1.0.dist-info/RECORD +16 -0
- chandra-0.1.0.dist-info/WHEEL +4 -0
- chandra-0.1.0.dist-info/entry_points.txt +5 -0
- chandra-0.1.0.dist-info/licenses/LICENSE.md +24 -0
chandra/__init__.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""chandra — tools for the metadata image generators embed in their output.
|
|
2
|
+
|
|
3
|
+
Everything is dispatched through a single `chandra` entry point:
|
|
4
|
+
|
|
5
|
+
- ``chandra show`` — print the A1111/CivitAI-compatible metadata derived from an embedded
|
|
6
|
+
ComfyUI workflow (read-only).
|
|
7
|
+
- ``chandra inject`` — write that metadata into the image(s) in place, so they're recognized by
|
|
8
|
+
services that don't analyze ComfyUI graphs themselves.
|
|
9
|
+
- ``chandra eject`` — remove that metadata again (the inverse of inject), restoring the image to
|
|
10
|
+
its pre-inject state.
|
|
11
|
+
- ``chandra search`` — search the prompts embedded across a directory tree of generated images.
|
|
12
|
+
- ``chandra scrub`` — strip a ComfyUI image to a de-branded, shareable skeleton.
|
|
13
|
+
|
|
14
|
+
Three engines do the work: `rosetta` (analyze + synthesize + inject/eject, behind show/inject/eject),
|
|
15
|
+
`concordance` (behind search), and `palimpsest` (behind scrub). See the README for the naming lore.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
__version__ = version("chandra")
|
|
22
|
+
except PackageNotFoundError: # running from a source tree without an installed dist
|
|
23
|
+
__version__ = "0.0.0+unknown"
|
|
24
|
+
|
|
25
|
+
# Signature stamped into everything `inject` writes — the A1111 `Version:` field and the XMP packet's
|
|
26
|
+
# `x:xmptk` (toolkit) attribute — so `eject` can recognize chandra's own output and remove only that,
|
|
27
|
+
# never a third party's metadata. The `rosetta` suffix names the engine that writes it (README lore).
|
|
28
|
+
TOOL_TAG = "chandra-rosetta"
|
|
29
|
+
|
|
30
|
+
__all__ = ["__version__", "TOOL_TAG"]
|
chandra/analyze.py
ADDED
|
@@ -0,0 +1,451 @@
|
|
|
1
|
+
"""Analyze a ComfyUI `prompt` graph into a normalized `Recipe`.
|
|
2
|
+
|
|
3
|
+
This is the heart of `rosetta`. Both CivitAI and SD Prompt Reader punt on non-trivial ComfyUI
|
|
4
|
+
graphs; we walk the graph ourselves and reduce it to the generation recipe, which synthesis then
|
|
5
|
+
renders as an A1111 `parameters` string. See `briefs/rosetta-metadata-injector.md`.
|
|
6
|
+
|
|
7
|
+
The walk is **role-based**, never keyed on node id or exact class name (those vary across node packs
|
|
8
|
+
and templates): we recognize a node by the shape of its inputs. We traverse the API-format `prompt`
|
|
9
|
+
graph — `{node_id: {"class_type", "inputs"}}`, each input either a literal or a `[node_id, slot]`
|
|
10
|
+
link — which is the *executed* graph (bypassed/muted/UI-only nodes are already absent), so toggled-off
|
|
11
|
+
LoRAs and reference chains need no special handling.
|
|
12
|
+
|
|
13
|
+
Strategy: find the Save-Image sink → walk to the sampler feeding it → read the sampler's scalars
|
|
14
|
+
(resolving literals, `Primitive*` values, and `Evaluate*` expressions) → trace its positive/negative
|
|
15
|
+
conditioning back through passthrough nodes to the text encoders → walk its model link through the
|
|
16
|
+
LoRA chain to the base loader. Image size comes from the PNG `IHDR`, not the graph (the most reliable
|
|
17
|
+
source, and Flux.2's latent geometry differs from older models / inpaint sizes are crop regions).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import math
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
try:
|
|
25
|
+
from simpleeval import simple_eval
|
|
26
|
+
except ImportError: # resolver degrades gracefully without it
|
|
27
|
+
simple_eval = None
|
|
28
|
+
|
|
29
|
+
__all__ = ["Lora", "Recipe", "analyze", "conditioning_roles", "format_recipe", "format_description"]
|
|
30
|
+
|
|
31
|
+
_MAX_DEPTH = 64 # guard against cycles/pathological graphs (the graph is a DAG, but a file may lie)
|
|
32
|
+
|
|
33
|
+
# Whitelisted functions for Evaluate* expressions, on top of simpleeval's safe arithmetic.
|
|
34
|
+
_SAFE_FUNCS = {
|
|
35
|
+
"int": int, "float": float, "round": round, "abs": abs,
|
|
36
|
+
"min": min, "max": max, "ceil": math.ceil, "floor": math.floor, "pow": pow,
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class Lora:
|
|
42
|
+
name: str
|
|
43
|
+
strength: Optional[float] = None
|
|
44
|
+
hash: Optional[str] = None # AutoV2, filled in by the hashing step (--hash)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class Recipe:
|
|
49
|
+
"""The generation recipe extracted from a ComfyUI graph. Fields are None when unresolved."""
|
|
50
|
+
positive: Optional[str] = None
|
|
51
|
+
negative: Optional[str] = None
|
|
52
|
+
seed: Optional[object] = None # int in practice; kept loose for odd graphs
|
|
53
|
+
steps: Optional[object] = None
|
|
54
|
+
cfg: Optional[object] = None
|
|
55
|
+
sampler_name: Optional[str] = None
|
|
56
|
+
scheduler: Optional[str] = None
|
|
57
|
+
denoise: Optional[object] = None
|
|
58
|
+
model: Optional[str] = None
|
|
59
|
+
model_hash: Optional[str] = None # AutoV2, filled in by the hashing step (--hash)
|
|
60
|
+
loras: list = field(default_factory=list)
|
|
61
|
+
vae: Optional[str] = None
|
|
62
|
+
vae_hash: Optional[str] = None # AutoV2, filled in by the hashing step (--hash)
|
|
63
|
+
text_encoders: list = field(default_factory=list) # separate CLIP/T5 loader files (Forge "VAE/TE")
|
|
64
|
+
width: Optional[int] = None
|
|
65
|
+
height: Optional[int] = None
|
|
66
|
+
sampler_class: Optional[str] = None
|
|
67
|
+
warnings: list = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
# --------------------------------------------------------------------------------
|
|
71
|
+
# Role predicates — recognize a node by the shape of its inputs, not its exact class name.
|
|
72
|
+
|
|
73
|
+
def _inputs(node) -> dict:
|
|
74
|
+
return node.get("inputs", {}) if node else {}
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _is_link(v) -> bool:
|
|
78
|
+
return isinstance(v, list) and len(v) == 2 and isinstance(v[0], str)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _is_sampler(node) -> bool:
|
|
82
|
+
ins = _inputs(node)
|
|
83
|
+
has_contract = "steps" in ins and ("cfg" in ins or "sampler_name" in ins)
|
|
84
|
+
return has_contract or "sampler" in node.get("class_type", "").lower()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _is_save(node) -> bool:
|
|
88
|
+
ct = node.get("class_type", "").lower()
|
|
89
|
+
return "save" in ct and "image" in ct
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _is_base_loader_field(name: str) -> bool:
|
|
93
|
+
return name in ("ckpt_name", "gguf_name", "unet_name", "model_path")
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --------------------------------------------------------------------------------
|
|
97
|
+
# Scalar resolution: literal | Primitive*.value | Evaluate* expression.
|
|
98
|
+
|
|
99
|
+
def _resolve_value(graph, v, depth=0):
|
|
100
|
+
"""Resolve a scalar input to a concrete value, or None if it can't be resolved honestly."""
|
|
101
|
+
if not _is_link(v):
|
|
102
|
+
return v # already a literal
|
|
103
|
+
if depth > _MAX_DEPTH:
|
|
104
|
+
return None
|
|
105
|
+
node = graph.get(v[0])
|
|
106
|
+
if node is None:
|
|
107
|
+
return None
|
|
108
|
+
ins = _inputs(node)
|
|
109
|
+
ct = node.get("class_type", "")
|
|
110
|
+
|
|
111
|
+
if "python_expression" in ins: # Evaluate Integers / Evaluate Floats (and kin)
|
|
112
|
+
if simple_eval is None:
|
|
113
|
+
return None
|
|
114
|
+
names = {name: _resolve_value(graph, ins.get(name, 0), depth + 1) for name in ("a", "b", "c")}
|
|
115
|
+
if any(val is None for val in names.values()):
|
|
116
|
+
return None
|
|
117
|
+
try:
|
|
118
|
+
return simple_eval(ins["python_expression"], names=names, functions=_SAFE_FUNCS)
|
|
119
|
+
except Exception:
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
if ct.startswith("Primitive") or "value" in ins: # PrimitiveInt/Float/String → value
|
|
123
|
+
return _resolve_value(graph, ins.get("value"), depth + 1)
|
|
124
|
+
|
|
125
|
+
return None # unknown scalar source → unresolved (honest fallback)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _resolve_string(graph, ref, depth=0):
|
|
129
|
+
"""A text field may be a literal or a link to an upstream string-bearing node."""
|
|
130
|
+
if isinstance(ref, str):
|
|
131
|
+
return ref
|
|
132
|
+
if not _is_link(ref) or depth > _MAX_DEPTH:
|
|
133
|
+
return None
|
|
134
|
+
node = graph.get(ref[0])
|
|
135
|
+
ins = _inputs(node)
|
|
136
|
+
for key in ("value", "text", "string", "prompt"):
|
|
137
|
+
if key in ins:
|
|
138
|
+
return _resolve_string(graph, ins[key], depth + 1)
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------------------------------
|
|
143
|
+
# Graph navigation
|
|
144
|
+
|
|
145
|
+
def _subgraph_size(graph, start_id):
|
|
146
|
+
"""Count nodes reachable upstream from start_id (for picking the dominant sink)."""
|
|
147
|
+
seen, stack = set(), [start_id]
|
|
148
|
+
while stack:
|
|
149
|
+
nid = stack.pop()
|
|
150
|
+
if nid in seen or nid not in graph:
|
|
151
|
+
continue
|
|
152
|
+
seen.add(nid)
|
|
153
|
+
for v in _inputs(graph[nid]).values():
|
|
154
|
+
if _is_link(v):
|
|
155
|
+
stack.append(v[0])
|
|
156
|
+
return len(seen)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _pick_sink(graph, warnings):
|
|
160
|
+
saves = [nid for nid, node in graph.items() if _is_save(node)]
|
|
161
|
+
if not saves:
|
|
162
|
+
# Fallback: no Save-Image node — treat the largest sampler subgraph as the endpoint.
|
|
163
|
+
samplers = [nid for nid, node in graph.items() if _is_sampler(node)]
|
|
164
|
+
if not samplers:
|
|
165
|
+
warnings.append("no Save-Image or sampler node found")
|
|
166
|
+
return None
|
|
167
|
+
warnings.append("no Save-Image node; using the largest sampler subgraph")
|
|
168
|
+
return max(samplers, key=lambda nid: _subgraph_size(graph, nid))
|
|
169
|
+
if len(saves) > 1:
|
|
170
|
+
warnings.append(f"{len(saves)} Save-Image nodes; using the one with the largest subgraph")
|
|
171
|
+
return max(saves, key=lambda nid: _subgraph_size(graph, nid))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# Inputs to follow on the produced-image path from the sink toward the sampler, in priority order.
|
|
175
|
+
# (Latent/sample path first, then composited-image inputs — e.g. inpaint crop-and-stitch.)
|
|
176
|
+
_IMAGE_PATH_KEYS = ("samples", "latent", "inpainted_image", "stitched_image", "image", "images")
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _next_image_ref(ins):
|
|
180
|
+
"""The link to follow toward the sampler: a known image/latent input, else a generic one.
|
|
181
|
+
|
|
182
|
+
Source-side inputs (mask, vae, the original `pixels`/`stitcher`) are excluded so we follow the
|
|
183
|
+
*produced* image, not the inputs that fed the inpaint/img2img region.
|
|
184
|
+
"""
|
|
185
|
+
for key in _IMAGE_PATH_KEYS:
|
|
186
|
+
if _is_link(ins.get(key)):
|
|
187
|
+
return ins[key]
|
|
188
|
+
for k, v in ins.items():
|
|
189
|
+
kl = k.lower()
|
|
190
|
+
if _is_link(v) and any(t in kl for t in ("image", "latent", "sample")) \
|
|
191
|
+
and not any(t in kl for t in ("mask", "vae", "pixel", "stitch")):
|
|
192
|
+
return v
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _find_sampler(graph, sink_id, warnings):
|
|
197
|
+
"""From the sink, follow the produced-image path until a sampler-role node is reached."""
|
|
198
|
+
node = graph.get(sink_id)
|
|
199
|
+
if _is_sampler(node): # sink fallback was itself a sampler
|
|
200
|
+
return sink_id
|
|
201
|
+
ref = _inputs(node).get("images") if node else None
|
|
202
|
+
seen = set()
|
|
203
|
+
while _is_link(ref) and ref[0] not in seen:
|
|
204
|
+
nid = ref[0]
|
|
205
|
+
seen.add(nid)
|
|
206
|
+
cur = graph.get(nid)
|
|
207
|
+
if cur is None:
|
|
208
|
+
break
|
|
209
|
+
if _is_sampler(cur):
|
|
210
|
+
return nid
|
|
211
|
+
ref = _next_image_ref(_inputs(cur))
|
|
212
|
+
warnings.append("could not locate a sampler upstream of the sink")
|
|
213
|
+
return None
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _trace_conditioning_node(graph, ref, depth=0):
|
|
217
|
+
"""Follow a conditioning link back to the text-encoder node; return its node id (or None).
|
|
218
|
+
|
|
219
|
+
Handles direct encoders (`text`/`prompt`), single-conditioning passthroughs (`ReferenceLatent`,
|
|
220
|
+
…), and dual-output passthroughs (`InpaintModelConditioning`, `ControlNetApplyAdvanced`) where
|
|
221
|
+
the slot index of the incoming link selects the positive (0) or negative (1) branch.
|
|
222
|
+
"""
|
|
223
|
+
if not _is_link(ref) or depth > _MAX_DEPTH:
|
|
224
|
+
return None
|
|
225
|
+
nid, slot = ref
|
|
226
|
+
node = graph.get(nid)
|
|
227
|
+
if node is None:
|
|
228
|
+
return None
|
|
229
|
+
ins = _inputs(node)
|
|
230
|
+
|
|
231
|
+
if "text" in ins or "prompt" in ins:
|
|
232
|
+
return nid
|
|
233
|
+
if "positive" in ins and "negative" in ins:
|
|
234
|
+
branch = "positive" if slot == 0 else "negative"
|
|
235
|
+
return _trace_conditioning_node(graph, ins.get(branch), depth + 1)
|
|
236
|
+
if "conditioning" in ins:
|
|
237
|
+
return _trace_conditioning_node(graph, ins["conditioning"], depth + 1)
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _trace_conditioning(graph, ref, depth=0):
|
|
242
|
+
"""Follow a conditioning link back to a text encoder; return the prompt text or None."""
|
|
243
|
+
nid = _trace_conditioning_node(graph, ref, depth)
|
|
244
|
+
if nid is None:
|
|
245
|
+
return None
|
|
246
|
+
ins = _inputs(graph[nid])
|
|
247
|
+
return _resolve_string(graph, ins["text"] if "text" in ins else ins["prompt"], depth + 1)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def conditioning_roles(graph: dict) -> dict:
|
|
251
|
+
"""Map text-encoder node id → ``"positive"`` / ``"negative"``, as the parser resolves the sampler.
|
|
252
|
+
|
|
253
|
+
Exposes *which node* supplies each prompt (the same traversal `analyze` uses to read the text), so
|
|
254
|
+
`palimpsest` can label scrubbed prompts the way the parser reads them — by graph position, which is
|
|
255
|
+
unambiguous even when the positive and negative text happen to be identical or empty. Returns an
|
|
256
|
+
empty map when there's no resolvable sampler.
|
|
257
|
+
"""
|
|
258
|
+
sink = _pick_sink(graph, [])
|
|
259
|
+
sampler = _find_sampler(graph, sink, []) if sink is not None else None
|
|
260
|
+
if sampler is None:
|
|
261
|
+
return {}
|
|
262
|
+
ins = _inputs(graph[sampler])
|
|
263
|
+
pos = _trace_conditioning_node(graph, ins.get("positive"))
|
|
264
|
+
neg = _trace_conditioning_node(graph, ins.get("negative"))
|
|
265
|
+
if pos is not None and pos == neg: # one node feeding both — ambiguous, don't guess
|
|
266
|
+
return {}
|
|
267
|
+
return {nid: role for nid, role in ((pos, "positive"), (neg, "negative")) if nid is not None}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _walk_model(graph, ref):
|
|
271
|
+
"""Walk the model link through the LoRA chain to the base loader.
|
|
272
|
+
|
|
273
|
+
Returns (model_name, [Lora, ...]). Collects every LoRA encountered (chains can be long), then
|
|
274
|
+
reads the base loader's model-name field (ckpt_name/gguf_name/unet_name).
|
|
275
|
+
"""
|
|
276
|
+
loras = []
|
|
277
|
+
model_name = None
|
|
278
|
+
seen = set()
|
|
279
|
+
while _is_link(ref) and ref[0] not in seen:
|
|
280
|
+
nid = ref[0]
|
|
281
|
+
seen.add(nid)
|
|
282
|
+
node = graph.get(nid)
|
|
283
|
+
if node is None:
|
|
284
|
+
break
|
|
285
|
+
ins = _inputs(node)
|
|
286
|
+
if "lora_name" in ins:
|
|
287
|
+
loras.append(Lora(name=_resolve_string(graph, ins["lora_name"]),
|
|
288
|
+
strength=_resolve_value(graph, ins.get("strength_model"))))
|
|
289
|
+
ref = ins.get("model")
|
|
290
|
+
continue
|
|
291
|
+
base_field = next((k for k in ins if _is_base_loader_field(k)), None)
|
|
292
|
+
if base_field is not None:
|
|
293
|
+
model_name = _resolve_string(graph, ins[base_field])
|
|
294
|
+
break
|
|
295
|
+
ref = ins.get("model") # unknown model-passthrough: keep walking
|
|
296
|
+
return model_name, loras
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
_CLIP_NAME_FIELDS = ("clip_name", "clip_name1", "clip_name2", "clip_name3", "clip_name4")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _walk_clip(graph, ref, depth=0):
|
|
303
|
+
"""Follow a `clip` link back through passthrough nodes (LoRA, CLIPSetLastLayer, …) to the
|
|
304
|
+
text-encoder loader; return its clip_name file(s) in field order.
|
|
305
|
+
|
|
306
|
+
Empty when CLIP is baked into the checkpoint (CheckpointLoaderSimple has no clip_name field and
|
|
307
|
+
no `clip` input to keep following) — there's no separate text-encoder file in that case.
|
|
308
|
+
"""
|
|
309
|
+
if not _is_link(ref) or depth > _MAX_DEPTH:
|
|
310
|
+
return []
|
|
311
|
+
node = graph.get(ref[0])
|
|
312
|
+
if node is None:
|
|
313
|
+
return []
|
|
314
|
+
ins = _inputs(node)
|
|
315
|
+
names = [n for n in (_resolve_string(graph, ins[f]) for f in _CLIP_NAME_FIELDS if f in ins) if n]
|
|
316
|
+
if names:
|
|
317
|
+
return names
|
|
318
|
+
return _walk_clip(graph, ins.get("clip"), depth + 1)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _find_text_encoders(graph, sampler_id):
|
|
322
|
+
"""The separate text-encoder file(s) feeding the conditioning, as Forge's "VAE/TE" modules.
|
|
323
|
+
|
|
324
|
+
Traces the sampler's positive conditioning back to its text-encoder node, then that node's `clip`
|
|
325
|
+
link back to the loader (DualCLIPLoader, CLIPLoader, …). Returns [] when CLIP comes baked in the
|
|
326
|
+
checkpoint, so a fully self-contained checkpoint emits no Module fields.
|
|
327
|
+
"""
|
|
328
|
+
enc_id = _trace_conditioning_node(graph, _inputs(graph.get(sampler_id)).get("positive"))
|
|
329
|
+
if enc_id is None:
|
|
330
|
+
return []
|
|
331
|
+
return _walk_clip(graph, _inputs(graph[enc_id]).get("clip"))
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _find_vae(graph, sampler_id):
|
|
335
|
+
"""Best-effort VAE name from the sampler's optional_vae / vae input."""
|
|
336
|
+
ins = _inputs(graph.get(sampler_id))
|
|
337
|
+
ref = ins.get("optional_vae") or ins.get("vae")
|
|
338
|
+
seen = set()
|
|
339
|
+
while _is_link(ref) and ref[0] not in seen:
|
|
340
|
+
nid = ref[0]
|
|
341
|
+
seen.add(nid)
|
|
342
|
+
node = graph.get(nid)
|
|
343
|
+
if node is None:
|
|
344
|
+
break
|
|
345
|
+
nins = _inputs(node)
|
|
346
|
+
if "vae_name" in nins:
|
|
347
|
+
return _resolve_string(graph, nins["vae_name"])
|
|
348
|
+
ref = nins.get("vae") or nins.get("samples")
|
|
349
|
+
return None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# --------------------------------------------------------------------------------
|
|
353
|
+
# Top-level
|
|
354
|
+
|
|
355
|
+
def analyze(graph: dict, width: Optional[int] = None, height: Optional[int] = None) -> Recipe:
|
|
356
|
+
"""Reduce a ComfyUI `prompt` graph (+ PNG dimensions) to a `Recipe`."""
|
|
357
|
+
recipe = Recipe(width=width, height=height)
|
|
358
|
+
|
|
359
|
+
sink_id = _pick_sink(graph, recipe.warnings)
|
|
360
|
+
if sink_id is None:
|
|
361
|
+
return recipe
|
|
362
|
+
sampler_id = _find_sampler(graph, sink_id, recipe.warnings)
|
|
363
|
+
if sampler_id is None:
|
|
364
|
+
return recipe
|
|
365
|
+
|
|
366
|
+
sampler = graph[sampler_id]
|
|
367
|
+
ins = _inputs(sampler)
|
|
368
|
+
recipe.sampler_class = sampler.get("class_type")
|
|
369
|
+
|
|
370
|
+
# Scalars (resolving literals / Primitive* / Evaluate*).
|
|
371
|
+
seed_ref = ins.get("seed", ins.get("noise_seed"))
|
|
372
|
+
recipe.seed = _resolve_value(graph, seed_ref)
|
|
373
|
+
recipe.steps = _resolve_value(graph, ins.get("steps"))
|
|
374
|
+
recipe.cfg = _resolve_value(graph, ins.get("cfg"))
|
|
375
|
+
recipe.sampler_name = _resolve_value(graph, ins.get("sampler_name"))
|
|
376
|
+
recipe.scheduler = _resolve_value(graph, ins.get("scheduler"))
|
|
377
|
+
recipe.denoise = _resolve_value(graph, ins.get("denoise"))
|
|
378
|
+
|
|
379
|
+
# Prompts via the conditioning chain.
|
|
380
|
+
recipe.positive = _trace_conditioning(graph, ins.get("positive"))
|
|
381
|
+
recipe.negative = _trace_conditioning(graph, ins.get("negative"))
|
|
382
|
+
|
|
383
|
+
# Model + LoRAs, and VAE.
|
|
384
|
+
recipe.model, recipe.loras = _walk_model(graph, ins.get("model"))
|
|
385
|
+
recipe.vae = _find_vae(graph, sampler_id)
|
|
386
|
+
recipe.text_encoders = _find_text_encoders(graph, sampler_id)
|
|
387
|
+
|
|
388
|
+
if recipe.positive is None and recipe.negative is None:
|
|
389
|
+
recipe.warnings.append("no prompt text resolved")
|
|
390
|
+
if recipe.model is None:
|
|
391
|
+
recipe.warnings.append("model name unresolved")
|
|
392
|
+
|
|
393
|
+
return recipe
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def format_recipe(recipe: Recipe) -> str:
|
|
397
|
+
"""A readable multi-line dump of a Recipe, for `chandra show --recipe`."""
|
|
398
|
+
lines = []
|
|
399
|
+
lines.append(f"positive: {recipe.positive!r}")
|
|
400
|
+
lines.append(f"negative: {recipe.negative!r}")
|
|
401
|
+
size = f"{recipe.width}x{recipe.height}" if recipe.width and recipe.height else "?"
|
|
402
|
+
lines.append(f"size: {size}")
|
|
403
|
+
model_hash = f" [{recipe.model_hash}]" if recipe.model_hash else ""
|
|
404
|
+
lines.append(f"model: {recipe.model!r}{model_hash}")
|
|
405
|
+
for lora in recipe.loras:
|
|
406
|
+
lora_hash = f" [{lora.hash}]" if lora.hash else ""
|
|
407
|
+
lines.append(f" lora: {lora.name!r} (strength {lora.strength}){lora_hash}")
|
|
408
|
+
if recipe.vae:
|
|
409
|
+
vae_hash = f" [{recipe.vae_hash}]" if recipe.vae_hash else ""
|
|
410
|
+
lines.append(f"vae: {recipe.vae!r}{vae_hash}")
|
|
411
|
+
for te in recipe.text_encoders:
|
|
412
|
+
lines.append(f"te: {te!r}")
|
|
413
|
+
lines.append(f"sampler: {recipe.sampler_name!r} scheduler: {recipe.scheduler!r} "
|
|
414
|
+
f"({recipe.sampler_class})")
|
|
415
|
+
lines.append(f"steps: {recipe.steps} cfg: {recipe.cfg} denoise: {recipe.denoise} "
|
|
416
|
+
f"seed: {recipe.seed}")
|
|
417
|
+
for w in recipe.warnings:
|
|
418
|
+
lines.append(f" ! {w}")
|
|
419
|
+
return "\n".join(lines)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def format_description(recipe: Recipe) -> str:
|
|
423
|
+
"""A clean, human-readable rendering of a Recipe, for embedding as an image description.
|
|
424
|
+
|
|
425
|
+
Unlike `format_recipe` (which uses `repr()` for exact, terminal-debugging fidelity), this keeps
|
|
426
|
+
the prompts as real text with real line breaks — so it reads naturally as the "Description"
|
|
427
|
+
caption a general image viewer (Pix, etc.) shows. Empty sections (no negative, no VAE, no LoRAs)
|
|
428
|
+
are omitted rather than shown blank.
|
|
429
|
+
"""
|
|
430
|
+
lines = ["Positive:", recipe.positive]
|
|
431
|
+
if recipe.negative:
|
|
432
|
+
lines += ["", "Negative:", recipe.negative]
|
|
433
|
+
lines.append("")
|
|
434
|
+
if recipe.width and recipe.height:
|
|
435
|
+
lines.append(f"Size: {recipe.width}x{recipe.height}")
|
|
436
|
+
model_hash = f" [{recipe.model_hash}]" if recipe.model_hash else ""
|
|
437
|
+
lines.append(f"Model: {recipe.model}{model_hash}")
|
|
438
|
+
for lora in recipe.loras:
|
|
439
|
+
lora_hash = f" [{lora.hash}]" if lora.hash else ""
|
|
440
|
+
lines.append(f"LoRA: {lora.name} (strength {lora.strength}){lora_hash}")
|
|
441
|
+
if recipe.vae:
|
|
442
|
+
vae_hash = f" [{recipe.vae_hash}]" if recipe.vae_hash else ""
|
|
443
|
+
lines.append(f"VAE: {recipe.vae}{vae_hash}")
|
|
444
|
+
for te in recipe.text_encoders:
|
|
445
|
+
lines.append(f"Text enc: {te}")
|
|
446
|
+
lines.append(f"Sampler: {recipe.sampler_name} / {recipe.scheduler}")
|
|
447
|
+
lines.append(f"Steps: {recipe.steps} CFG: {recipe.cfg} Denoise: {recipe.denoise} "
|
|
448
|
+
f"Seed: {recipe.seed}")
|
|
449
|
+
for w in recipe.warnings:
|
|
450
|
+
lines.append(f"! {w}")
|
|
451
|
+
return "\n".join(lines)
|
chandra/cli.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
# PYTHON_ARGCOMPLETE_OK
|
|
3
|
+
"""chandra — dispatcher for the imagegen-metadata-tools CLI toolbox.
|
|
4
|
+
|
|
5
|
+
Routes subcommands to the individual tools:
|
|
6
|
+
|
|
7
|
+
chandra search ... search prompts across a directory of images
|
|
8
|
+
chandra show ... print the A1111/CivitAI metadata for a ComfyUI image (read-only)
|
|
9
|
+
chandra inject ... write that metadata into the image(s)
|
|
10
|
+
chandra eject ... remove that metadata again (the inverse of inject)
|
|
11
|
+
chandra scrub ... strip a ComfyUI image to a de-branded, shareable skeleton
|
|
12
|
+
|
|
13
|
+
Each subtool module registers its subparser(s) (``add_subparser``) and sets an ``args.func`` handler,
|
|
14
|
+
so the dispatcher only has to wire them up and route. (The modules keep the names ``rosetta`` —
|
|
15
|
+
``show``/``inject``/``eject`` —, ``concordance`` — ``search`` —, and ``palimpsest`` — ``scrub``; see
|
|
16
|
+
the README for the lineage.) Tab completion is provided by argcomplete when installed; it derives the
|
|
17
|
+
completion set from the live parser, so new subcommands appear in completion automatically.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import sys
|
|
22
|
+
|
|
23
|
+
try:
|
|
24
|
+
import argcomplete
|
|
25
|
+
except ImportError: # completion is optional; the CLI works without it
|
|
26
|
+
argcomplete = None
|
|
27
|
+
|
|
28
|
+
from . import __version__
|
|
29
|
+
from . import concordance, palimpsest, rosetta
|
|
30
|
+
|
|
31
|
+
__all__ = ["build_parser", "main"]
|
|
32
|
+
|
|
33
|
+
# Subtool modules; subcommands appear in `chandra --help` in this order (search, show, inject, scrub).
|
|
34
|
+
_SUBTOOLS = (concordance, rosetta, palimpsest)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
38
|
+
"""Construct the top-level `chandra` parser with one subparser per subtool."""
|
|
39
|
+
parser = argparse.ArgumentParser(
|
|
40
|
+
prog="chandra",
|
|
41
|
+
description="Tools for the metadata image generators embed in their output.",
|
|
42
|
+
)
|
|
43
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
44
|
+
# Not required: bare `chandra` prints the command list (see main) rather than erroring.
|
|
45
|
+
subparsers = parser.add_subparsers(dest="command", metavar="<command>")
|
|
46
|
+
for tool in _SUBTOOLS:
|
|
47
|
+
tool.add_subparser(subparsers)
|
|
48
|
+
return parser
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def main(argv=None) -> int:
|
|
52
|
+
"""Entry point for the `chandra` console script.
|
|
53
|
+
|
|
54
|
+
`argv` defaults to `sys.argv[1:]`; pass an explicit list to drive the dispatcher from tests.
|
|
55
|
+
Bare `chandra` (no subcommand) prints the help, which lists the available commands.
|
|
56
|
+
"""
|
|
57
|
+
parser = build_parser()
|
|
58
|
+
if argcomplete is not None:
|
|
59
|
+
argcomplete.autocomplete(parser)
|
|
60
|
+
args = parser.parse_args(argv)
|
|
61
|
+
if not getattr(args, "func", None):
|
|
62
|
+
parser.print_help()
|
|
63
|
+
return 0
|
|
64
|
+
return args.func(args)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
sys.exit(main())
|