amv-diffusion 2.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.
- amv_diffusion-2.1.0.dist-info/METADATA +180 -0
- amv_diffusion-2.1.0.dist-info/RECORD +39 -0
- amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
- amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
- amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
- amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
- termux_diffusion/__init__.py +185 -0
- termux_diffusion/__main__.py +7 -0
- termux_diffusion/_version.py +3 -0
- termux_diffusion/adapter.py +41 -0
- termux_diffusion/canvas.py +364 -0
- termux_diffusion/cli.py +492 -0
- termux_diffusion/cluster.py +340 -0
- termux_diffusion/control/__init__.py +5 -0
- termux_diffusion/control/component.py +309 -0
- termux_diffusion/control/status.py +15 -0
- termux_diffusion/core.py +2936 -0
- termux_diffusion/data/bootstrap-manifest.json +15 -0
- termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
- termux_diffusion/data/release-public-keys.json +10 -0
- termux_diffusion/data/validated-vulkan-profiles.json +298 -0
- termux_diffusion/detailer.py +283 -0
- termux_diffusion/doctor.py +128 -0
- termux_diffusion/downloader.py +111 -0
- termux_diffusion/engine.py +226 -0
- termux_diffusion/exceptions.py +158 -0
- termux_diffusion/hardware.py +985 -0
- termux_diffusion/hub.py +686 -0
- termux_diffusion/installer.py +499 -0
- termux_diffusion/locking.py +134 -0
- termux_diffusion/manifest.py +128 -0
- termux_diffusion/npu.py +267 -0
- termux_diffusion/platform.py +533 -0
- termux_diffusion/purger.py +145 -0
- termux_diffusion/py.typed +1 -0
- termux_diffusion/safety.py +150 -0
- termux_diffusion/selftest.py +178 -0
- termux_diffusion/web.py +3370 -0
- termux_diffusion/workflow.py +1345 -0
|
@@ -0,0 +1,1345 @@
|
|
|
1
|
+
# Copyright (c) 2026 Uno Kim / AMEVA Foundation. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0 OR Commercial-License
|
|
3
|
+
"""ComfyUI Workflow (DAG) Dual-Format Engine for termux-diffusion.
|
|
4
|
+
|
|
5
|
+
Parses, topologically sorts, and executes ComfyUI workflows in both:
|
|
6
|
+
1. UI Graph Format (standard exported JSON with 'nodes' and 'links')
|
|
7
|
+
2. API Prompt Format (clean dictionary keyed by node ID with 'class_type' and 'inputs')
|
|
8
|
+
|
|
9
|
+
Follows the 100% Open & Scalable Architecture Principle:
|
|
10
|
+
Zero artificial hardware blocking. Hardware scalability scales from 6GB up to 24GB/32GB+
|
|
11
|
+
devices with dynamic resource adaptation and extensible node dispatchers.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import json
|
|
17
|
+
import logging
|
|
18
|
+
import os
|
|
19
|
+
import shutil
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from collections import defaultdict, deque
|
|
23
|
+
from dataclasses import dataclass, field
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Any, Callable, Dict, List, Optional, Set, Tuple, Union
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger("termux_diffusion.workflow")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class WorkflowError(Exception):
|
|
31
|
+
"""Base exception for workflow parsing and execution failures."""
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class WorkflowFormatError(WorkflowError):
|
|
36
|
+
"""Raised when workflow JSON structure is invalid or unparseable."""
|
|
37
|
+
pass
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class WorkflowCycleError(WorkflowError):
|
|
41
|
+
"""Raised when cyclic dependency is detected in the DAG."""
|
|
42
|
+
pass
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class UnsupportedNodeError(WorkflowError):
|
|
46
|
+
"""Raised when an unhandled node is encountered and cannot be bypassed."""
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class NodeConnection:
|
|
52
|
+
"""Represents a connection from source node slot to target node input."""
|
|
53
|
+
source_node_id: str
|
|
54
|
+
source_slot_index: int = 0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class WorkflowNode:
|
|
59
|
+
"""Standardized representation of an executable node in the DAG."""
|
|
60
|
+
id: str
|
|
61
|
+
class_type: str
|
|
62
|
+
inputs: Dict[str, Any] = field(default_factory=dict)
|
|
63
|
+
widgets_values: List[Any] = field(default_factory=list)
|
|
64
|
+
title: Optional[str] = None
|
|
65
|
+
output_types: List[str] = field(default_factory=list)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class WorkflowExecutionResult:
|
|
70
|
+
"""Encapsulates the final outcome and artifacts of a workflow run."""
|
|
71
|
+
success: bool
|
|
72
|
+
output_images: List[Path]
|
|
73
|
+
executed_nodes: List[str]
|
|
74
|
+
elapsed_seconds: float
|
|
75
|
+
metadata: Dict[str, Any] = field(default_factory=dict)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class NodeRegistry:
|
|
79
|
+
"""Extensible registry for node executors with pluggable fallback."""
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self._handlers: Dict[str, Callable[[WorkflowNode, Dict[str, Any], "ExecutionContext"], Any]] = {}
|
|
83
|
+
|
|
84
|
+
def register(self, *class_names: str) -> Callable[[Callable], Callable]:
|
|
85
|
+
def decorator(fn: Callable) -> Callable:
|
|
86
|
+
for name in class_names:
|
|
87
|
+
self._handlers[name.lower()] = fn
|
|
88
|
+
return fn
|
|
89
|
+
return decorator
|
|
90
|
+
|
|
91
|
+
def get(self, class_name: str) -> Optional[Callable]:
|
|
92
|
+
return self._handlers.get(class_name.lower())
|
|
93
|
+
|
|
94
|
+
def list_supported_nodes(self) -> List[str]:
|
|
95
|
+
return sorted(list(self._handlers.keys()))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
# Global node registry instance
|
|
99
|
+
NODE_REGISTRY = NodeRegistry()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class ExecutionContext:
|
|
104
|
+
"""Runtime state passed across topologically sorted node executions."""
|
|
105
|
+
workflow_path: Optional[Path]
|
|
106
|
+
output_dir: Path
|
|
107
|
+
device: str
|
|
108
|
+
force_run: bool
|
|
109
|
+
progress: bool
|
|
110
|
+
json_progress: bool
|
|
111
|
+
node_outputs: Dict[str, Any] = field(default_factory=dict)
|
|
112
|
+
generated_images: List[Path] = field(default_factory=list)
|
|
113
|
+
extra_params: Dict[str, Any] = field(default_factory=dict)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class WorkflowParser:
|
|
117
|
+
"""Detects and parses ComfyUI UI Graph Format and API Prompt Format."""
|
|
118
|
+
|
|
119
|
+
@staticmethod
|
|
120
|
+
def load_json(source: Union[str, Path, Dict[str, Any]]) -> Dict[str, Any]:
|
|
121
|
+
"""Loads JSON from file path or validates raw dictionary."""
|
|
122
|
+
if isinstance(source, (str, Path)):
|
|
123
|
+
p = Path(source).resolve()
|
|
124
|
+
if not p.is_file():
|
|
125
|
+
raise FileNotFoundError(f"[WorkflowParser] Workflow JSON file not found: {p}")
|
|
126
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
127
|
+
try:
|
|
128
|
+
return json.load(f)
|
|
129
|
+
except json.JSONDecodeError as err:
|
|
130
|
+
raise WorkflowFormatError(f"Corrupt JSON syntax in '{p}': {err}") from err
|
|
131
|
+
elif isinstance(source, dict):
|
|
132
|
+
return source
|
|
133
|
+
else:
|
|
134
|
+
raise WorkflowFormatError(f"Expected file path or dictionary, got {type(source)}")
|
|
135
|
+
|
|
136
|
+
@staticmethod
|
|
137
|
+
def detect_format(data: Dict[str, Any]) -> str:
|
|
138
|
+
"""Determines whether payload is 'UI_GRAPH' or 'API_PROMPT'."""
|
|
139
|
+
if "nodes" in data and isinstance(data["nodes"], list) and "links" in data:
|
|
140
|
+
return "UI_GRAPH"
|
|
141
|
+
# Check API format: dictionary where keys map to objects with class_type & inputs
|
|
142
|
+
if data and all(isinstance(v, dict) and "class_type" in v and "inputs" in v for v in data.values()):
|
|
143
|
+
return "API_PROMPT"
|
|
144
|
+
# Fallback check for nested prompt format
|
|
145
|
+
if "prompt" in data and isinstance(data["prompt"], dict):
|
|
146
|
+
return "API_PROMPT"
|
|
147
|
+
raise WorkflowFormatError(
|
|
148
|
+
"[WorkflowParser] Unknown workflow format. File must either contain 'nodes' & 'links' (UI Format) "
|
|
149
|
+
"or node dictionaries with 'class_type' and 'inputs' (API Prompt Format)."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
@classmethod
|
|
153
|
+
def parse(cls, source: Union[str, Path, Dict[str, Any]]) -> Dict[str, WorkflowNode]:
|
|
154
|
+
"""Parses arbitrary ComfyUI workflow JSON into canonical WorkflowNode graph."""
|
|
155
|
+
data = cls.load_json(source)
|
|
156
|
+
fmt = cls.detect_format(data)
|
|
157
|
+
logger.debug("[WorkflowParser] Detected workflow format: %s", fmt)
|
|
158
|
+
|
|
159
|
+
if fmt == "UI_GRAPH":
|
|
160
|
+
inlined_data = cls.inline_subgraphs(data)
|
|
161
|
+
return cls._parse_ui_graph(inlined_data)
|
|
162
|
+
elif fmt == "API_PROMPT":
|
|
163
|
+
prompt_data = data.get("prompt", data)
|
|
164
|
+
return cls._parse_api_prompt(prompt_data)
|
|
165
|
+
raise WorkflowFormatError(f"Unsupported format: {fmt}")
|
|
166
|
+
|
|
167
|
+
@staticmethod
|
|
168
|
+
def inline_subgraphs(raw_data: Dict[str, Any]) -> Dict[str, Any]:
|
|
169
|
+
"""Inlines ComfyUI v0.4+ definitions.subgraphs into root nodes and links.
|
|
170
|
+
|
|
171
|
+
Zero-Silent-Fallback Principle: Wrapper node slot connections, widget parameters,
|
|
172
|
+
and subgraph-internal linkages are mechanically preserved and rerouted without loss.
|
|
173
|
+
"""
|
|
174
|
+
import copy
|
|
175
|
+
data = copy.deepcopy(raw_data)
|
|
176
|
+
definitions = data.get("definitions", {})
|
|
177
|
+
subgraphs_list = definitions.get("subgraphs", [])
|
|
178
|
+
if not subgraphs_list or not isinstance(subgraphs_list, list):
|
|
179
|
+
return data
|
|
180
|
+
|
|
181
|
+
subgraphs_by_id = {sg["id"]: sg for sg in subgraphs_list if isinstance(sg, dict) and "id" in sg}
|
|
182
|
+
if not subgraphs_by_id:
|
|
183
|
+
return data
|
|
184
|
+
|
|
185
|
+
nodes = data.get("nodes", [])
|
|
186
|
+
links = data.get("links", [])
|
|
187
|
+
|
|
188
|
+
link_map: Dict[int, List[Any]] = {}
|
|
189
|
+
max_link_id = 0
|
|
190
|
+
for l in links:
|
|
191
|
+
if isinstance(l, (list, tuple)) and len(l) >= 5:
|
|
192
|
+
link_map[l[0]] = list(l)
|
|
193
|
+
if isinstance(l[0], int) and l[0] > max_link_id:
|
|
194
|
+
max_link_id = l[0]
|
|
195
|
+
|
|
196
|
+
new_nodes: List[Dict[str, Any]] = []
|
|
197
|
+
new_links = list(links)
|
|
198
|
+
|
|
199
|
+
for n in nodes:
|
|
200
|
+
if not isinstance(n, dict):
|
|
201
|
+
continue
|
|
202
|
+
ntype = str(n.get("type", n.get("class_type", "")))
|
|
203
|
+
if ntype in subgraphs_by_id:
|
|
204
|
+
sg = subgraphs_by_id[ntype]
|
|
205
|
+
wrapper_id = n["id"]
|
|
206
|
+
wrapper_inputs = n.get("inputs", [])
|
|
207
|
+
wrapper_wvals_named = n.get("widgets_values_named", {})
|
|
208
|
+
wrapper_wvals = n.get("widgets_values", [])
|
|
209
|
+
|
|
210
|
+
# Map input slot index -> incoming link
|
|
211
|
+
input_link_by_slot: Dict[int, int] = {}
|
|
212
|
+
for idx, inp in enumerate(wrapper_inputs):
|
|
213
|
+
if inp.get("link") is not None:
|
|
214
|
+
input_link_by_slot[idx] = inp["link"]
|
|
215
|
+
|
|
216
|
+
sg_inputs = sg.get("inputs", [])
|
|
217
|
+
sg_outputs = sg.get("outputs", [])
|
|
218
|
+
sg_links = sg.get("links", [])
|
|
219
|
+
sg_nodes = copy.deepcopy(sg.get("nodes", []))
|
|
220
|
+
|
|
221
|
+
# Internal links
|
|
222
|
+
internal_links_from_input: Dict[int, List[Tuple[Any, int, str, int]]] = {}
|
|
223
|
+
internal_links_to_output: Dict[int, Tuple[Any, int, str, int]] = {}
|
|
224
|
+
|
|
225
|
+
for sl in sg_links:
|
|
226
|
+
lid = sl.get("id")
|
|
227
|
+
oid = sl.get("origin_id")
|
|
228
|
+
oslot = sl.get("origin_slot")
|
|
229
|
+
tid = sl.get("target_id")
|
|
230
|
+
tslot = sl.get("target_slot")
|
|
231
|
+
ltype = sl.get("type", "ANY")
|
|
232
|
+
|
|
233
|
+
if oid == -10:
|
|
234
|
+
internal_links_from_input.setdefault(oslot, []).append((tid, tslot, ltype, lid))
|
|
235
|
+
elif tid == -20:
|
|
236
|
+
internal_links_to_output[tslot] = (oid, oslot, ltype, lid)
|
|
237
|
+
else:
|
|
238
|
+
new_links.append([lid, oid, oslot, tid, tslot, ltype])
|
|
239
|
+
|
|
240
|
+
# Rewire wrapper inputs
|
|
241
|
+
for s_idx, sg_inp in enumerate(sg_inputs):
|
|
242
|
+
inp_name = sg_inp.get("name")
|
|
243
|
+
outer_link_id = input_link_by_slot.get(s_idx)
|
|
244
|
+
targets = internal_links_from_input.get(s_idx, [])
|
|
245
|
+
|
|
246
|
+
if outer_link_id is not None and outer_link_id in link_map:
|
|
247
|
+
ol = link_map[outer_link_id]
|
|
248
|
+
for t_i, (tid, tslot, ltype, orig_lid) in enumerate(targets):
|
|
249
|
+
if t_i == 0:
|
|
250
|
+
ol[3] = tid
|
|
251
|
+
ol[4] = tslot
|
|
252
|
+
for sn in sg_nodes:
|
|
253
|
+
if sn["id"] == tid:
|
|
254
|
+
if "inputs" in sn and len(sn["inputs"]) > tslot:
|
|
255
|
+
sn["inputs"][tslot]["link"] = outer_link_id
|
|
256
|
+
else:
|
|
257
|
+
max_link_id += 1
|
|
258
|
+
new_links.append([max_link_id, ol[1], ol[2], tid, tslot, ltype])
|
|
259
|
+
for sn in sg_nodes:
|
|
260
|
+
if sn["id"] == tid:
|
|
261
|
+
if "inputs" in sn and len(sn["inputs"]) > tslot:
|
|
262
|
+
sn["inputs"][tslot]["link"] = max_link_id
|
|
263
|
+
else:
|
|
264
|
+
# Extract literal value from wrapper parameters
|
|
265
|
+
val = None
|
|
266
|
+
if isinstance(wrapper_wvals_named, dict) and inp_name in wrapper_wvals_named:
|
|
267
|
+
val = wrapper_wvals_named[inp_name]
|
|
268
|
+
elif s_idx < len(wrapper_wvals):
|
|
269
|
+
val = wrapper_wvals[s_idx]
|
|
270
|
+
|
|
271
|
+
if val is not None:
|
|
272
|
+
for tid, tslot, ltype, orig_lid in targets:
|
|
273
|
+
for sn in sg_nodes:
|
|
274
|
+
if sn["id"] == tid:
|
|
275
|
+
if "inputs" in sn and len(sn["inputs"]) > tslot:
|
|
276
|
+
sn["inputs"][tslot]["link"] = None
|
|
277
|
+
sn["inputs"][tslot]["default_value"] = val
|
|
278
|
+
sn.setdefault("widgets_values_named", {})[inp_name] = val
|
|
279
|
+
w_v = sn.setdefault("widgets_values", [])
|
|
280
|
+
if tslot < len(w_v):
|
|
281
|
+
w_v[tslot] = val
|
|
282
|
+
else:
|
|
283
|
+
w_v.append(val)
|
|
284
|
+
|
|
285
|
+
# Rewire wrapper outputs
|
|
286
|
+
for out_slot_idx, sg_out in enumerate(sg_outputs):
|
|
287
|
+
if out_slot_idx in internal_links_to_output:
|
|
288
|
+
oid, oslot, ltype, _ = internal_links_to_output[out_slot_idx]
|
|
289
|
+
for l in new_links:
|
|
290
|
+
if l[1] == wrapper_id and l[2] == out_slot_idx:
|
|
291
|
+
l[1] = oid
|
|
292
|
+
l[2] = oslot
|
|
293
|
+
|
|
294
|
+
new_nodes.extend(sg_nodes)
|
|
295
|
+
else:
|
|
296
|
+
new_nodes.append(n)
|
|
297
|
+
|
|
298
|
+
# Filter out links referencing replaced wrapper node
|
|
299
|
+
final_links = [l for l in new_links if l[1] not in subgraphs_by_id and l[3] not in subgraphs_by_id]
|
|
300
|
+
data["nodes"] = new_nodes
|
|
301
|
+
data["links"] = final_links
|
|
302
|
+
return data
|
|
303
|
+
|
|
304
|
+
@staticmethod
|
|
305
|
+
def _parse_ui_graph(data: Dict[str, Any]) -> Dict[str, WorkflowNode]:
|
|
306
|
+
"""Parses ComfyUI UI format using 'nodes' and 'links' table."""
|
|
307
|
+
nodes_raw = data.get("nodes", [])
|
|
308
|
+
links_raw = data.get("links", [])
|
|
309
|
+
|
|
310
|
+
# Link index: link_id -> (from_node_id, from_slot, to_node_id, to_slot, type_name)
|
|
311
|
+
link_map: Dict[int, Tuple[str, int, str, int, str]] = {}
|
|
312
|
+
for link in links_raw:
|
|
313
|
+
if isinstance(link, (list, tuple)) and len(link) >= 5:
|
|
314
|
+
l_id = link[0]
|
|
315
|
+
from_node = str(link[1])
|
|
316
|
+
from_slot = int(link[2])
|
|
317
|
+
to_node = str(link[3])
|
|
318
|
+
to_slot = int(link[4])
|
|
319
|
+
t_name = str(link[5]) if len(link) > 5 else "ANY"
|
|
320
|
+
link_map[l_id] = (from_node, from_slot, to_node, to_slot, t_name)
|
|
321
|
+
|
|
322
|
+
nodes: Dict[str, WorkflowNode] = {}
|
|
323
|
+
for n in nodes_raw:
|
|
324
|
+
if not isinstance(n, dict) or "id" not in n:
|
|
325
|
+
continue
|
|
326
|
+
node_id = str(n["id"])
|
|
327
|
+
class_type = str(n.get("type", n.get("class_type", "UnknownNode")))
|
|
328
|
+
widgets_vals = list(n.get("widgets_values") or [])
|
|
329
|
+
title = n.get("title")
|
|
330
|
+
|
|
331
|
+
inputs: Dict[str, Any] = {}
|
|
332
|
+
# Wire up connected input slots or default inlined values
|
|
333
|
+
for inp_slot in n.get("inputs", []):
|
|
334
|
+
inp_name = inp_slot.get("name")
|
|
335
|
+
link_id = inp_slot.get("link")
|
|
336
|
+
if link_id is not None and link_id in link_map:
|
|
337
|
+
from_node, from_slot, _, _, _ = link_map[link_id]
|
|
338
|
+
inputs[inp_name] = NodeConnection(source_node_id=from_node, source_slot_index=from_slot)
|
|
339
|
+
elif "default_value" in inp_slot:
|
|
340
|
+
inputs[inp_name] = inp_slot["default_value"]
|
|
341
|
+
|
|
342
|
+
out_types = []
|
|
343
|
+
for out_slot in n.get("outputs", []):
|
|
344
|
+
out_types.append(out_slot.get("type", "ANY"))
|
|
345
|
+
|
|
346
|
+
nodes[node_id] = WorkflowNode(
|
|
347
|
+
id=node_id,
|
|
348
|
+
class_type=class_type,
|
|
349
|
+
inputs=inputs,
|
|
350
|
+
widgets_values=widgets_vals,
|
|
351
|
+
title=title,
|
|
352
|
+
output_types=out_types
|
|
353
|
+
)
|
|
354
|
+
return nodes
|
|
355
|
+
|
|
356
|
+
@staticmethod
|
|
357
|
+
def _parse_api_prompt(data: Dict[str, Any]) -> Dict[str, WorkflowNode]:
|
|
358
|
+
"""Parses ComfyUI API Prompt format."""
|
|
359
|
+
nodes: Dict[str, WorkflowNode] = {}
|
|
360
|
+
for node_id_raw, node_dict in data.items():
|
|
361
|
+
if not isinstance(node_dict, dict):
|
|
362
|
+
continue
|
|
363
|
+
node_id = str(node_id_raw)
|
|
364
|
+
class_type = str(node_dict.get("class_type", "UnknownNode"))
|
|
365
|
+
raw_inputs = node_dict.get("inputs", {})
|
|
366
|
+
|
|
367
|
+
inputs: Dict[str, Any] = {}
|
|
368
|
+
for k, v in raw_inputs.items():
|
|
369
|
+
if isinstance(v, (list, tuple)) and len(v) == 2 and isinstance(v[0], (str, int)) and isinstance(v[1], int):
|
|
370
|
+
inputs[k] = NodeConnection(source_node_id=str(v[0]), source_slot_index=int(v[1]))
|
|
371
|
+
else:
|
|
372
|
+
inputs[k] = v
|
|
373
|
+
|
|
374
|
+
nodes[node_id] = WorkflowNode(
|
|
375
|
+
id=node_id,
|
|
376
|
+
class_type=class_type,
|
|
377
|
+
inputs=inputs,
|
|
378
|
+
widgets_values=[],
|
|
379
|
+
title=node_dict.get("_meta", {}).get("title") if isinstance(node_dict.get("_meta"), dict) else None
|
|
380
|
+
)
|
|
381
|
+
return nodes
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
class DAGEngine:
|
|
385
|
+
"""Manages DAG dependency resolution, cycle detection, and topological sorting."""
|
|
386
|
+
|
|
387
|
+
@staticmethod
|
|
388
|
+
def topological_sort(nodes: Dict[str, WorkflowNode]) -> List[str]:
|
|
389
|
+
"""Performs Kahn's algorithm to resolve node execution order."""
|
|
390
|
+
adj: Dict[str, List[str]] = defaultdict(list)
|
|
391
|
+
in_degree: Dict[str, int] = {node_id: 0 for node_id in nodes}
|
|
392
|
+
|
|
393
|
+
for node_id, node in nodes.items():
|
|
394
|
+
for inp_val in node.inputs.values():
|
|
395
|
+
if isinstance(inp_val, NodeConnection):
|
|
396
|
+
src_id = inp_val.source_node_id
|
|
397
|
+
if src_id in nodes:
|
|
398
|
+
adj[src_id].append(node_id)
|
|
399
|
+
in_degree[node_id] += 1
|
|
400
|
+
|
|
401
|
+
queue: deque[str] = deque([nid for nid, deg in in_degree.items() if deg == 0])
|
|
402
|
+
ordered: List[str] = []
|
|
403
|
+
|
|
404
|
+
while queue:
|
|
405
|
+
curr = queue.popleft()
|
|
406
|
+
ordered.append(curr)
|
|
407
|
+
for neighbor in adj[curr]:
|
|
408
|
+
in_degree[neighbor] -= 1
|
|
409
|
+
if in_degree[neighbor] == 0:
|
|
410
|
+
queue.append(neighbor)
|
|
411
|
+
|
|
412
|
+
if len(ordered) != len(nodes):
|
|
413
|
+
unresolved = [nid for nid, deg in in_degree.items() if deg > 0]
|
|
414
|
+
raise WorkflowCycleError(
|
|
415
|
+
f"[DAGEngine] Cyclic dependency or unreachable graph cycle detected in nodes: {unresolved}"
|
|
416
|
+
)
|
|
417
|
+
return ordered
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
# --- Standard ComfyUI Node Handlers Implementation ---
|
|
421
|
+
|
|
422
|
+
@NODE_REGISTRY.register(
|
|
423
|
+
"checkpointloadersimple",
|
|
424
|
+
"checkpointloader",
|
|
425
|
+
"dualcliploader",
|
|
426
|
+
"cliploader",
|
|
427
|
+
"unetloader",
|
|
428
|
+
"vaeloader",
|
|
429
|
+
"loadcheckpoint"
|
|
430
|
+
)
|
|
431
|
+
def handle_checkpoint_loader(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
432
|
+
"""Resolves and loads model weights (GGUF or Safetensors) with Zero-Silent-Fallback enforcement."""
|
|
433
|
+
ckpt_name = None
|
|
434
|
+
for key in ("ckpt_name", "unet_name", "clip_name", "vae_name", "model_name"):
|
|
435
|
+
if key in inputs:
|
|
436
|
+
ckpt_name = str(inputs[key])
|
|
437
|
+
break
|
|
438
|
+
if not ckpt_name and node.widgets_values:
|
|
439
|
+
ckpt_name = str(node.widgets_values[0])
|
|
440
|
+
|
|
441
|
+
if not ckpt_name:
|
|
442
|
+
ckpt_name = "realistic"
|
|
443
|
+
|
|
444
|
+
# Enforce Zero-Silent-Fallback: If an uncataloged specific weight file (.safetensors, .gguf, or path) is requested,
|
|
445
|
+
# strictly verify its presence on disk. Silent weight substitution or mock fabrication is strictly prohibited.
|
|
446
|
+
if isinstance(ckpt_name, str) and (ckpt_name.endswith(".safetensors") or ckpt_name.endswith(".gguf") or "/" in ckpt_name):
|
|
447
|
+
from .hub import get_cache_dir, is_model_cached, list_presets
|
|
448
|
+
presets = list_presets()
|
|
449
|
+
is_known_preset = ckpt_name in presets or any(v.get("alias") == ckpt_name for v in presets.values() if isinstance(v, dict))
|
|
450
|
+
|
|
451
|
+
if not is_known_preset:
|
|
452
|
+
cache_dir = get_cache_dir()
|
|
453
|
+
possible_paths = [
|
|
454
|
+
Path(ckpt_name),
|
|
455
|
+
Path.cwd() / ckpt_name,
|
|
456
|
+
Path.cwd() / "models" / ckpt_name,
|
|
457
|
+
Path.cwd() / "models" / "diffusion_models" / ckpt_name,
|
|
458
|
+
Path.cwd() / "models" / "text_encoders" / ckpt_name,
|
|
459
|
+
Path.cwd() / "models" / "vae" / ckpt_name,
|
|
460
|
+
cache_dir / ckpt_name,
|
|
461
|
+
cache_dir / "models" / ckpt_name,
|
|
462
|
+
]
|
|
463
|
+
found = any(p.is_file() for p in possible_paths) or is_model_cached(ckpt_name)
|
|
464
|
+
if not found and not ctx.force_run:
|
|
465
|
+
raise FileNotFoundError(
|
|
466
|
+
f"[Workflow] [Node {node.id}: {node.class_type}] Required weight file '{ckpt_name}' was not found on device. "
|
|
467
|
+
f"Zero-Silent-Fallback Protocol: Silent weight remapping or mock substitution is strictly prohibited. "
|
|
468
|
+
f"Please ensure '{ckpt_name}' is placed in models/ or downloaded before execution."
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
logger.info("[Workflow] [Node %s: %s] Checkpoint resolved: %s", node.id, node.class_type, ckpt_name)
|
|
472
|
+
return {
|
|
473
|
+
"model": ckpt_name,
|
|
474
|
+
"clip": ckpt_name,
|
|
475
|
+
"vae": ckpt_name
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
@NODE_REGISTRY.register("cliptextencode", "cliptextencodesdxl")
|
|
480
|
+
def handle_clip_text_encode(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
481
|
+
"""Extracts text prompt conditioning."""
|
|
482
|
+
text = ""
|
|
483
|
+
if "text" in inputs:
|
|
484
|
+
text = str(inputs["text"])
|
|
485
|
+
elif node.widgets_values:
|
|
486
|
+
text = str(node.widgets_values[0])
|
|
487
|
+
|
|
488
|
+
logger.info("[Workflow] [Node %s: %s] Prompt text: '%s'", node.id, node.class_type, text[:40] + ("..." if len(text) > 40 else ""))
|
|
489
|
+
return {"conditioning": text}
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@NODE_REGISTRY.register("conditioningzeroout")
|
|
493
|
+
def handle_conditioning_zero_out(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
494
|
+
"""Zeroes out text conditioning vector (sets negative prompt to empty string for CFG=1 distillation)."""
|
|
495
|
+
logger.info("[Workflow] [Node %s: %s] Conditioning zeroed out for CFG=1 guidance distillation", node.id, node.class_type)
|
|
496
|
+
return {"conditioning": ""}
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
@NODE_REGISTRY.register("emptylatentimage", "emptysd3latentimage")
|
|
500
|
+
def handle_empty_latent(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
501
|
+
"""Sets initial canvas dimensions."""
|
|
502
|
+
width = 512
|
|
503
|
+
height = 512
|
|
504
|
+
batch_size = 1
|
|
505
|
+
|
|
506
|
+
if "width" in inputs:
|
|
507
|
+
w_val = inputs["width"]
|
|
508
|
+
if isinstance(w_val, dict) and "width" in w_val:
|
|
509
|
+
width = int(w_val["width"])
|
|
510
|
+
else:
|
|
511
|
+
try:
|
|
512
|
+
width = int(w_val)
|
|
513
|
+
except (ValueError, TypeError):
|
|
514
|
+
pass
|
|
515
|
+
elif len(node.widgets_values) >= 1:
|
|
516
|
+
try:
|
|
517
|
+
width = int(node.widgets_values[0])
|
|
518
|
+
except (ValueError, TypeError):
|
|
519
|
+
pass
|
|
520
|
+
|
|
521
|
+
if "height" in inputs:
|
|
522
|
+
h_val = inputs["height"]
|
|
523
|
+
if isinstance(h_val, dict) and "height" in h_val:
|
|
524
|
+
height = int(h_val["height"])
|
|
525
|
+
else:
|
|
526
|
+
try:
|
|
527
|
+
height = int(h_val)
|
|
528
|
+
except (ValueError, TypeError):
|
|
529
|
+
pass
|
|
530
|
+
elif len(node.widgets_values) >= 2:
|
|
531
|
+
try:
|
|
532
|
+
height = int(node.widgets_values[1])
|
|
533
|
+
except (ValueError, TypeError):
|
|
534
|
+
pass
|
|
535
|
+
|
|
536
|
+
if "batch_size" in inputs:
|
|
537
|
+
try:
|
|
538
|
+
batch_size = int(inputs["batch_size"])
|
|
539
|
+
except (ValueError, TypeError):
|
|
540
|
+
pass
|
|
541
|
+
elif len(node.widgets_values) >= 3:
|
|
542
|
+
try:
|
|
543
|
+
batch_size = int(node.widgets_values[2])
|
|
544
|
+
except (ValueError, TypeError):
|
|
545
|
+
pass
|
|
546
|
+
|
|
547
|
+
logger.info("[Workflow] [Node %s: %s] Canvas: %dx%d (Batch: %d)", node.id, node.class_type, width, height, batch_size)
|
|
548
|
+
return {"latent": {"width": width, "height": height, "batch_size": batch_size}}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
@NODE_REGISTRY.register("modelsamplingauraflow", "modelsamplingflux")
|
|
552
|
+
def handle_model_sampling(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
553
|
+
"""Configures model sampling schedule parameters (e.g. AuraFlow/Flux shift)."""
|
|
554
|
+
model_in = inputs.get("model", {})
|
|
555
|
+
shift = 3.0
|
|
556
|
+
if "shift" in inputs:
|
|
557
|
+
try:
|
|
558
|
+
shift = float(inputs["shift"])
|
|
559
|
+
except (ValueError, TypeError):
|
|
560
|
+
pass
|
|
561
|
+
elif node.widgets_values:
|
|
562
|
+
try:
|
|
563
|
+
shift = float(node.widgets_values[0])
|
|
564
|
+
except (ValueError, IndexError, TypeError):
|
|
565
|
+
pass
|
|
566
|
+
|
|
567
|
+
res = dict(model_in) if isinstance(model_in, dict) else {"model": str(model_in)}
|
|
568
|
+
res["shift"] = shift
|
|
569
|
+
logger.info("[Workflow] [Node %s: %s] ModelSampling shift configured: %s", node.id, node.class_type, shift)
|
|
570
|
+
return {"model": res}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
@NODE_REGISTRY.register("resolutionselector")
|
|
574
|
+
def handle_resolution_selector(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
575
|
+
"""Resolves aspect ratio string to width and height dimensions."""
|
|
576
|
+
ratio_str = "1:1 (Square)"
|
|
577
|
+
if "aspect_ratio" in inputs:
|
|
578
|
+
ratio_str = str(inputs["aspect_ratio"])
|
|
579
|
+
elif node.widgets_values:
|
|
580
|
+
ratio_str = str(node.widgets_values[0])
|
|
581
|
+
|
|
582
|
+
ASPECT_RATIOS = {
|
|
583
|
+
"1:1": (1024, 1024),
|
|
584
|
+
"4:3": (1152, 896),
|
|
585
|
+
"3:4": (896, 1152),
|
|
586
|
+
"16:9": (1344, 768),
|
|
587
|
+
"9:16": (768, 1344),
|
|
588
|
+
"3:2": (1216, 832),
|
|
589
|
+
"2:3": (832, 1216),
|
|
590
|
+
"21:9": (1536, 640),
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
width, height = 1024, 1024
|
|
594
|
+
for key, (w, h) in ASPECT_RATIOS.items():
|
|
595
|
+
if key in ratio_str:
|
|
596
|
+
width, height = w, h
|
|
597
|
+
break
|
|
598
|
+
|
|
599
|
+
logger.info("[Workflow] [Node %s: %s] Selected resolution: %dx%d for '%s'", node.id, node.class_type, width, height, ratio_str)
|
|
600
|
+
return {
|
|
601
|
+
"width": width,
|
|
602
|
+
"height": height,
|
|
603
|
+
0: width,
|
|
604
|
+
1: height
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@NODE_REGISTRY.register("markdownnote", "note")
|
|
609
|
+
def handle_note(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
610
|
+
"""Informational documentation note node (no-op)."""
|
|
611
|
+
return {}
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
@NODE_REGISTRY.register("ksampler", "ksampleradvanced", "samplercustomadvanced")
|
|
615
|
+
def handle_ksampler(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
616
|
+
"""Coordinates and executes the primary diffusion inference step."""
|
|
617
|
+
from .core import generate
|
|
618
|
+
|
|
619
|
+
# Extract model and shift
|
|
620
|
+
model_obj = inputs.get("model", {})
|
|
621
|
+
shift_val = None
|
|
622
|
+
while isinstance(model_obj, dict):
|
|
623
|
+
if "shift" in model_obj and shift_val is None:
|
|
624
|
+
shift_val = model_obj["shift"]
|
|
625
|
+
if "model" in model_obj and isinstance(model_obj["model"], (dict, str)):
|
|
626
|
+
model_obj = model_obj["model"]
|
|
627
|
+
else:
|
|
628
|
+
break
|
|
629
|
+
model_name = str(model_obj) if model_obj and not isinstance(model_obj, dict) else (str(model_obj.get("model", "realistic")) if isinstance(model_obj, dict) else "realistic")
|
|
630
|
+
flow_shift = shift_val if shift_val is not None else (model_obj.get("shift") if isinstance(model_obj, dict) else None)
|
|
631
|
+
|
|
632
|
+
# Extract positive/negative conditioning
|
|
633
|
+
pos_obj = inputs.get("positive", {})
|
|
634
|
+
pos_text = pos_obj.get("conditioning", "") if isinstance(pos_obj, dict) else str(pos_obj)
|
|
635
|
+
|
|
636
|
+
neg_obj = inputs.get("negative", {})
|
|
637
|
+
neg_text = neg_obj.get("conditioning", "") if isinstance(neg_obj, dict) else str(neg_obj)
|
|
638
|
+
|
|
639
|
+
# Extract canvas parameters
|
|
640
|
+
latent_obj = inputs.get("latent_image", {})
|
|
641
|
+
latent_data = latent_obj.get("latent", {}) if isinstance(latent_obj, dict) else {}
|
|
642
|
+
width = latent_data.get("width", 512)
|
|
643
|
+
height = latent_data.get("height", 512)
|
|
644
|
+
|
|
645
|
+
# Sampler hyperparameters
|
|
646
|
+
seed = inputs.get("seed", -1)
|
|
647
|
+
steps = inputs.get("steps", 15)
|
|
648
|
+
cfg = inputs.get("cfg", 7.0)
|
|
649
|
+
sampler_name = inputs.get("sampler_name", "euler_a")
|
|
650
|
+
scheduler = inputs.get("scheduler", "default" if "z_image" in str(model_name).lower() else "karras")
|
|
651
|
+
denoise = inputs.get("denoise", 1.0)
|
|
652
|
+
|
|
653
|
+
# If parsing from UI widgets values
|
|
654
|
+
if node.widgets_values and len(node.widgets_values) >= 6:
|
|
655
|
+
try:
|
|
656
|
+
seed = int(node.widgets_values[0])
|
|
657
|
+
steps = int(node.widgets_values[2])
|
|
658
|
+
cfg = float(node.widgets_values[3])
|
|
659
|
+
sampler_name = str(node.widgets_values[4])
|
|
660
|
+
scheduler = str(node.widgets_values[5])
|
|
661
|
+
if len(node.widgets_values) >= 7:
|
|
662
|
+
denoise = float(node.widgets_values[6])
|
|
663
|
+
except (ValueError, IndexError):
|
|
664
|
+
pass
|
|
665
|
+
|
|
666
|
+
out_file = ctx.output_dir / f"workflow_out_{int(time.time())}_{node.id}.png"
|
|
667
|
+
|
|
668
|
+
logger.info(
|
|
669
|
+
"[Workflow] [Node %s: %s] Executing KSampler (steps=%s, cfg=%s, sampler=%s, sched=%s)",
|
|
670
|
+
node.id, node.class_type, steps, cfg, sampler_name, scheduler
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if flow_shift is None and isinstance(model_obj, dict):
|
|
674
|
+
flow_shift = model_obj.get("shift")
|
|
675
|
+
|
|
676
|
+
# Mobile memory defense parameters for high-footprint DiT models (Z-Image Turbo 6B)
|
|
677
|
+
extra_kws = dict(ctx.extra_params or {})
|
|
678
|
+
model_str = str(model_name).lower()
|
|
679
|
+
if "z_image" in model_str or "turbo" in model_str or "dit" in model_str:
|
|
680
|
+
extra_kws.setdefault("params_backend", "diffusion=cpu")
|
|
681
|
+
extra_kws.setdefault("max_vram", "vulkan0=1")
|
|
682
|
+
extra_kws.setdefault("stream_layers", True)
|
|
683
|
+
extra_kws.setdefault("clip_on_cpu", True)
|
|
684
|
+
extra_kws.setdefault("vae_on_cpu", True)
|
|
685
|
+
extra_kws.setdefault("vae_format", "flux")
|
|
686
|
+
extra_kws.setdefault("mmap", True)
|
|
687
|
+
extra_kws.setdefault("taesd", "taef1.safetensors")
|
|
688
|
+
extra_kws.setdefault("vae_tiling", True)
|
|
689
|
+
extra_kws.setdefault("safe_mode", True)
|
|
690
|
+
extra_kws.setdefault("gpu_duty_cycle", 90)
|
|
691
|
+
extra_kws.setdefault("diffusion_fa", True)
|
|
692
|
+
extra_kws.setdefault("threads", 4)
|
|
693
|
+
# Use ultra-quantized IQ1_S (1.1GB vs 1.6GB Q2_K) to reclaim 500MB headroom on 8GB phones
|
|
694
|
+
extra_kws.setdefault("llm", "Qwen3-4B-Instruct-2507-Q2_K.gguf")
|
|
695
|
+
|
|
696
|
+
gen_res = generate(
|
|
697
|
+
prompt=pos_text or "beautiful realistic masterpiece",
|
|
698
|
+
negative_prompt=neg_text,
|
|
699
|
+
model=model_name,
|
|
700
|
+
device=ctx.device,
|
|
701
|
+
steps=steps,
|
|
702
|
+
cfg_scale=cfg,
|
|
703
|
+
width=width,
|
|
704
|
+
height=height,
|
|
705
|
+
seed=seed,
|
|
706
|
+
sampling_method=sampler_name,
|
|
707
|
+
schedule=scheduler,
|
|
708
|
+
flow_shift=flow_shift,
|
|
709
|
+
output=str(out_file),
|
|
710
|
+
timeout=max(7200, steps * 900),
|
|
711
|
+
progress=ctx.progress,
|
|
712
|
+
json_progress=ctx.json_progress,
|
|
713
|
+
**extra_kws
|
|
714
|
+
)
|
|
715
|
+
|
|
716
|
+
ctx.generated_images.append(gen_res.path)
|
|
717
|
+
return {
|
|
718
|
+
"latent": {"path": gen_res.path, "width": width, "height": height},
|
|
719
|
+
"image": gen_res.path
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
|
|
723
|
+
@NODE_REGISTRY.register("vaedecode", "vaedecodetiled")
|
|
724
|
+
def handle_vae_decode(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
725
|
+
"""Decodes latent representation to pixel canvas (passthrough if already decoded by KSampler)."""
|
|
726
|
+
samples = inputs.get("samples", {})
|
|
727
|
+
img_path = samples.get("image") or samples.get("path")
|
|
728
|
+
if not img_path and isinstance(samples, dict) and "latent" in samples:
|
|
729
|
+
img_path = samples["latent"].get("path")
|
|
730
|
+
|
|
731
|
+
logger.info("[Workflow] [Node %s: %s] VAE Decode pass complete: %s", node.id, node.class_type, img_path)
|
|
732
|
+
return {"image": img_path}
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@NODE_REGISTRY.register("facedetailer", "facedetailerpipe", "detailerforeach")
|
|
736
|
+
def handle_face_detailer(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
737
|
+
"""Executes on-device FaceDetailer localized crop & Img2Img loop."""
|
|
738
|
+
from .core import generate
|
|
739
|
+
from .detailer import refine_face_regions
|
|
740
|
+
|
|
741
|
+
img_input = inputs.get("image")
|
|
742
|
+
if isinstance(img_input, dict):
|
|
743
|
+
img_input = img_input.get("image") or img_input.get("path")
|
|
744
|
+
|
|
745
|
+
if not img_input or not Path(str(img_input)).is_file():
|
|
746
|
+
# Fallback to most recent generated image
|
|
747
|
+
if ctx.generated_images:
|
|
748
|
+
img_input = ctx.generated_images[-1]
|
|
749
|
+
else:
|
|
750
|
+
raise WorkflowError(f"[FaceDetailer Node {node.id}] No source image found to refine.")
|
|
751
|
+
|
|
752
|
+
steps = int(inputs.get("steps", 2))
|
|
753
|
+
denoise = float(inputs.get("denoise", 0.35))
|
|
754
|
+
guide_size = int(inputs.get("guide_size", 512))
|
|
755
|
+
|
|
756
|
+
if node.widgets_values and len(node.widgets_values) >= 3:
|
|
757
|
+
try:
|
|
758
|
+
guide_size = int(node.widgets_values[0])
|
|
759
|
+
steps = int(node.widgets_values[3])
|
|
760
|
+
denoise = float(node.widgets_values[5])
|
|
761
|
+
except (ValueError, IndexError):
|
|
762
|
+
pass
|
|
763
|
+
|
|
764
|
+
detailed_out = ctx.output_dir / f"detailed_{Path(img_input).name}"
|
|
765
|
+
logger.info("[Workflow] [Node %s: %s] Applying FaceDetailer on '%s'", node.id, node.class_type, img_input)
|
|
766
|
+
|
|
767
|
+
res_path = refine_face_regions(
|
|
768
|
+
image_path=img_input,
|
|
769
|
+
generator_func=generate,
|
|
770
|
+
prompt="beautiful realistic eyes, detailed facial features, flawless natural skin, 8k portrait",
|
|
771
|
+
output_path=detailed_out,
|
|
772
|
+
strength=denoise,
|
|
773
|
+
steps=steps,
|
|
774
|
+
device=ctx.device,
|
|
775
|
+
generator_kwargs={"progress": ctx.progress}
|
|
776
|
+
)
|
|
777
|
+
|
|
778
|
+
ctx.generated_images.append(res_path)
|
|
779
|
+
return {"image": res_path}
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
@NODE_REGISTRY.register("saveimage", "previewimage", "saveimageadvanced")
|
|
783
|
+
def handle_save_image(node: WorkflowNode, inputs: Dict[str, Any], ctx: ExecutionContext) -> Dict[str, Any]:
|
|
784
|
+
"""Exports and finalizes destination images."""
|
|
785
|
+
img_input = inputs.get("images") or inputs.get("image")
|
|
786
|
+
if isinstance(img_input, dict):
|
|
787
|
+
img_input = img_input.get("image") or img_input.get("path")
|
|
788
|
+
|
|
789
|
+
target_path = Path(str(img_input)) if img_input else None
|
|
790
|
+
if not target_path or not target_path.is_file():
|
|
791
|
+
if ctx.generated_images:
|
|
792
|
+
target_path = ctx.generated_images[-1]
|
|
793
|
+
|
|
794
|
+
logger.info("[Workflow] [Node %s: %s] Final artifact saved: %s", node.id, node.class_type, target_path)
|
|
795
|
+
return {"saved": str(target_path)}
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
# --- Workflow Runner Interface ---
|
|
799
|
+
|
|
800
|
+
class WorkflowExecutor:
|
|
801
|
+
"""Executes parsed workflow graphs with dynamic resource adaptability."""
|
|
802
|
+
|
|
803
|
+
def __init__(self, registry: Optional[NodeRegistry] = None) -> None:
|
|
804
|
+
self.registry = registry or NODE_REGISTRY
|
|
805
|
+
|
|
806
|
+
def run(
|
|
807
|
+
self,
|
|
808
|
+
workflow: Union[str, Path, Dict[str, Any]],
|
|
809
|
+
output_dir: Optional[Union[str, Path]] = None,
|
|
810
|
+
device: str = "auto",
|
|
811
|
+
force_run: bool = False,
|
|
812
|
+
progress: bool = True,
|
|
813
|
+
json_progress: bool = False,
|
|
814
|
+
extra_params: Optional[Dict[str, Any]] = None,
|
|
815
|
+
node_progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
816
|
+
) -> WorkflowExecutionResult:
|
|
817
|
+
"""Executes a ComfyUI workflow end-to-end.
|
|
818
|
+
|
|
819
|
+
Args:
|
|
820
|
+
workflow: File path to .json or parsed dictionary.
|
|
821
|
+
output_dir: Target folder for generated outputs.
|
|
822
|
+
device: 'auto', 'gpu', 'vulkan', 'cpu'.
|
|
823
|
+
force_run: Bypass hardware tiering checks (for 16GB/24GB+ workstations).
|
|
824
|
+
progress: Display progress bars.
|
|
825
|
+
json_progress: Stream NDJSON progress events.
|
|
826
|
+
extra_params: Key-value parameter overrides.
|
|
827
|
+
node_progress_callback: Real-time callback reporting active node stage.
|
|
828
|
+
"""
|
|
829
|
+
start_time = time.time()
|
|
830
|
+
wf_path = Path(workflow).resolve() if isinstance(workflow, (str, Path)) and os.path.exists(str(workflow)) else None
|
|
831
|
+
|
|
832
|
+
# 1. Parse and build DAG
|
|
833
|
+
nodes = WorkflowParser.parse(workflow)
|
|
834
|
+
logger.info("[WorkflowExecutor] Successfully parsed %d nodes from workflow.", len(nodes))
|
|
835
|
+
|
|
836
|
+
# 2. Topological sort
|
|
837
|
+
exec_order = DAGEngine.topological_sort(nodes)
|
|
838
|
+
logger.info("[WorkflowExecutor] Execution order resolved (%d steps): %s", len(exec_order), " -> ".join(exec_order))
|
|
839
|
+
|
|
840
|
+
# 3. Setup Context
|
|
841
|
+
resolved_out_dir = Path(output_dir).resolve() if output_dir else Path.cwd() / "outputs"
|
|
842
|
+
resolved_out_dir.mkdir(parents=True, exist_ok=True)
|
|
843
|
+
|
|
844
|
+
ctx = ExecutionContext(
|
|
845
|
+
workflow_path=wf_path,
|
|
846
|
+
output_dir=resolved_out_dir,
|
|
847
|
+
device=device,
|
|
848
|
+
force_run=force_run,
|
|
849
|
+
progress=progress,
|
|
850
|
+
json_progress=json_progress,
|
|
851
|
+
extra_params=dict(extra_params or {})
|
|
852
|
+
)
|
|
853
|
+
|
|
854
|
+
executed_node_ids: List[str] = []
|
|
855
|
+
total_steps_in_workflow = len(exec_order)
|
|
856
|
+
|
|
857
|
+
# 4. Sequential execution loop
|
|
858
|
+
for idx, node_id in enumerate(exec_order, start=1):
|
|
859
|
+
node = nodes[node_id]
|
|
860
|
+
handler = self.registry.get(node.class_type)
|
|
861
|
+
|
|
862
|
+
if handler is None:
|
|
863
|
+
if not force_run:
|
|
864
|
+
raise UnsupportedNodeError(
|
|
865
|
+
f"[WorkflowExecutor] [UNSUPPORTED_NODE] Node #{node.id} ('{node.class_type}') has no "
|
|
866
|
+
f"registered handler. Use --force-run to attempt best-effort bypass or register a custom handler."
|
|
867
|
+
)
|
|
868
|
+
logger.warning(
|
|
869
|
+
"[WorkflowExecutor] Bypassing unregistered node #%s ('%s') under --force-run.",
|
|
870
|
+
node.id, node.class_type
|
|
871
|
+
)
|
|
872
|
+
continue
|
|
873
|
+
|
|
874
|
+
# Resolve input values from predecessor outputs
|
|
875
|
+
resolved_inputs: Dict[str, Any] = {}
|
|
876
|
+
for in_name, in_val in node.inputs.items():
|
|
877
|
+
if isinstance(in_val, NodeConnection):
|
|
878
|
+
src_out = ctx.node_outputs.get(in_val.source_node_id, {})
|
|
879
|
+
if isinstance(src_out, dict):
|
|
880
|
+
resolved_inputs[in_name] = src_out
|
|
881
|
+
else:
|
|
882
|
+
resolved_inputs[in_name] = src_out
|
|
883
|
+
else:
|
|
884
|
+
resolved_inputs[in_name] = in_val
|
|
885
|
+
|
|
886
|
+
# Apply runtime overrides if matching node id or class type
|
|
887
|
+
if ctx.extra_params:
|
|
888
|
+
for k, v in ctx.extra_params.items():
|
|
889
|
+
if k in resolved_inputs:
|
|
890
|
+
resolved_inputs[k] = v
|
|
891
|
+
|
|
892
|
+
# Notify node execution start
|
|
893
|
+
if node_progress_callback:
|
|
894
|
+
node_progress_callback({
|
|
895
|
+
"current_node_index": idx,
|
|
896
|
+
"total_nodes": total_steps_in_workflow,
|
|
897
|
+
"node_id": node.id,
|
|
898
|
+
"node_title": node.title or node.class_type,
|
|
899
|
+
"class_type": node.class_type,
|
|
900
|
+
"phase": f"Node {idx}/{total_steps_in_workflow}: {node.title or node.class_type}",
|
|
901
|
+
"percent": round(((idx - 1) / total_steps_in_workflow) * 100, 1),
|
|
902
|
+
"status": "executing",
|
|
903
|
+
"executed_nodes": list(executed_node_ids)
|
|
904
|
+
})
|
|
905
|
+
|
|
906
|
+
# Execute node
|
|
907
|
+
logger.debug("[WorkflowExecutor] Running node #%s: %s", node.id, node.class_type)
|
|
908
|
+
result = handler(node, resolved_inputs, ctx)
|
|
909
|
+
ctx.node_outputs[node.id] = result
|
|
910
|
+
executed_node_ids.append(node.id)
|
|
911
|
+
|
|
912
|
+
# Notify node execution complete
|
|
913
|
+
if node_progress_callback:
|
|
914
|
+
node_progress_callback({
|
|
915
|
+
"current_node_index": idx,
|
|
916
|
+
"total_nodes": total_steps_in_workflow,
|
|
917
|
+
"node_id": node.id,
|
|
918
|
+
"node_title": node.title or node.class_type,
|
|
919
|
+
"class_type": node.class_type,
|
|
920
|
+
"phase": f"Completed Node {idx}/{total_steps_in_workflow}: {node.title or node.class_type}",
|
|
921
|
+
"percent": round((idx / total_steps_in_workflow) * 100, 1),
|
|
922
|
+
"status": "completed",
|
|
923
|
+
"executed_nodes": list(executed_node_ids)
|
|
924
|
+
})
|
|
925
|
+
|
|
926
|
+
elapsed = time.time() - start_time
|
|
927
|
+
return WorkflowExecutionResult(
|
|
928
|
+
success=True,
|
|
929
|
+
output_images=ctx.generated_images,
|
|
930
|
+
executed_nodes=executed_node_ids,
|
|
931
|
+
elapsed_seconds=elapsed,
|
|
932
|
+
metadata={"total_nodes": len(nodes), "format": WorkflowParser.detect_format(WorkflowParser.load_json(workflow))}
|
|
933
|
+
)
|
|
934
|
+
|
|
935
|
+
|
|
936
|
+
def execute_workflow(
|
|
937
|
+
workflow_path: Union[str, Path, Dict[str, Any]],
|
|
938
|
+
output_dir: Optional[Union[str, Path]] = None,
|
|
939
|
+
device: str = "auto",
|
|
940
|
+
force_run: bool = False,
|
|
941
|
+
progress: bool = True,
|
|
942
|
+
json_progress: bool = False,
|
|
943
|
+
node_progress_callback: Optional[Callable[[Dict[str, Any]], None]] = None,
|
|
944
|
+
**kwargs: Any
|
|
945
|
+
) -> WorkflowExecutionResult:
|
|
946
|
+
"""Convenience helper to execute a workflow with default settings."""
|
|
947
|
+
executor = WorkflowExecutor()
|
|
948
|
+
return executor.run(
|
|
949
|
+
workflow=workflow_path,
|
|
950
|
+
output_dir=output_dir,
|
|
951
|
+
device=device,
|
|
952
|
+
force_run=force_run,
|
|
953
|
+
progress=progress,
|
|
954
|
+
json_progress=json_progress,
|
|
955
|
+
extra_params=kwargs,
|
|
956
|
+
node_progress_callback=node_progress_callback
|
|
957
|
+
)
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def get_workflows_dir() -> Path:
|
|
961
|
+
"""Returns the bundled workflows directory."""
|
|
962
|
+
return Path(__file__).resolve().parent / "workflows"
|
|
963
|
+
|
|
964
|
+
|
|
965
|
+
def get_user_workflows_dir() -> Path:
|
|
966
|
+
"""Returns the user custom workflows directory under ~/.cache/termux-diffusion/workflows."""
|
|
967
|
+
d = Path.home() / ".cache" / "termux-diffusion" / "workflows"
|
|
968
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
969
|
+
return d
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def list_builtin_workflows() -> List[Dict[str, Any]]:
|
|
973
|
+
"""Lists standard bundled and user-saved AmfyUI / ComfyUI workflows."""
|
|
974
|
+
results: List[Dict[str, Any]] = []
|
|
975
|
+
seen_ids = set()
|
|
976
|
+
|
|
977
|
+
# 1. User-saved custom workflows first (higher priority)
|
|
978
|
+
user_dir = get_user_workflows_dir()
|
|
979
|
+
if user_dir.is_dir():
|
|
980
|
+
for p in sorted(user_dir.glob("*.json")):
|
|
981
|
+
try:
|
|
982
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
983
|
+
data = json.load(f)
|
|
984
|
+
nodes = WorkflowParser.parse(data)
|
|
985
|
+
order = DAGEngine.topological_sort(nodes)
|
|
986
|
+
wf_id = p.stem
|
|
987
|
+
name = data.get("name", wf_id.replace("_", " ").title())
|
|
988
|
+
desc = data.get("description", "User Custom Workflow")
|
|
989
|
+
author = data.get("author", "User")
|
|
990
|
+
seen_ids.add(wf_id)
|
|
991
|
+
results.append({
|
|
992
|
+
"id": wf_id,
|
|
993
|
+
"name": name,
|
|
994
|
+
"description": desc,
|
|
995
|
+
"author": author,
|
|
996
|
+
"node_count": len(nodes),
|
|
997
|
+
"execution_order": order,
|
|
998
|
+
"file_path": str(p),
|
|
999
|
+
"custom": not (get_workflows_dir() / f"{wf_id}.json").is_file()
|
|
1000
|
+
})
|
|
1001
|
+
except Exception as e:
|
|
1002
|
+
logger.warning("[list_builtin_workflows] Failed parsing user workflow %s: %s", p, e)
|
|
1003
|
+
|
|
1004
|
+
# 2. Bundled canonical workflows
|
|
1005
|
+
wf_dir = get_workflows_dir()
|
|
1006
|
+
if wf_dir.is_dir():
|
|
1007
|
+
for p in sorted(wf_dir.glob("*.json")):
|
|
1008
|
+
wf_id = p.stem
|
|
1009
|
+
if wf_id in seen_ids:
|
|
1010
|
+
continue
|
|
1011
|
+
try:
|
|
1012
|
+
with open(p, "r", encoding="utf-8") as f:
|
|
1013
|
+
data = json.load(f)
|
|
1014
|
+
nodes = WorkflowParser.parse(data)
|
|
1015
|
+
order = DAGEngine.topological_sort(nodes)
|
|
1016
|
+
name = data.get("name", wf_id.replace("_", " ").title())
|
|
1017
|
+
desc = data.get("description", "Standard AmfyUI Workflow")
|
|
1018
|
+
author = data.get("author", "AmfyUI")
|
|
1019
|
+
results.append({
|
|
1020
|
+
"id": wf_id,
|
|
1021
|
+
"name": name,
|
|
1022
|
+
"description": desc,
|
|
1023
|
+
"author": author,
|
|
1024
|
+
"node_count": len(nodes),
|
|
1025
|
+
"execution_order": order,
|
|
1026
|
+
"file_path": str(p),
|
|
1027
|
+
"custom": False
|
|
1028
|
+
})
|
|
1029
|
+
except Exception as e:
|
|
1030
|
+
logger.warning("[list_builtin_workflows] Failed parsing %s: %s", p, e)
|
|
1031
|
+
return results
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def get_builtin_workflow(workflow_id: str) -> Optional[Dict[str, Any]]:
|
|
1035
|
+
"""Loads and returns workflow JSON dictionary by ID, searching user dir then bundled dir."""
|
|
1036
|
+
user_target = get_user_workflows_dir() / f"{workflow_id}.json"
|
|
1037
|
+
if user_target.is_file():
|
|
1038
|
+
with open(user_target, "r", encoding="utf-8") as f:
|
|
1039
|
+
return json.load(f)
|
|
1040
|
+
|
|
1041
|
+
wf_dir = get_workflows_dir()
|
|
1042
|
+
target = wf_dir / f"{workflow_id}.json"
|
|
1043
|
+
if target.is_file():
|
|
1044
|
+
with open(target, "r", encoding="utf-8") as f:
|
|
1045
|
+
return json.load(f)
|
|
1046
|
+
return None
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def save_user_workflow(
|
|
1050
|
+
workflow_data: Dict[str, Any],
|
|
1051
|
+
workflow_id: Optional[str] = None,
|
|
1052
|
+
name: Optional[str] = None,
|
|
1053
|
+
description: Optional[str] = None,
|
|
1054
|
+
author: Optional[str] = None
|
|
1055
|
+
) -> Dict[str, Any]:
|
|
1056
|
+
"""Saves or updates a workflow in the user custom workflows directory."""
|
|
1057
|
+
if not isinstance(workflow_data, dict):
|
|
1058
|
+
raise WorkflowFormatError("Workflow data must be a valid JSON dictionary.")
|
|
1059
|
+
|
|
1060
|
+
# Validate parseability
|
|
1061
|
+
WorkflowParser.parse(workflow_data)
|
|
1062
|
+
|
|
1063
|
+
if name:
|
|
1064
|
+
workflow_data["name"] = name
|
|
1065
|
+
if description:
|
|
1066
|
+
workflow_data["description"] = description
|
|
1067
|
+
if author:
|
|
1068
|
+
workflow_data["author"] = author
|
|
1069
|
+
|
|
1070
|
+
if not workflow_id:
|
|
1071
|
+
base_name = workflow_data.get("name", "custom_workflow")
|
|
1072
|
+
import re
|
|
1073
|
+
workflow_id = re.sub(r"[^a-zA-Z0-9_-]", "_", base_name).strip("_").lower()
|
|
1074
|
+
if not workflow_id:
|
|
1075
|
+
workflow_id = f"workflow_{int(time.time())}"
|
|
1076
|
+
|
|
1077
|
+
user_dir = get_user_workflows_dir()
|
|
1078
|
+
user_dir.mkdir(parents=True, exist_ok=True)
|
|
1079
|
+
target_path = user_dir / f"{workflow_id}.json"
|
|
1080
|
+
|
|
1081
|
+
with open(target_path, "w", encoding="utf-8") as f:
|
|
1082
|
+
json.dump(workflow_data, f, indent=2, ensure_ascii=False)
|
|
1083
|
+
|
|
1084
|
+
return {
|
|
1085
|
+
"id": workflow_id,
|
|
1086
|
+
"name": workflow_data.get("name", workflow_id),
|
|
1087
|
+
"path": str(target_path)
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def update_workflow_node_parameter(
|
|
1092
|
+
workflow_data: Dict[str, Any],
|
|
1093
|
+
node_id: Union[str, int],
|
|
1094
|
+
params: Dict[str, Any]
|
|
1095
|
+
) -> Dict[str, Any]:
|
|
1096
|
+
"""Updates parameters for a specific node in either UI format or API format."""
|
|
1097
|
+
node_id_str = str(node_id)
|
|
1098
|
+
fmt = WorkflowParser.detect_format(workflow_data)
|
|
1099
|
+
|
|
1100
|
+
if fmt == "UI_GRAPH":
|
|
1101
|
+
nodes = workflow_data.get("nodes", [])
|
|
1102
|
+
target_node = None
|
|
1103
|
+
for n in nodes:
|
|
1104
|
+
if str(n.get("id")) == node_id_str:
|
|
1105
|
+
target_node = n
|
|
1106
|
+
break
|
|
1107
|
+
if not target_node:
|
|
1108
|
+
for sg in workflow_data.get("definitions", {}).get("subgraphs", []):
|
|
1109
|
+
for sn in sg.get("nodes", []):
|
|
1110
|
+
if str(sn.get("id")) == node_id_str:
|
|
1111
|
+
target_node = sn
|
|
1112
|
+
break
|
|
1113
|
+
if target_node:
|
|
1114
|
+
break
|
|
1115
|
+
|
|
1116
|
+
if not target_node:
|
|
1117
|
+
raise WorkflowError(f"Node #{node_id_str} not found in UI workflow.")
|
|
1118
|
+
|
|
1119
|
+
ctype_lower = str(target_node.get("type", "")).lower()
|
|
1120
|
+
wvals = list(target_node.get("widgets_values", []))
|
|
1121
|
+
|
|
1122
|
+
# Update specific parameters based on node class
|
|
1123
|
+
if "cliptextencode" in ctype_lower:
|
|
1124
|
+
if "text" in params:
|
|
1125
|
+
if len(wvals) >= 1:
|
|
1126
|
+
wvals[0] = str(params["text"])
|
|
1127
|
+
else:
|
|
1128
|
+
wvals.append(str(params["text"]))
|
|
1129
|
+
elif "ksampler" in ctype_lower:
|
|
1130
|
+
while len(wvals) < 7:
|
|
1131
|
+
wvals.append(None)
|
|
1132
|
+
if "seed" in params:
|
|
1133
|
+
wvals[0] = int(params["seed"])
|
|
1134
|
+
if "steps" in params:
|
|
1135
|
+
wvals[2] = int(params["steps"])
|
|
1136
|
+
if "cfg" in params:
|
|
1137
|
+
wvals[3] = float(params["cfg"])
|
|
1138
|
+
if "sampler_name" in params:
|
|
1139
|
+
wvals[4] = str(params["sampler_name"])
|
|
1140
|
+
if "scheduler" in params:
|
|
1141
|
+
wvals[5] = str(params["scheduler"])
|
|
1142
|
+
if "denoise" in params:
|
|
1143
|
+
wvals[6] = float(params["denoise"])
|
|
1144
|
+
elif "emptylatentimage" in ctype_lower or "emptysd3latentimage" in ctype_lower:
|
|
1145
|
+
while len(wvals) < 3:
|
|
1146
|
+
wvals.append(None)
|
|
1147
|
+
if "width" in params:
|
|
1148
|
+
wvals[0] = int(params["width"])
|
|
1149
|
+
if "height" in params:
|
|
1150
|
+
wvals[1] = int(params["height"])
|
|
1151
|
+
if "batch_size" in params:
|
|
1152
|
+
wvals[2] = int(params["batch_size"])
|
|
1153
|
+
elif "facedetailer" in ctype_lower:
|
|
1154
|
+
while len(wvals) < 8:
|
|
1155
|
+
wvals.append(None)
|
|
1156
|
+
if "guide_size" in params:
|
|
1157
|
+
wvals[0] = int(params["guide_size"])
|
|
1158
|
+
if "bbox_threshold" in params:
|
|
1159
|
+
wvals[1] = float(params["bbox_threshold"])
|
|
1160
|
+
if "steps" in params:
|
|
1161
|
+
wvals[3] = int(params["steps"])
|
|
1162
|
+
if "cfg" in params:
|
|
1163
|
+
wvals[4] = float(params["cfg"])
|
|
1164
|
+
if "denoise" in params:
|
|
1165
|
+
wvals[5] = float(params["denoise"])
|
|
1166
|
+
elif "checkpointloadersimple" in ctype_lower or "unetloader" in ctype_lower or "cliploader" in ctype_lower or "vaeloader" in ctype_lower:
|
|
1167
|
+
for k in ("ckpt_name", "unet_name", "clip_name", "vae_name"):
|
|
1168
|
+
if k in params:
|
|
1169
|
+
if len(wvals) >= 1:
|
|
1170
|
+
wvals[0] = str(params[k])
|
|
1171
|
+
else:
|
|
1172
|
+
wvals.append(str(params[k]))
|
|
1173
|
+
break
|
|
1174
|
+
elif "resolutionselector" in ctype_lower:
|
|
1175
|
+
if "aspect_ratio" in params:
|
|
1176
|
+
if len(wvals) >= 1:
|
|
1177
|
+
wvals[0] = str(params["aspect_ratio"])
|
|
1178
|
+
else:
|
|
1179
|
+
wvals.append(str(params["aspect_ratio"]))
|
|
1180
|
+
else:
|
|
1181
|
+
if "widgets_values" in params and isinstance(params["widgets_values"], list):
|
|
1182
|
+
wvals = params["widgets_values"]
|
|
1183
|
+
|
|
1184
|
+
target_node["widgets_values"] = wvals
|
|
1185
|
+
if "widgets_values_named" in target_node and isinstance(target_node["widgets_values_named"], dict):
|
|
1186
|
+
for k, v in params.items():
|
|
1187
|
+
if k != "widgets_values":
|
|
1188
|
+
target_node["widgets_values_named"][k] = v
|
|
1189
|
+
|
|
1190
|
+
elif fmt == "API_PROMPT":
|
|
1191
|
+
if node_id_str not in workflow_data:
|
|
1192
|
+
raise WorkflowError(f"Node #{node_id_str} not found in API prompt workflow.")
|
|
1193
|
+
node_entry = workflow_data[node_id_str]
|
|
1194
|
+
inputs = node_entry.setdefault("inputs", {})
|
|
1195
|
+
for k, v in params.items():
|
|
1196
|
+
if k == "widgets_values":
|
|
1197
|
+
continue
|
|
1198
|
+
inputs[k] = v
|
|
1199
|
+
|
|
1200
|
+
return workflow_data
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
def inspect_workflow_details(workflow_source: Union[str, Path, Dict[str, Any]]) -> Dict[str, Any]:
|
|
1204
|
+
"""Inspects a workflow and returns full DAG topology, node parameters, and wiring details."""
|
|
1205
|
+
raw = WorkflowParser.load_json(workflow_source) if not isinstance(workflow_source, dict) else workflow_source
|
|
1206
|
+
fmt = WorkflowParser.detect_format(raw)
|
|
1207
|
+
if fmt == "UI_GRAPH":
|
|
1208
|
+
raw = WorkflowParser.inline_subgraphs(raw)
|
|
1209
|
+
nodes = WorkflowParser.parse(raw)
|
|
1210
|
+
order = DAGEngine.topological_sort(nodes)
|
|
1211
|
+
|
|
1212
|
+
titles = {nid: (n.title or n.class_type) for nid, n in nodes.items()}
|
|
1213
|
+
|
|
1214
|
+
edges: List[Dict[str, Any]] = []
|
|
1215
|
+
outgoing_map: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
1216
|
+
incoming_map: Dict[str, List[Dict[str, Any]]] = defaultdict(list)
|
|
1217
|
+
|
|
1218
|
+
for dst_id, dst_node in nodes.items():
|
|
1219
|
+
for in_name, in_val in dst_node.inputs.items():
|
|
1220
|
+
if isinstance(in_val, NodeConnection):
|
|
1221
|
+
src_id = in_val.source_node_id
|
|
1222
|
+
src_slot = in_val.source_slot_index
|
|
1223
|
+
edge = {
|
|
1224
|
+
"from_node": src_id,
|
|
1225
|
+
"from_title": titles.get(src_id, src_id),
|
|
1226
|
+
"from_slot": src_slot,
|
|
1227
|
+
"to_node": dst_id,
|
|
1228
|
+
"to_title": titles.get(dst_id, dst_id),
|
|
1229
|
+
"to_slot": in_name,
|
|
1230
|
+
"type": "LINK"
|
|
1231
|
+
}
|
|
1232
|
+
edges.append(edge)
|
|
1233
|
+
incoming_map[dst_id].append({
|
|
1234
|
+
"slot": in_name,
|
|
1235
|
+
"from_node": src_id,
|
|
1236
|
+
"from_title": titles.get(src_id, src_id),
|
|
1237
|
+
"from_slot": src_slot
|
|
1238
|
+
})
|
|
1239
|
+
outgoing_map[src_id].append({
|
|
1240
|
+
"to_node": dst_id,
|
|
1241
|
+
"to_title": titles.get(dst_id, dst_id),
|
|
1242
|
+
"slot": in_name
|
|
1243
|
+
})
|
|
1244
|
+
|
|
1245
|
+
ROLE_MAP = {
|
|
1246
|
+
"checkpointloadersimple": "Loads neural network weights (UNet/DiT, CLIP, VAE) into device memory.",
|
|
1247
|
+
"unetloader": "Loads standalone UNet / DiT diffusion backbone into compute graph.",
|
|
1248
|
+
"cliploader": "Loads standalone CLIP / Multimodal text encoder weights into memory.",
|
|
1249
|
+
"dualcliploader": "Loads dual text encoders for SDXL / DiT models.",
|
|
1250
|
+
"vaeloader": "Loads standalone Variational Autoencoder (VAE) weights.",
|
|
1251
|
+
"loadcheckpoint": "Loads bundled checkpoint weights.",
|
|
1252
|
+
"diffusionmodel": "Loads diffusion backbone transformer weights.",
|
|
1253
|
+
"emptylatentimage": "Allocates empty latent noise tensor for diffusion sampling.",
|
|
1254
|
+
"emptysdxllatentimage": "Allocates SDXL-resolution empty latent noise tensor.",
|
|
1255
|
+
"emptysd3latentimage": "Allocates SD3 / DiT latent noise tensor for diffusion sampling.",
|
|
1256
|
+
"resolutionselector": "Calculates optimal canvas dimensions from aspect ratio and megapixels.",
|
|
1257
|
+
"modelsamplingauraflow": "Configures AuraFlow / Flux DiT sampling trajectory shift.",
|
|
1258
|
+
"modelsamplingflux": "Configures Flux DiT sampling trajectory shift.",
|
|
1259
|
+
"cliptextencode": "Encodes natural language prompt into conditioning vector embeddings.",
|
|
1260
|
+
"cliptextencodesdxl": "Encodes SDXL dual-text prompt into conditioning vector embeddings.",
|
|
1261
|
+
"conditioningzeroout": "Zeroes out text conditioning vector for CFG=1 distillation and speed optimization.",
|
|
1262
|
+
"ksampler": "Iteratively denoises latent canvas using numerical solver.",
|
|
1263
|
+
"ksampleradvanced": "Advanced multi-stage latent denoising with custom start/end step ranges.",
|
|
1264
|
+
"samplercustomadvanced": "Custom sampler pipeline with decoupled scheduler and step controllers.",
|
|
1265
|
+
"vaedecode": "Decodes compressed latent space into full-resolution RGB pixel image.",
|
|
1266
|
+
"vaedecodetiled": "Tiled VAE decoding with reduced peak VRAM consumption.",
|
|
1267
|
+
"facedetailer": "Detects facial bounding boxes, crops, and refines high-frequency features.",
|
|
1268
|
+
"facedetailerpipe": "Pipelined face detection and localized 2-step Img2Img refinement.",
|
|
1269
|
+
"detailerforeach": "Iterative multi-face localized crop and refinement loop.",
|
|
1270
|
+
"saveimage": "Encodes final RGB tensor and exports PNG artifact to gallery.",
|
|
1271
|
+
"saveimageadvanced": "Exports output images with advanced format and color space metadata.",
|
|
1272
|
+
"previewimage": "Previews in-flight image tensor in web viewport.",
|
|
1273
|
+
"markdownnote": "Displays workflow documentation, architectural instructions, and model links.",
|
|
1274
|
+
"note": "Informational workflow annotation note."
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
steps_info: List[Dict[str, Any]] = []
|
|
1278
|
+
nodes_detail: Dict[str, Dict[str, Any]] = {}
|
|
1279
|
+
|
|
1280
|
+
for idx, nid in enumerate(order, 1):
|
|
1281
|
+
n = nodes[nid]
|
|
1282
|
+
ctype_lower = n.class_type.lower()
|
|
1283
|
+
role = ROLE_MAP.get(ctype_lower, f"Executes {n.class_type} pipeline stage.")
|
|
1284
|
+
|
|
1285
|
+
# Extract resolved parameters
|
|
1286
|
+
params: Dict[str, Any] = {}
|
|
1287
|
+
for k, v in n.inputs.items():
|
|
1288
|
+
if not isinstance(v, NodeConnection):
|
|
1289
|
+
params[k] = v
|
|
1290
|
+
|
|
1291
|
+
if n.widgets_values:
|
|
1292
|
+
if ctype_lower in ("checkpointloadersimple", "unetloader", "cliploader", "vaeloader", "dualcliploader") and len(n.widgets_values) >= 1:
|
|
1293
|
+
params.setdefault("ckpt_name", n.widgets_values[0])
|
|
1294
|
+
elif ctype_lower in ("resolutionselector",) and len(n.widgets_values) >= 1:
|
|
1295
|
+
params.setdefault("aspect_ratio", n.widgets_values[0])
|
|
1296
|
+
elif ctype_lower in ("modelsamplingauraflow", "modelsamplingflux") and len(n.widgets_values) >= 1:
|
|
1297
|
+
params.setdefault("shift", n.widgets_values[0])
|
|
1298
|
+
elif ctype_lower in ("markdownnote", "note") and len(n.widgets_values) >= 1:
|
|
1299
|
+
params.setdefault("text", n.widgets_values[0])
|
|
1300
|
+
elif ctype_lower in ("emptylatentimage", "emptysdxllatentimage", "emptysd3latentimage") and len(n.widgets_values) >= 2:
|
|
1301
|
+
params.setdefault("width", n.widgets_values[0])
|
|
1302
|
+
params.setdefault("height", n.widgets_values[1])
|
|
1303
|
+
if len(n.widgets_values) >= 3:
|
|
1304
|
+
params.setdefault("batch_size", n.widgets_values[2])
|
|
1305
|
+
elif ctype_lower in ("cliptextencode", "cliptextencodesdxl") and len(n.widgets_values) >= 1:
|
|
1306
|
+
params.setdefault("text", n.widgets_values[0])
|
|
1307
|
+
elif "ksampler" in ctype_lower and len(n.widgets_values) >= 6:
|
|
1308
|
+
params.setdefault("seed", n.widgets_values[0])
|
|
1309
|
+
params.setdefault("steps", n.widgets_values[2])
|
|
1310
|
+
params.setdefault("cfg", n.widgets_values[3])
|
|
1311
|
+
params.setdefault("sampler_name", n.widgets_values[4])
|
|
1312
|
+
params.setdefault("scheduler", n.widgets_values[5])
|
|
1313
|
+
if len(n.widgets_values) >= 7:
|
|
1314
|
+
params.setdefault("denoise", n.widgets_values[6])
|
|
1315
|
+
elif "detailer" in ctype_lower and len(n.widgets_values) >= 6:
|
|
1316
|
+
params.setdefault("guide_size", n.widgets_values[0])
|
|
1317
|
+
params.setdefault("bbox_threshold", n.widgets_values[1])
|
|
1318
|
+
params.setdefault("steps", n.widgets_values[3])
|
|
1319
|
+
params.setdefault("denoise", n.widgets_values[5])
|
|
1320
|
+
elif ctype_lower in ("saveimage", "previewimage", "saveimageadvanced") and len(n.widgets_values) >= 1:
|
|
1321
|
+
params.setdefault("filename_prefix", n.widgets_values[0])
|
|
1322
|
+
|
|
1323
|
+
node_info = {
|
|
1324
|
+
"step": idx,
|
|
1325
|
+
"node_id": nid,
|
|
1326
|
+
"class_type": n.class_type,
|
|
1327
|
+
"title": n.title or n.class_type,
|
|
1328
|
+
"role": role,
|
|
1329
|
+
"parameters": params,
|
|
1330
|
+
"widgets_values": n.widgets_values,
|
|
1331
|
+
"connections_in": incoming_map.get(nid, []),
|
|
1332
|
+
"connections_out": outgoing_map.get(nid, [])
|
|
1333
|
+
}
|
|
1334
|
+
steps_info.append(node_info)
|
|
1335
|
+
nodes_detail[nid] = node_info
|
|
1336
|
+
|
|
1337
|
+
return {
|
|
1338
|
+
"format": fmt,
|
|
1339
|
+
"total_nodes": len(nodes),
|
|
1340
|
+
"execution_order": steps_info,
|
|
1341
|
+
"nodes_detail": nodes_detail,
|
|
1342
|
+
"edges": edges
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
|