List each saved workflow with its route in one line
list_workflows gives each card a route read from its saved graph: its passes in run order, the model each draws with, a split's steps, an enlargement, control models such as a Tile LLLite and what comes after. A Flash run picked a one-pass card for a two-model job; it saw names only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
9f72cfce83
commit
c84b5960fd
+3
-1
@@ -241,7 +241,9 @@ covered by the `styles.css` manifest.
|
||||
the list of an older Anomalous is told so and the task header says how to reconnect it.
|
||||
`api/mcp_fill.py` gives run_workflow its blanks, a daily run in one call: a saved workflow, or
|
||||
one of its 方案 ("workflow / plan": a saved parameter set of it, `mcp_tools.plans`, which
|
||||
list_workflows lists; the page opens the workflow and applies the set as its Apply button does),
|
||||
list_workflows lists, each card with its route in one line from `api/workflow_route.py`: its passes,
|
||||
models, enlargement, control models and finishing nodes, read from the saved graph as `recipe_flow.js`
|
||||
reads it on the page; the page opens the workflow and applies the set as its Apply button does),
|
||||
opened in the task's tab once and reused (the page marks the tab with the workflow's file, and the set's),
|
||||
then the prompts, seed, size and the picture a LoadImage reads filled in by the page's
|
||||
`fill_slots`, planned by `web/modules/mcp_fill.js` (pure). Any other setting goes by its name
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
- **Fix hands: enlarge, then fix** gains a SAM3 pass: after the light redraw, SAM3 finds the hands by the word (up to 8 in a picture) and redraws each inside its own outline, then the hand detector mends them in their boxes, as the reference author does in two passes. SAM3's nodes come with ComfyUI; its model (sam3.1, 1.7 GB) is one click in the Ways panel, checked by its SHA-256, and without it the way runs the detector's pass alone as before.
|
||||
- **半途换模型接着画** 可以在换模型那一步顺手放大(1.5 倍):后面的步数在大尺寸上画细节,省掉画完再放大重画的那一轮;AI 加一个放大节点、改两个设置就行,前后用同一个模型也可以。放大前,前一段会先把画面收干净:带着剩下的噪声直接放大,整张图会变成彩色方块。skill“分阶段出图的思路”补了一句。
|
||||
- **Switch models mid-way** can enlarge at the switch (1.5×): the later steps draw the detail at the larger size, in place of an upscale pass after; the AI adds one node and changes two settings, with the same model or another. The first part finishes its picture before it is enlarged: enlarged with its leftover noise, the picture turned into coloured blocks. The skill "Drawing a picture in stages" says so in one sentence.
|
||||
- AI 看你存的工作流卡时,每张卡多一行“路线”:分几段、每段用哪个模型、在哪放大、有没有 Tile 这类控制、最后接了什么(比如“2 段:出图 35 步(Harem 1.5)→ 放大 1.22 倍,用 Tile 在 Realskin 1.3 上重画 → ColorMatch”),不打开卡也知道它是一段出图还是两段放大,不会拿单段卡去做两段的活。
|
||||
- When an AI lists your saved workflow cards, each now has a route line: its passes, the model of each, where it enlarges, control such as a Tile, and what comes after ("2 passes: base 35 steps on Harem 1.5 -> enlarge 1.22x, redraw on Realskin 1.3 with Tile -> then ColorMatch"), so it tells a one-pass card from a two-pass one without opening it.
|
||||
- **先出几张底图给你挑**:分几段出图时,AI 可以只跑第一段(底图),一次几张,每张换个种子。这些底图只是预览,不存进输出文件夹,你画布上的工作流也一点不动。跑完 AI 工作台里自动弹出 **挑一张底图**,你点 **就选这张**,只有这一张存进输出文件夹(和卡平时存底图的地方一样),种子也记住。AI 接着跑整张卡时:像“两段放大”这种底图画完再交下去的卡,第二段直接读你挑的那张图,底图不再重画(6 GB 显卡上整张从约 190 秒变成约 110 秒);“半途换模型”交接的是画到一半的东西,就按记住的种子重画,跑完 AMB 再拿它和你挑的那张比一比,对不上就告诉你和 AI。预览存在 ComfyUI 的临时文件夹,ComfyUI 重启就清掉:没挑之前重启了,点的时候会告诉你那张的种子,让 AI 重画。
|
||||
- **Bases to pick from**: for a picture in passes, the AI can run the first pass (the base) alone, a few at once, each with its own seed. They are previews only, nothing goes into the output folder, and the workflow on your canvas stays as it is. When they are done, **Pick a base** opens in the AI workbench; **Take this one** keeps only that one in the output folder (where the card saves its bases) and holds its seed. When the AI then runs the whole card, a card that hands on the finished base (the two-pass upscale) has its second pass read the picture you picked, and the base is not drawn again (on a 6 GB card the run went from about 190 s to about 110 s); the mid-way switch hands on an unfinished picture, so its base is drawn again from the held seed, and when that run saves its base Anomalous compares it with yours and tells you and the AI if they differ. Previews live in ComfyUI's temp folder, emptied when ComfyUI restarts: picked after a restart, the panel tells you the seed to draw it again.
|
||||
|
||||
|
||||
+3
-2
@@ -39,6 +39,7 @@ from .path_utils import model_file_within, resolve_folder_subdir, resolve_within
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
from .scan_report import last_scan_path
|
||||
from .scan_summary import _summarize
|
||||
from . import workflow_route
|
||||
|
||||
MAX_LIMIT = 50
|
||||
MAX_SEARCH_PICTURES = 6 # search_images(with_pictures): the first few attached
|
||||
@@ -454,7 +455,7 @@ def list_workflows(query="", limit=20, *, exact_name=False):
|
||||
continue
|
||||
if not exact_name and not _matches(terms, name, " ".join(data.get("tags") or []), data.get("notes"), params, " ".join(own)):
|
||||
continue
|
||||
found.append({"name": name, "tags": data.get("tags") or [],
|
||||
found.append({"name": name, "route": workflow_route.line(data.get("workflow")), "tags": data.get("tags") or [],
|
||||
"notes": _text(data.get("notes"), 500), "saved": _when((data.get("timestamp") or 0) / 1000),
|
||||
"settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…",
|
||||
**({"plans": own} if own else {})}) # run_workflow(workflow=one of them) runs it with that set
|
||||
@@ -534,7 +535,7 @@ TOOLS = dict([
|
||||
"structure\") model nodes and text nodes saved from the canvas with their links, listed by slot.", list_combos,
|
||||
{"query": _QUERY, "limit": _LIMIT}),
|
||||
_tool("list_workflows", "Workflow recipes",
|
||||
"The user's saved workflow recipes with tags, notes, main settings and plans.", list_workflows,
|
||||
"The user's saved workflow recipes: route, tags, notes, settings, plans.", list_workflows,
|
||||
{"query": _QUERY, "limit": _LIMIT}),
|
||||
_tool("list_saved_prompts", "Saved prompts",
|
||||
"Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts,
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
"""A saved workflow's route in one line for the AI (list_workflows): its passes in the order they run,
|
||||
each with the model file it draws with, the part of a split it runs, its redraw strength, an
|
||||
enlargement before it and the control models patched in, then the picture nodes after the samplers.
|
||||
So an AI picking a card sees "one pass" from "base, then a Tile redraw on a second model" without
|
||||
opening it. Read from the serialized graph alone, as the workflow details' flow is on the page
|
||||
(web/modules/recipe_flow.js); widget names come from ComfyUI's node types (`names` in tests)."""
|
||||
|
||||
import re
|
||||
|
||||
CONTROL = re.compile(r"lllite|controlnet|t2iadapter|ipadapter", re.I)
|
||||
UPSCALE = re.compile(r"upscale|scaleby|imagescale", re.I)
|
||||
SAVE = re.compile(r"^(SaveImage|PreviewImage)|save", re.I)
|
||||
PLUMBING = re.compile(r"^(VAEDecode|VAEEncode|Reroute|Primitive|Note|MarkdownNote)")
|
||||
LOADER_VALUE = re.compile(r"\.(safetensors|ckpt|pt|pth|bin|gguf|sft)$", re.I)
|
||||
WIDGET_KINDS = ("INT", "FLOAT", "STRING", "BOOLEAN", "COMBO")
|
||||
SEEDS = ("seed", "noise_seed")
|
||||
|
||||
|
||||
def widget_names(node_type):
|
||||
"""A node type's widget names in the order its values are saved, or [] for a type not loaded."""
|
||||
import nodes # ComfyUI's registry, filled once ComfyUI has loaded the packs
|
||||
|
||||
cls = nodes.NODE_CLASS_MAPPINGS.get(node_type)
|
||||
try:
|
||||
spec = cls.INPUT_TYPES() if cls else {}
|
||||
except Exception: # noqa: BLE001 - a pack's own code; its values stay unnamed
|
||||
return []
|
||||
names = []
|
||||
for group in ("required", "optional"):
|
||||
for name, item in (spec.get(group) or {}).items():
|
||||
kind = item[0] if isinstance(item, (list, tuple)) and item else item
|
||||
options = item[1] if isinstance(item, (list, tuple)) and len(item) > 1 and isinstance(item[1], dict) else {}
|
||||
if options.get("forceInput") or not (isinstance(kind, list) or kind in WIDGET_KINDS):
|
||||
continue
|
||||
names.append(name)
|
||||
if kind == "INT" and (options.get("control_after_generate") or name in SEEDS):
|
||||
names.append("control_after_generate")
|
||||
return names
|
||||
|
||||
|
||||
def _values(node):
|
||||
values = node.get("widgets_values")
|
||||
return values if isinstance(values, list) else list((values or {}).values())
|
||||
|
||||
|
||||
def _off(node):
|
||||
return node.get("mode") in (2, 4)
|
||||
|
||||
|
||||
def _file(node):
|
||||
return next((value for value in _values(node) if isinstance(value, str) and LOADER_VALUE.search(value)), "")
|
||||
|
||||
|
||||
def _short(file):
|
||||
return LOADER_VALUE.sub("", re.split(r"[\\/]", str(file))[-1])
|
||||
|
||||
|
||||
def _sampler(node):
|
||||
names = {item.get("name") for item in node.get("inputs") or []}
|
||||
return {"model", "positive"} <= names and any("latent" in str(name) for name in names)
|
||||
|
||||
|
||||
def route(workflow, names=widget_names):
|
||||
"""[{model, loras, controls, steps, window, denoise, upscale, kind, state}] in the order the
|
||||
samplers run, and the picture nodes after them: (stages, after)."""
|
||||
nodes = {str(node.get("id")): node for node in (workflow or {}).get("nodes") or [] if isinstance(node, dict)}
|
||||
links = {}
|
||||
for link in (workflow or {}).get("links") or []:
|
||||
if isinstance(link, list) and len(link) >= 2:
|
||||
links[str(link[0])] = str(link[1])
|
||||
elif isinstance(link, dict):
|
||||
links[str(link.get("id"))] = str(link.get("origin_id"))
|
||||
source = lambda item: nodes.get(links.get(str(item.get("link")))) if item and item.get("link") is not None else None # noqa: E731
|
||||
named = {}
|
||||
|
||||
def value(node, name):
|
||||
if node["type"] not in named:
|
||||
named[node["type"]] = names(node["type"])
|
||||
at = named[node["type"]].index(name) if name in named[node["type"]] else -1
|
||||
values = _values(node)
|
||||
return values[at] if 0 <= at < len(values) else None
|
||||
|
||||
def feeders(sampler):
|
||||
seen, before, todo = set(), set(), [sampler]
|
||||
while todo:
|
||||
current = todo.pop()
|
||||
for item in current.get("inputs") or []:
|
||||
if current is sampler and item.get("name") in ("positive", "negative"):
|
||||
continue
|
||||
found = source(item)
|
||||
key = found and str(found["id"])
|
||||
if not found or key in seen or key in before:
|
||||
continue
|
||||
if _sampler(found):
|
||||
before.add(key)
|
||||
else:
|
||||
seen.add(key)
|
||||
todo.append(found)
|
||||
return seen, before
|
||||
|
||||
def model_of(sampler):
|
||||
out = {"model": "", "loras": [], "controls": []}
|
||||
node = source(next((item for item in sampler.get("inputs") or [] if item.get("name") == "model"), None))
|
||||
for _hop in range(16):
|
||||
if not node:
|
||||
break
|
||||
if not _off(node):
|
||||
if re.search("lora", node["type"], re.I):
|
||||
out["loras"].append(_short(_file(node)) or node["type"])
|
||||
elif CONTROL.search(node["type"]):
|
||||
out["controls"].append(_short(_file(node)) or node.get("title") or node["type"])
|
||||
elif _file(node) and not any(item.get("name") == "model" for item in node.get("inputs") or []):
|
||||
out["model"] = _short(_file(node))
|
||||
break
|
||||
node = source(next((item for item in node.get("inputs") or [] if item.get("name") == "model"), None))
|
||||
return out
|
||||
|
||||
samplers = [node for node in nodes.values() if _sampler(node)]
|
||||
fed = {str(node["id"]): feeders(node) for node in samplers}
|
||||
depth = {}
|
||||
|
||||
def level(key, guard=0):
|
||||
if key not in depth:
|
||||
depth[key] = 0 if guard > 16 else 1 + max([level(item, guard + 1) for item in fed[key][1]] or [0])
|
||||
return depth[key]
|
||||
|
||||
stages = []
|
||||
for node in sorted(samplers, key=lambda item: (level(str(item["id"])), int(item["id"]) if str(item["id"]).isdigit() else 0)):
|
||||
key = str(node["id"])
|
||||
steps, start, end = value(node, "steps"), value(node, "start_at_step"), value(node, "end_at_step")
|
||||
window = None if start is None or not isinstance(steps, int) else [start, min(end if isinstance(end, int) else steps, steps)]
|
||||
enlarge = next((nodes[item] for item in fed[key][0] if UPSCALE.search(nodes[item]["type"])), None)
|
||||
stages.append({**model_of(node), "steps": steps, "window": window, "denoise": value(node, "denoise"),
|
||||
"upscale": (value(enlarge, "scale_by") or True) if enlarge else None,
|
||||
"kind": "continue" if fed[key][1] else "base",
|
||||
"state": "off" if _off(node) else "skipped" if window and window[0] >= window[1] else "run"})
|
||||
staged = set().union(*[fed[str(node["id"])][0] for node in samplers]) if samplers else set()
|
||||
after, todo = [], list(samplers)
|
||||
users = {}
|
||||
for node in nodes.values():
|
||||
for item in node.get("inputs") or []:
|
||||
found = source(item)
|
||||
if found:
|
||||
users.setdefault(str(found["id"]), []).append(node)
|
||||
seen = set()
|
||||
while todo:
|
||||
for user in users.get(str(todo.pop()["id"]), []):
|
||||
key = str(user["id"])
|
||||
if key in seen or key in staged or _sampler(user):
|
||||
continue
|
||||
seen.add(key)
|
||||
if not PLUMBING.search(user["type"]) and not SAVE.search(user["type"]) and not _off(user):
|
||||
after.append(user.get("title") or user["type"])
|
||||
todo.append(user)
|
||||
return stages, after
|
||||
|
||||
|
||||
def line(workflow, names=widget_names):
|
||||
"""The route as one line for the AI, or "" when it has no sampler."""
|
||||
stages, after = route(workflow, names)
|
||||
parts = []
|
||||
for stage in stages:
|
||||
if stage["state"] != "run":
|
||||
continue
|
||||
words = ["base" if stage["kind"] == "base" else "redraw" if stage["upscale"] else "continue"]
|
||||
if stage["upscale"]:
|
||||
words.insert(0, f"enlarge {stage['upscale']}x," if stage["upscale"] is not True else "enlarge,")
|
||||
if stage["window"]:
|
||||
words.append(f"steps {stage['window'][0]}-{stage['window'][1]}")
|
||||
elif isinstance(stage["steps"], int):
|
||||
words.append(f"{stage['steps']} steps")
|
||||
if isinstance(stage["denoise"], (int, float)) and stage["denoise"] < 1:
|
||||
words.append(f"denoise {stage['denoise']}")
|
||||
words.append(f"on {stage['model'] or 'its model'}")
|
||||
if stage["loras"]:
|
||||
words.append("+ LoRA " + ", ".join(dict.fromkeys(stage["loras"])))
|
||||
if stage["controls"]:
|
||||
words.append("with " + ", ".join(dict.fromkeys(stage["controls"])))
|
||||
parts.append(" ".join(words))
|
||||
if not parts:
|
||||
return ""
|
||||
passes = f"{len(parts)} pass{'es' if len(parts) > 1 else ''}: "
|
||||
return passes + " -> ".join(parts) + (f" -> then {', '.join(dict.fromkeys(after))}" if after else "")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""A saved workflow's route in one line for the AI (api/workflow_route.py), read from the serialized graph
|
||||
with stand-in widget names, and its place in list_workflows."""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])]
|
||||
|
||||
from api import workflow_route # noqa: E402
|
||||
|
||||
NAMES = {
|
||||
"UNETLoader": ["unet_name", "weight_dtype"],
|
||||
"LoraLoader": ["lora_name", "strength_model", "strength_clip"],
|
||||
"KSampler": ["seed", "control_after_generate", "steps", "cfg", "sampler_name", "scheduler", "denoise"],
|
||||
"KSamplerAdvanced": ["add_noise", "noise_seed", "control_after_generate", "steps", "cfg", "sampler_name", "scheduler",
|
||||
"start_at_step", "end_at_step", "return_with_leftover_noise"],
|
||||
"LatentUpscaleBy": ["upscale_method", "scale_by"],
|
||||
}
|
||||
|
||||
|
||||
def node(id_, type_, inputs=(), values=(), mode=0):
|
||||
return {"id": id_, "type": type_, "mode": mode, "inputs": [{"name": name, "link": link} for name, link in inputs],
|
||||
"widgets_values": list(values)}
|
||||
|
||||
|
||||
def two_passes(second="Anima\\miaomiaoRealskin_anima13.safetensors"):
|
||||
"""The two-pass card: a base, then enlarged and redrawn on a second model under a Tile LLLite, then a colour match."""
|
||||
nodes = [
|
||||
node(1, "UNETLoader", values=["Anima\\MiaoMiao Harem_Anima_1.5.safetensors", "default"]),
|
||||
node(16, "LoraLoader", [("model", 1)], ["style.safetensors", 1, 1]),
|
||||
node(30, "UNETLoader", values=[second, "default"]),
|
||||
node(2, "CLIPTextEncode", values=["a lake"]),
|
||||
node(6, "EmptyLatentImage", values=[832, 1216, 1]),
|
||||
node(32, "KSamplerAdvanced", [("model", 2), ("positive", 3), ("negative", 3), ("latent_image", 4)],
|
||||
["enable", 7, "fixed", 35, 4.4, "dpmpp_2m_sde_gpu", "beta", 0, 35, "disable"]),
|
||||
node(7, "VAEDecode", [("samples", 5)]),
|
||||
node(9, "SaveImage", [("images", 6)], ["base"]),
|
||||
node(10, "VAEEncode", [("pixels", 7)]),
|
||||
node(11, "LatentUpscaleBy", [("samples", 8)], ["bislerp", 1.22]),
|
||||
node(18, "AnimaLLLiteApply_sdscripts", [("model", 9), ("image", 10)], ["animaTileRepair_v20.safetensors", 0.7]),
|
||||
node(19, "KSampler", [("model", 11), ("positive", 3), ("negative", 3), ("latent_image", 12)],
|
||||
[3, "increment", 30, 4.4, "euler", "normal", 0.85]),
|
||||
node(20, "VAEDecode", [("samples", 13)]),
|
||||
node(21, "ColorMatch", [("image_ref", 14), ("image_target", 15)]),
|
||||
node(22, "SaveImage", [("images", 16)], ["upscaled"]),
|
||||
]
|
||||
links = [[1, 1, 0], [2, 16, 0], [3, 2, 0], [4, 6, 0], [5, 32, 0], [6, 7, 0], [7, 7, 0], [8, 10, 0], [9, 30, 0], [10, 7, 0],
|
||||
[11, 18, 0], [12, 11, 0], [13, 19, 0], [14, 7, 0], [15, 20, 0], [16, 21, 0]]
|
||||
return {"nodes": nodes, "links": [[link_id, origin, slot, 0, 0, "*"] for link_id, origin, slot in links]}
|
||||
|
||||
|
||||
class RouteTests(unittest.TestCase):
|
||||
def line(self, workflow):
|
||||
return workflow_route.line(workflow, lambda kind: NAMES.get(kind, []))
|
||||
|
||||
def test_a_two_pass_card_says_its_passes_models_and_control(self):
|
||||
self.assertEqual(self.line(two_passes()),
|
||||
"2 passes: base steps 0-35 on MiaoMiao Harem_Anima_1.5 + LoRA style -> enlarge 1.22x, redraw 30 steps "
|
||||
"denoise 0.85 on miaomiaoRealskin_anima13 with animaTileRepair_v20 -> then ColorMatch")
|
||||
|
||||
def test_a_bypassed_redraw_leaves_one_pass(self):
|
||||
workflow = two_passes()
|
||||
next(item for item in workflow["nodes"] if item["id"] == 19)["mode"] = 4
|
||||
self.assertTrue(self.line(workflow).startswith("1 pass: base steps 0-35 on MiaoMiao Harem_Anima_1.5"))
|
||||
|
||||
def test_no_sampler_no_route_and_unknown_types_stay_unnamed(self):
|
||||
self.assertEqual(self.line({"nodes": [node(1, "UNETLoader", values=["a.safetensors"])], "links": []}), "")
|
||||
self.assertIn("on MiaoMiao Harem_Anima_1.5", workflow_route.line(two_passes(), lambda kind: []))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user