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.
Files changed (39) hide show
  1. amv_diffusion-2.1.0.dist-info/METADATA +180 -0
  2. amv_diffusion-2.1.0.dist-info/RECORD +39 -0
  3. amv_diffusion-2.1.0.dist-info/WHEEL +5 -0
  4. amv_diffusion-2.1.0.dist-info/entry_points.txt +7 -0
  5. amv_diffusion-2.1.0.dist-info/licenses/LICENSE +21 -0
  6. amv_diffusion-2.1.0.dist-info/top_level.txt +1 -0
  7. termux_diffusion/__init__.py +185 -0
  8. termux_diffusion/__main__.py +7 -0
  9. termux_diffusion/_version.py +3 -0
  10. termux_diffusion/adapter.py +41 -0
  11. termux_diffusion/canvas.py +364 -0
  12. termux_diffusion/cli.py +492 -0
  13. termux_diffusion/cluster.py +340 -0
  14. termux_diffusion/control/__init__.py +5 -0
  15. termux_diffusion/control/component.py +309 -0
  16. termux_diffusion/control/status.py +15 -0
  17. termux_diffusion/core.py +2936 -0
  18. termux_diffusion/data/bootstrap-manifest.json +15 -0
  19. termux_diffusion/data/prompt_cache/108a7149c4e70550.json +10 -0
  20. termux_diffusion/data/release-public-keys.json +10 -0
  21. termux_diffusion/data/validated-vulkan-profiles.json +298 -0
  22. termux_diffusion/detailer.py +283 -0
  23. termux_diffusion/doctor.py +128 -0
  24. termux_diffusion/downloader.py +111 -0
  25. termux_diffusion/engine.py +226 -0
  26. termux_diffusion/exceptions.py +158 -0
  27. termux_diffusion/hardware.py +985 -0
  28. termux_diffusion/hub.py +686 -0
  29. termux_diffusion/installer.py +499 -0
  30. termux_diffusion/locking.py +134 -0
  31. termux_diffusion/manifest.py +128 -0
  32. termux_diffusion/npu.py +267 -0
  33. termux_diffusion/platform.py +533 -0
  34. termux_diffusion/purger.py +145 -0
  35. termux_diffusion/py.typed +1 -0
  36. termux_diffusion/safety.py +150 -0
  37. termux_diffusion/selftest.py +178 -0
  38. termux_diffusion/web.py +3370 -0
  39. 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
+