diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 091e642..801fb8b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -243,9 +243,13 @@ covered by the `styles.css` manifest. swapped with set_model), before canvas_wiring splices it in (`tests/canvas_roles.mjs`). A "standalone" block (the picture from words) names no role: it is built on the model `add_nodes(model=…)` names or the task's picked one, its family's own files found beside it ("files": Anima's text encoder and VAE), and only on a canvas making no picture. + Blocks chain: each says what it gives the next (its sampler and output node, "gives") and names its own + loader ("own_model"); `api/block_chain.py` builds a list of steps on a new tab, the picture first, each next + block anchored to what the step before gave (near_node_id and the page-only save_node_id, so several samplers + and outputs are no guess), a step's model on the block's own loader with its LoRAs (`tests/test_block_chain.py`). `api/mcp_routes.py` (data: `api/routes.json`) turns two picked main models into choices the user makes: `ask_user(routes=true)` shows the routes this ComfyUI can build for them, a saved card using both first, its answer says what to call, and - `add_nodes(route=…)` builds one from the ways on a new tab; `get_toolbox` lists each picked model's cards as its examples. + `add_nodes(route=…)` builds one from the ways on a new tab (a two-step chain); `get_toolbox` lists each picked model's cards as its examples. An AI placing nodes itself is a new route (`ai_gates.route_rule`): one reminder a task, or the user's OK. - Saves of a workflow. `api/workflow_saves.py` keeps them, a group for each canvas, found by the key in the workflow's extra or by its file (AD-023), and finds a run's pictures in ComfyUI's history; `web/modules/snapshot_store.js` is the page's diff --git a/CHANGELOG.md b/CHANGELOG.md index 00b9bf8..7431c28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,8 @@ - The model details show three more things above the description: for a LoRA without trigger words from Civitai, "Common words in its file (a guess)" (dashed, click to copy; a LoRA scanned before is read when opened); with the author's example pictures from a scan, "Settings of its author's examples" (sampler, steps, CFG, size and a LoRA's weight), which only the AI saw before; and for a model loaded in parts such as Anima or Flux, "Files it needs beside it" (text encoder and VAE), saying whether you have a file of that name. - AI 能查 AMB 自己的说明书了(`read_guide`):哪个页面、哪个功能怎么用,模型检查的完整规则,按一节一节查,中文用户就照中文界面上的字教你。内容就是 README 和规则文档里的那几节,不另写一份。为了给以后的新功能腾地方,展示面板那段很长的格式说明也挪进了说明书,看下载进度并进了 `run_status`:给 AI 的工具清单从 43,957 字减到 42,600 字左右,还多了一个工具。 - The AI can read Anomalous's own manual (read_guide): how each page and feature works, Model Check's full rules, a section at a time, and guides a Chinese user with the words of the Chinese window. It is the README's and the rules' own sections, not a second copy. To make room for what comes next, the long format of show_panel's blocks moved into it and the download status into run_status: the tool list AI apps get went from 43,957 to about 42,600 characters, with one tool more. +- 现成的做法(积木)都写明了做完以后交给下一块什么(哪个采样器、哪张图),AMB 能把几块按顺序接起来,不再在画布上有好几个采样器和保存节点时去猜接哪个。"换模型精修"和"脸手细化"接在别的步骤后面时,处理的是上一步出的那张图(以前拿的是采样器刚解码的图,排在放大后面会去改放大前那张)。两个模型的现成路线也改用这种接法搭。 +- The ready-made ways (blocks) now say what they hand the next one (which sampler, which picture), so Anomalous chains several in order instead of guessing when the canvas holds several samplers and outputs. Redraw with another model and face and hand detailing, put after another step, work on that step's picture (they took the sampler's fresh decode, so after an enlargement they changed the picture from before it). The two-model routes are built this way too. ## v1.63.0 — unreleased diff --git a/api/block_chain.py b/api/block_chain.py new file mode 100644 index 0000000..648bd7b --- /dev/null +++ b/api/block_chain.py @@ -0,0 +1,55 @@ +"""A chain of ready-made blocks (reference_blocks.py) built on a new tab, one after another: the plan an AI +lays out in big steps (make the picture, redraw it with another model, fix the faces, enlarge), built by +Anomalous so the AI wires nothing. The first step makes the picture (a block that stands alone, on the +model it names). Each next block goes on from what the step before gave (a block's "gives": the sampler +a next one takes its model, prompts and settings from, and the output node whose picture it works on), +both named to the page, so nothing is guessed when the canvas holds several samplers and outputs. A block +that gives nothing changes the step it is put on (a pose to follow, a split between two models), and the +next goes on from the same place. A step may name a model for the block's own loader ("own_model"), and +LoRAs: on the picture's model, or on a bare second loader (a copied model line has them already).""" + +from . import reference_blocks +from .mcp_tools import ToolError + + +def _loader(step, block, nodes): + """The node id of the block's own model loader in `nodes` (add_nodes' key -> id), or None.""" + own = reference_blocks.BLOCKS[block].get("own_model") or {} + found = next((nodes[key] for key in own.get("nodes") or () if key in nodes), None) + if step.get("model") and found is None: + raise ToolError(f"Step {block} has no loader of its own for a second model; leave model out.") + return None if found is None else int(found) + + +def build(ctx, steps, name="plan"): + """`steps` ([{use, want | block, model, loras, image}], the first a picture) on a new tab named `name`. + {"steps": [{"block", "nodes"}], "nodes": every step's nodes by "."} for what was built.""" + from . import mcp_actions, mcp_canvas_edit + + if not steps: + raise ToolError("A plan has at least its first step, the picture.") + mcp_canvas_edit.new_workflow(ctx, name) + at, built = {"sampler": None, "save": None}, [] + for index, step in enumerate(steps): + how = {key: step[key] for key in ("use", "want", "block", "image") if step.get(key) is not None} + if index == 0: + how["model"] = step.get("model") + else: + how.update(near_node_id=at["sampler"], save_node_id=at["save"]) + added = mcp_canvas_edit.add_nodes(ctx, **how) + block, nodes = added.get("block") or step.get("block"), added.get("nodes") or {} + if index == 0 and not reference_blocks.BLOCKS.get(block, {}).get("standalone"): + raise ToolError("A plan starts with the picture (use=picture).") + loader = int(nodes["model"]) if index == 0 else _loader(step, block, nodes) + if index and step.get("model"): + mcp_actions.set_model(ctx, loader, step["model"]) + copied = (reference_blocks.BLOCKS[block].get("own_model") or {}).get("copies_loras") + if loader is not None and not copied: + for lora in step.get("loras") or (): + mcp_actions.add_lora(ctx, lora, after_node_id=loader) + for role, key in (reference_blocks.BLOCKS[block].get("gives") or {}).items(): + if key in nodes: + at[role] = int(nodes[key]) + built.append({"block": block, "nodes": nodes}) + return {"steps": built, "nodes": {f"{at_step}.{key}": node for at_step, item in enumerate(built, 1) + for key, node in item["nodes"].items()}} diff --git a/api/blocks/change_scene.json b/api/blocks/change_scene.json index a168511..06bd5c1 100644 --- a/api/blocks/change_scene.json +++ b/api/blocks/change_scene.json @@ -108,6 +108,7 @@ } }, "after": "Write the new scene in the prompt and keep the character's own words (who, hair, clothes) so its edge fits, then run_workflow. The picture's size is the drawing's: the empty latent is no longer used. The kept character (cut out) is among the run's previews: if part of it is missing or the old background clings to it, say so; a halo at its edge: raise the GrowMask expand (6) a little.", + "gives": {}, "notes": "Denoise 1.0: the background is drawn anew, not changed. GrowMask lets the background reach 6 px into the character so the seam is redrawn.", "models": [], "tested": { diff --git a/api/blocks/color_lineart.json b/api/blocks/color_lineart.json index 19f5249..68552bd 100644 --- a/api/blocks/color_lineart.json +++ b/api/blocks/color_lineart.json @@ -80,6 +80,7 @@ } }, "after": "Set the picture's size to the drawing's shape (get_image_info on it; the empty latent's width and height), write the colours and style in the prompt (what is drawn, hair, clothes, background), then run_workflow. Its lines (white on black) are among the run's previews: if they are black on white, the drawing was light lines on dark: remove_nodes the ImageInvert and wire the picture straight in.", + "gives": {}, "notes": "A drawing is black lines on white; a lineart ControlNet reads white lines on black, hence ImageInvert.", "models": [ "union_promax_sdxl", diff --git a/api/blocks/control_depth.json b/api/blocks/control_depth.json index c432df3..0ea7c24 100644 --- a/api/blocks/control_depth.json +++ b/api/blocks/control_depth.json @@ -84,6 +84,7 @@ } }, "after": "run_workflow: the picture now keeps that layout (strength 0.7, for the first 70% of the steps; lower either to let the model change more). The prompt still says what is drawn. Its depth map is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), the picture is too hard to read.", + "gives": {}, "variants": { "anima": { "packs": { diff --git a/api/blocks/control_pose.json b/api/blocks/control_pose.json index 5c901b6..a1f8248 100644 --- a/api/blocks/control_pose.json +++ b/api/blocks/control_pose.json @@ -89,6 +89,7 @@ } }, "after": "run_workflow: the picture now takes that pose (strength 0.8, for the first 80% of the steps; lower either for a looser pose). The prompt still says who and what: change it if it fights the pose. Its pose skeleton is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), try control_depth.", + "gives": {}, "variants": { "anima": { "packs": { diff --git a/api/blocks/detail_face_hands.json b/api/blocks/detail_face_hands.json index 6f06706..6eb5e84 100644 --- a/api/blocks/detail_face_hands.json +++ b/api/blocks/detail_face_hands.json @@ -39,8 +39,8 @@ "class_type": "FaceDetailer", "inputs": { "image": [ - "$decode", - 0 + "$save.images", + "*" ], "model": [ "$sampler.model", @@ -117,6 +117,9 @@ } }, "after": "run_workflow saves the picture as before and the detailed one beside it (prefix detailed).", + "gives": { + "save": "save" + }, "models": [ "face_yolov8m", "hand_yolov8s" diff --git a/api/blocks/fix_hands.json b/api/blocks/fix_hands.json index 536d7ef..3449c78 100644 --- a/api/blocks/fix_hands.json +++ b/api/blocks/fix_hands.json @@ -231,6 +231,9 @@ } }, "after": "A fallback: a FLUX-Fill redraw of one square crop around each hand did better in the user's tests; use that when you have it. The picture is enlarged to about 4 megapixels (2× for one of about 1 megapixel, at most 2×, not at all for a large one) and drawn again lightly (the sampler now takes it, at denoise 0.4), then the hands are redrawn larger: with SAM3 here, first each hand it finds by the word inside its own outline (shaped_hands), then each hand the detector finds, in its box (hands, denoise 0.45), saved with prefix hands_fixed (without SAM3, which the user can get in the ways panel, only the detector's pass); the enlarged one is saved too, without the hand pass. About 3 tries in 4 gave a good hand in the pictures tried (one or two people, simple poses), no rate for crowds or hard poses: run_workflow with batch 4 (each try its own seed), then check them before the user does: zoom_image each hands_fixed picture on the hand, compare_with the enlarged one (the light redraw alone already changes faces and hands), and drop a hand with the wrong number of fingers, fused or melted fingers, one far bigger or smaller than its arm, a seam at its edge, or a hand that was fine made worse; say if a hand was missed. None good: run_workflow batch 4 again, at most twice. Then a picks panel zoomed on the hand lets the user choose among the good ones.", + "gives": { + "save": "save" + }, "notes": "Tried on the Codex test picture (1280×832, three characters, the hand about 110×78 px): the hand pass alone gave a good hand in about half the seeds whatever its size (512 or 768) and denoise (0.45 or 0.55); enlarging 1.5× first gave good hands in 3 of 4, 2× gave sharper fingers in the same seeds (about 105 s instead of 75 s). Denoise 0.4 for the enlarged redraw kept the layout; faces and splashes change a little. The SAM3 pass (2026-11-03): its word says how many it takes, \"hand\" alone only the likeliest hand, \"hand:8\" up to eight; on a 832×1216 picture of a girl waving both hands (enlarged 2×) it outlined both hands closely at threshold 0.5 (0.2 found no more), about 112 s with both passes on Anima with the Turbo LoRA. GPT's check the same day (two people, one hand with fused fingers, two seeds): SAM3 found both hands (one outline took in the wrist), both versions gave acceptable hands; on that one picture the SAM3 pass was not shown better than the detector alone.", "models": [ "hand_yolov8s", diff --git a/api/blocks/inpaint_basic.json b/api/blocks/inpaint_basic.json index 36d7f77..5cdf324 100644 --- a/api/blocks/inpaint_basic.json +++ b/api/blocks/inpaint_basic.json @@ -67,6 +67,7 @@ } }, "after": "The sampler repaints only the area, at denoise 0.5 so it keeps the picture's look (higher changes more). The marked area's size becomes the size of what is painted there: mark tightly around it (a hand, not the arm beside it). Keep denoise 0.35-0.5 to fix something and keep its size and pose; 0.7 and up paints a new thing that fills the whole mark. Hands and faces vary a lot from seed to seed: run_workflow with batch 4 and let the user pick, rather than judging one. Then run_workflow.", + "gives": {}, "models": [], "tested": {} } diff --git a/api/blocks/inpaint_crop_stitch.json b/api/blocks/inpaint_crop_stitch.json index 4d0f6b2..1c3d499 100644 --- a/api/blocks/inpaint_crop_stitch.json +++ b/api/blocks/inpaint_crop_stitch.json @@ -96,6 +96,7 @@ } }, "after": "The sampler now repaints only the cropped area, at denoise 0.45 (lower keeps more of the old). Its prompt is still the whole picture's: set_prompt it to what belongs in the area (\"a hand, five fingers\"), or it paints the whole subject there. The marked area's size becomes the size of what is painted there: mark tightly around it (a hand, not the arm beside it). Keep denoise 0.35-0.5 to fix something and keep its size and pose; 0.7 and up paints a new thing that fills the whole mark. Hands and faces vary a lot from seed to seed: run_workflow with batch 4 and let the user pick, rather than judging one. Then run_workflow; the saved picture is the whole one, stitched.", + "gives": {}, "notes": "Denoise 0.45: a fix. At 0.55-0.75 a hand came out the size of its mark (too big, 2026-10-29 Codex tests A-C), at 1 a model not made for inpainting paints a whole new subject into the crop; 0.4 kept the hand's size. Context factor 2.0 is the source's.", "variants": { "anima": { diff --git a/api/blocks/inpaint_lanpaint.json b/api/blocks/inpaint_lanpaint.json index 63a4b99..7e8d9ee 100644 --- a/api/blocks/inpaint_lanpaint.json +++ b/api/blocks/inpaint_lanpaint.json @@ -77,6 +77,9 @@ } }, "after": "The KSampler is now LanPaint's (same steps, cfg and seed): it samples the whole picture and changes only the area, blending it in. Keep the prompt about the whole picture and add what the area should show (set_prompt mode append: \"a hand, five fingers\"). Then run_workflow; it takes a few times longer than before.", + "gives": { + "sampler": "sampler" + }, "models": [], "tested": { "gpu": "RTX 4050 Laptop", diff --git a/api/blocks/picture.json b/api/blocks/picture.json index d956e09..80da3c4 100644 --- a/api/blocks/picture.json +++ b/api/blocks/picture.json @@ -230,6 +230,10 @@ } }, "after": "The picture's nodes are on the canvas, on the checkpoint picked (the loader titled Model), with common SDXL settings (28 steps, euler_ancestral, cfg 5.5, 832x1216; get_model's author_examples has what the model's author used). run_workflow with prompt and negative fills them in. A style LoRA the user picked: add_lora. A second pass after it (enlarge, another model): add_nodes with that use.", + "gives": { + "sampler": "sampler", + "save": "save" + }, "notes": "SDXL's values are common ones, not tested per model here; Anima's are the base pass of the user's tested two-pass card.", "tested": { "gpu": "RTX 4050 Laptop", diff --git a/api/blocks/place_in_scene.json b/api/blocks/place_in_scene.json index 8e42f15..6af44a7 100644 --- a/api/blocks/place_in_scene.json +++ b/api/blocks/place_in_scene.json @@ -164,6 +164,7 @@ } }, "after": "Write both in the prompt, the character (who, hair, clothes) and the place as it looks, then run_workflow. The size is the character picture's; the place is cropped to it. The pasted picture before the redraw is among the previews. The place changes too much: lower the denoise (0.5); the character still looks pasted on: raise it a little.", + "gives": {}, "notes": "Denoise 0.5 redraws the place lightly around the character, keeping its look; the character itself is not redrawn but for 6 px of its edge.", "models": [], "tested": { diff --git a/api/blocks/refine_other_model.json b/api/blocks/refine_other_model.json index 2e26f4e..cd87b2a 100644 --- a/api/blocks/refine_other_model.json +++ b/api/blocks/refine_other_model.json @@ -45,8 +45,8 @@ "class_type": "VAEEncode", "inputs": { "pixels": [ - "$decode", - 0 + "$save.images", + "*" ], "vae": [ "second_model", @@ -102,6 +102,16 @@ } }, "after": "The refine's loader (second_model) starts as the same checkpoint: set_model it to the one the user wants (ask_user, search_models), add_lora its own LoRAs onto it, and write its prompts (second_positive, second_negative, copied from the first) in that model's style. Another family may want other steps, cfg and sampler on redraw. Then run_workflow; the refined one is saved beside it (prefix refined). Denoise 0.4: lower keeps more of the first, higher takes more of the second.", + "gives": { + "sampler": "redraw", + "save": "save" + }, + "own_model": { + "nodes": [ + "second_model" + ], + "copies_loras": false + }, "notes": "The source keeps a separate loader and LoRA stack for refining. Its own CLIP and VAE, so a checkpoint of another family works. On an Anima model (UNET, text encoder and VAE in separate files) its variant loads only another UNET and reuses the canvas's text encoder and VAE: another Anima model.", "models": [], "tested": { @@ -133,8 +143,8 @@ "class_type": "VAEEncode", "inputs": { "pixels": [ - "$decode", - 0 + "$save.images", + "*" ], "vae": [ "$decode.vae", diff --git a/api/blocks/remove_background.json b/api/blocks/remove_background.json index f15da65..4cd13a8 100644 --- a/api/blocks/remove_background.json +++ b/api/blocks/remove_background.json @@ -48,6 +48,9 @@ } }, "after": "run_workflow saves it with the background cut away (transparent PNG, prefix nobg). On a canvas that makes pictures those are made too: to cut this one out alone, add the block on a blank tab (new_workflow).", + "gives": { + "save": "save" + }, "models": [], "tested": { "gpu": "RTX 4050 Laptop", diff --git a/api/blocks/restyle.json b/api/blocks/restyle.json index 68ca245..6723018 100644 --- a/api/blocks/restyle.json +++ b/api/blocks/restyle.json @@ -101,6 +101,7 @@ } }, "after": "Write what is in the picture and the new style in the prompt (e.g. watercolor, flat colors, oil painting), or add_lora a style LoRA, then run_workflow. The size is the picture's. Its outlines are among the previews. To keep more of the old colours: lower the denoise to 0.8 (the style then changes less); the layout drifts: raise the ControlNet strength (0.7).", + "gives": {}, "notes": "Canny outlines through the union ControlNet (its canny/lineart mode); Tried: denoise 0.8 kept the old look (only lighter); 1.0 gave the style, the outlines held the layout.", "models": [ "union_promax_sdxl" diff --git a/api/blocks/switch_midway.json b/api/blocks/switch_midway.json index 30de6d8..d262aba 100644 --- a/api/blocks/switch_midway.json +++ b/api/blocks/switch_midway.json @@ -68,6 +68,15 @@ } }, "after": "The canvas's sampler became two: the first runs steps 0 to 21 on the canvas's model, the second (second_model, a new loader that starts as the same model) from step 21 to the end, and the picture goes on to the decode as before. set_model second_model to the other model the user wants, of the same family (it reuses the prompts). A LoRA on the first model (a style) is not on the second: add_lora it onto second_model too when the user wants it kept. 21 is 60% of 35 steps: with other steps set the first sampler's end_at_step and the second's start_at_step both to about 60% (earlier: more of the second model, later: more of the first). er_sde split cleanly on one SDXL model but came out grainy on Anima: on another family's er_sde, dpmpp_2m_sde_gpu or euler is the safe choice for both. To enlarge at the switch too (the composition settled small, the later steps drawing the detail at the larger size, in place of an upscale pass after): add_node LatentUpscaleBy insert_after=first with values {\"upscale_method\": \"nearest-exact\", \"scale_by\": 1.5}, then one set_values: first {\"return_with_leftover_noise\": \"disable\"}, second {\"add_noise\": \"enable\"}. The first part then finishes its picture at the switch and the second noises the enlarged one anew: enlarged with its leftover noise, the picture breaks into coloured blocks. Keep the switch where it was: an earlier one redraws more (colours, clothes). second_model may stay the same model when only the enlargement is wanted. When the workflow already enlarges this picture after it (an upscale pass downstream), ask the user whether to keep that one: two enlargements are rarely wanted, and say the pictures differ in size when comparing. For a picture drawn from an empty latent; then run_workflow.", + "gives": { + "sampler": "second" + }, + "own_model": { + "nodes": [ + "second_model" + ], + "copies_loras": false + }, "notes": "KSampler Advanced: the first part returns its leftover noise, the second adds none and finishes it, so the two parts make one run of the steps. er_sde split, the same model in both parts, one seed each: grainy on two Anima models (Kirazuri all over, Harem 1.5 blotchy), clean on Nova Anime XL (SDXL); dpmpp_2m_sde_gpu and euler split cleanly. Tested on the user's route: Harem 1.5 for steps 0-21, Realskin 1.3 for 21-35, its style LoRA on both, 35 steps dpmpp_2m_sde_gpu beta; on SDXL: Nova Anime XL for steps 0-18, MiaoMiao Harem v2.0 for 18-30, dpmpp_2m_sde_gpu beta. Enlarging 1.5× at the switch (Harem 1.5 both parts, 35 steps, 832×1216 → 1248×1824, about 106 s): with the leftover noise enlarged (nearest-exact, the reference author's wiring) the picture came out covered in coloured blocks at step 21 and blots at step 12, the second part adding noise or not; the first part finishing (return_with_leftover_noise disable) and the second adding noise came out clean, at step 21 keeping the pose and layout, at step 12 changing colours and clothes. GPT's check on the user's Harem 1.5 → Realskin 1.3 card, enlarged at step 21: the AI changed both settings as told, no blocks; pose, hair and colours held, face and hair finer, ornaments and tassels redrawn; about 53 s more than the card's base.", "models": [], "tested": { diff --git a/api/blocks/tags_from_picture.json b/api/blocks/tags_from_picture.json index 53c823a..7717acb 100644 --- a/api/blocks/tags_from_picture.json +++ b/api/blocks/tags_from_picture.json @@ -43,6 +43,7 @@ } }, "after": "run_workflow (what the canvas makes is made too): run_status's answer has texts, the picture's tags. Write the prompt from them with set_prompt, keeping the quality words the prompt has, then remove_nodes these two so later runs skip them.", + "gives": {}, "notes": "The source's model and thresholds (wd-eva02-large-tagger-v3, 0.35, 0.85).", "models": [], "tested": { diff --git a/api/blocks/upscale.json b/api/blocks/upscale.json index 2eb773b..e0caf3f 100644 --- a/api/blocks/upscale.json +++ b/api/blocks/upscale.json @@ -17,6 +17,10 @@ "packs": {}, "upscaler": true, "after": "run_workflow saves the picture as before and the larger one beside it (prefix upscaled). The redraw sampler has the first one's steps and cfg at denoise 0.35: lower keeps more of the picture, higher adds more (and above 0.5 may change it). For 2× or more use upscale_tiled.", + "gives": { + "sampler": "redraw", + "save": "save" + }, "notes": "1.25× and denoise 0.35: the source used 1.25 and 0.32; its author says more than 1.5× goes wrong, and 1.5× of 832×1216 is too much for a 6 GB card. ColorMatch mkl after the redraw is the source's, optional (KJNodes).", "upscale_by": 1.25, "nodes": { diff --git a/api/blocks/upscale_tile.json b/api/blocks/upscale_tile.json index 651e646..f77df13 100644 --- a/api/blocks/upscale_tile.json +++ b/api/blocks/upscale_tile.json @@ -311,6 +311,17 @@ } }, "after": "run_workflow saves the picture as before and the 1.25x one beside it (prefix upscaled): a tile ControlNet holds it (strength 0.6, to 80% of the steps) while it is redrawn at denoise 0.5 with its own copy of the canvas's model and LoRAs (the loader titled Upscale model). For a second model (steadier, another look), set_model that loader to one of the same family (it reuses the first model's prompts); the LoRAs stay. Lower the denoise if it changes too much, raise the strength to hold it closer.", + "gives": { + "sampler": "redraw", + "save": "save" + }, + "own_model": { + "nodes": [ + "own_1", + "own" + ], + "copies_loras": true + }, "notes": "The source's tile values; denoise 0.5 because the tile holds the picture. The redraw's own model line replaced the separate second-model way (2026-11-04): same model by default, one swap for another. On Anima the user's tested route: Harem 1.5 base, Realskin 1.3 for the Tile upscale, its style LoRA on both, 832x1216 to 1016x1480, 116 s for the upscale on 6 GB.", "models": [ "union_promax_sdxl", diff --git a/api/blocks/upscale_tiled.json b/api/blocks/upscale_tiled.json index cdf9b54..2272577 100644 --- a/api/blocks/upscale_tiled.json +++ b/api/blocks/upscale_tiled.json @@ -21,6 +21,9 @@ "instead": "upscale", "upscaler": true, "after": "run_workflow saves the picture as before and the 2× one beside it (prefix upscaled); each tile is redrawn with the sampler's steps and cfg at denoise 0.3 (higher adds more detail, and may add seams).", + "gives": { + "save": "save" + }, "extras": { "ColorMatch": "ComfyUI-KJNodes" }, diff --git a/api/mcp_canvas_edit.py b/api/mcp_canvas_edit.py index f26c7af..d9e2085 100644 --- a/api/mcp_canvas_edit.py +++ b/api/mcp_canvas_edit.py @@ -271,7 +271,8 @@ def _empty_canvas(): raise ToolError("The canvas already makes a picture: run it, or new_workflow (blank) first for a separate one.") -def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, image=None, scene=None, use=None, want=None, model=None, route=None): +def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, image=None, scene=None, use=None, want=None, model=None, route=None, + save_node_id=None): if route is not None: return mcp_routes.build(ctx, str(route), model) if not isinstance(replace, bool): @@ -312,7 +313,10 @@ def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, ima _allowed(ctx, [spec["class_type"] for spec in nodes.values()]) answer = _next(_page("add_nodes", {"nodes": nodes, "replace": replace, "roles": built["roles"] if block is not None else {}, - "near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")}, timeout=60)) + "near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id"), + "save_node_id": None if save_node_id is None else _id(save_node_id, "save_node_id")}, timeout=60)) + if block is not None and isinstance(answer, dict): + answer["block"] = str(block) # which one this ComfyUI ran for the use (block_chain.py reads what it gives) if block is not None and isinstance(answer, dict) and not answer.get("unwired") and answer.get("reaches_output") is not False: answer["next"] = built["after"] if built["tested"]: diff --git a/api/mcp_routes.py b/api/mcp_routes.py index 022d806..863ff9e 100644 --- a/api/mcp_routes.py +++ b/api/mcp_routes.py @@ -2,13 +2,13 @@ model does not work out that two picked models mean one for the base and one for the enlargement, so Anomalous prepares the choices (routes.json, those this ComfyUI can build for these models, a saved card using both first): ask_user(routes=true) shows them, and add_nodes(route=…) builds the one picked on a new -tab from the ways (reference_blocks.py), its LoRAs on each model, so the AI wires nothing. Each picked +tab from the ways (reference_blocks.py) as a chain (block_chain.py), its LoRAs on each model, so the AI wires nothing. Each picked model's cards are its examples: get_toolbox lists them with their route (workflow_route.py).""" import json import os -from . import ai_bases, ai_tasks, ai_toolbox, model_advice, reference_blocks, workflow_route +from . import ai_bases, ai_tasks, ai_toolbox, block_chain, model_advice, reference_blocks, workflow_route from .mcp_tools import ToolError, _resolve_model from .recipe_store import _list_recipes, get_recipes_dir @@ -104,7 +104,7 @@ def waiting(task): def build(ctx, key, model=None): """Route `key` on a new tab: the picture on the first model, the picked LoRAs on it, the way after it with its own loader on the second model and the LoRAs there too. What was built, and what to do now.""" - from . import mcp_actions, mcp_canvas_edit + from . import mcp_canvas_edit route = ROUTES.get(key) if not route: @@ -114,22 +114,15 @@ def build(ctx, key, model=None): if len(picked) < route["models"]: raise ToolError(f"Route {key} draws with {route['models']} main models: the user picks them for the task, or give model.") ai_bases.forget(ctx.get("task")) # a base picked on another canvas: its sampler is not this route's - mcp_canvas_edit.new_workflow(ctx, key) - first = mcp_canvas_edit.add_nodes(ctx, use="picture", model=picked[0]) - node = int(first["nodes"]["model"]) loras = _loras(box) - for lora in loras: - mcp_actions.add_lora(ctx, lora, after_node_id=node) - answer = {"route": key, "built": first["nodes"], "next": NEXT if route.get("way") else ONE_PASS} + steps = [{"use": "picture", "model": picked[0], "loras": loras}] if route.get("way"): use, want = route["way"] - added = mcp_canvas_edit.add_nodes(ctx, use=use, want=want) - answer["built"] = {**first["nodes"], **added["nodes"]} - loader = int(next(added["nodes"][name] for name in route["loader"] if name in added["nodes"])) - mcp_actions.set_model(ctx, loader, picked[1]) - if "second_model" in route["loader"]: # loaded bare; the Tile's own model copied the LoRAs already - for lora in loras: - mcp_actions.add_lora(ctx, lora, after_node_id=loader) + steps.append({"use": use, "want": want, "model": picked[1], "loras": loras}) + chain = block_chain.build(ctx, steps, key) + answer = {"route": key, "built": chain["nodes"], "next": NEXT if route.get("way") else ONE_PASS} + if route.get("way"): + added = chain["steps"][1] if route.get("enlarge"): before, after = int(added["nodes"]["first"]), int(added["nodes"]["second"]) mcp_canvas_edit.add_node(ctx, "LatentUpscaleBy", {"upscale_method": "nearest-exact", "scale_by": 1.5}, insert_after=before) diff --git a/api/reference_blocks.py b/api/reference_blocks.py index ef3fdbe..c5cf594 100644 --- a/api/reference_blocks.py +++ b/api/reference_blocks.py @@ -12,7 +12,10 @@ latent), "$save" (the output node its picture reaches), "$loader" (the sampler's ("$sampler.positive.clip") followed upstream. A node "like" a role takes its settings of the same name; one that "replaces" a role takes its place, links and settings; one "copy_of" a path ("$sampler.model") is a copy of the canvas's model line there, loader and LoRAs (a redraw's own model). A block may have a variant for -a model family (on an Anima model an LLLite, or the Turbo LoRA for fewer steps). The directory +a model family (on an Anima model an LLLite, or the Turbo LoRA for fewer steps). Each block says what it +gives a block after it ("gives": its sampler and its output node, by key; {} for one that changes the step it +is put on) and names its own model loader when it has one ("own_model"), so blocks chain (block_chain.py); a +block going on from a step takes that step's picture from "$save.images". The directory (directory()) is what the AI is handed when it begins a task: the blocks this ComfyUI can run and the user has not turned off (user/anomalous/ai_blocks.json), a few words each, by job. """ diff --git a/api/routes.json b/api/routes.json index 7112f7a..d1b8f91 100644 --- a/api/routes.json +++ b/api/routes.json @@ -3,15 +3,13 @@ "label": "{a} draws the base, {b} redraws it enlarged under Tile", "detail": "The first settles the composition, the second adds detail and texture; a larger picture. Faces, hands and small ornaments get redrawn.", "models": 2, - "way": ["upscale", "detail"], - "loader": ["own_1", "own"] + "way": ["upscale", "detail"] }, "midway_enlarge": { "label": "{a} draws the early steps, {b} finishes, enlarged 1.5x at the switch", "detail": "The second model's look comes through more, on a larger picture; colours and ornaments may change.", "models": 2, "way": ["refine", "midway"], - "loader": ["second_model"], "enlarge": true, "only": ["anima"] }, @@ -20,7 +18,6 @@ "detail": "The second model's look comes through more, at the same size.", "models": 2, "way": ["refine", "midway"], - "loader": ["second_model"], "not": ["anima"] }, "one_pass": { diff --git a/tests/canvas_roles.mjs b/tests/canvas_roles.mjs index 5e25ede..66da8e4 100644 --- a/tests/canvas_roles.mjs +++ b/tests/canvas_roles.mjs @@ -56,6 +56,19 @@ function txt2img() { assert.equal(placeRoles(g, plain), plain, 'a group naming no role is passed on as it is'); } +// A chain's next block goes on from the output node it is given, not the one the sampler's decode reaches. +{ + const { g, decode, save } = txt2img(); + const bigger = g.node('ImageScaleBy', [['image', 'IMAGE']], [['IMAGE', 'IMAGE']]); + const later = g.node('SaveImage', [['images', 'IMAGE']], []); + g.wire(decode, 0, bigger, 'image'); g.wire(bigger, 0, later, 'images'); + assert.equal(findRoles(g).save, save, 'unasked: the sampler decode\'s own output'); + assert.equal(findRoles(g, null, later.id).save, later); + const placed = placeRoles(g, { a: { class_type: 'X', inputs: { image: ['$save.images', '*'] } } }, null, later.id); + assert.deepEqual(placed.a.inputs.image, [`#${bigger.id}`, 0], "the step before's picture"); + assert.throws(() => findRoles(g, null, 999), /No node #999/); +} + // The loader is past the LoRAs on the model line. { const { g, ckpt, sampler } = txt2img(); diff --git a/tests/test_block_chain.py b/tests/test_block_chain.py new file mode 100644 index 0000000..e56b340 --- /dev/null +++ b/tests/test_block_chain.py @@ -0,0 +1,93 @@ +"""A chain of ready-made blocks (api/block_chain.py): what each block gives the one after it, the chain steps +taking the step before's picture, and the chain built on a new tab with each step anchored to the one before.""" +import json +import sys +import unittest +from pathlib import Path +from unittest import mock + +PLUGIN = Path(__file__).resolve().parents[1] +sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1])] + +from api import block_chain, mcp_actions, mcp_canvas_edit, reference_blocks # noqa: E402 +from api.mcp_tools import ToolError # noqa: E402 + +A, B, LORA = "diffusion_models:0:a.safetensors", "diffusion_models:0:b.safetensors", "loras:0:style.safetensors" + + +def node_sets(block): + return [block["nodes"]] + [variant["nodes"] for variant in (block.get("variants") or {}).values() if variant.get("nodes")] + + +class BlockEndTests(unittest.TestCase): + def test_every_block_says_what_it_gives(self): + for name, block in reference_blocks.BLOCKS.items(): + with self.subTest(block=name): + self.assertIn("gives", block, "say {} for a block that changes the step it is put on") + self.assertLessEqual(set(block["gives"]), {"sampler", "save"}) + for nodes in node_sets(block): + for key in block["gives"].values(): + self.assertIn(key, nodes) + self.assertFalse(nodes[key].get("optional"), "a node a next block goes on from is always there") + own = block.get("own_model") + if own: + self.assertTrue(any(key in block["nodes"] for key in own["nodes"])) + + def test_a_chain_step_works_on_the_picture_of_the_step_before(self): + for name, block in reference_blocks.BLOCKS.items(): + chained = "save" in block["gives"] and not block.get("standalone") and block.get("takes") not in ("picture", "reference") + for nodes in node_sets(block) if chained else (): + for key, spec in nodes.items(): + with self.subTest(block=name, node=key): + self.assertNotIn(["$decode", 0], list(spec.get("inputs", {}).values()), + "$save.images: after an enlargement the decode's picture is the older one") + + +class ChainTests(unittest.TestCase): + def run_chain(self, steps, answers): + calls, answers = [], iter(answers) + with mock.patch.object(mcp_canvas_edit, "new_workflow", side_effect=lambda ctx, name: calls.append(("new", name))), \ + mock.patch.object(mcp_canvas_edit, "add_nodes", side_effect=lambda ctx, **kw: (calls.append(("add", kw)), next(answers))[1]), \ + mock.patch.object(mcp_actions, "add_lora", side_effect=lambda ctx, lora, after_node_id: calls.append(("lora", after_node_id))), \ + mock.patch.object(mcp_actions, "set_model", side_effect=lambda ctx, node, model: calls.append(("model", node, model))): + return calls, block_chain.build({}, steps, "plan") + + def test_each_step_goes_on_from_what_the_one_before_gave(self): + calls, built = self.run_chain( + [{"use": "picture", "model": A, "loras": [LORA]}, {"use": "refine", "model": B, "loras": [LORA]}, + {"use": "follow", "want": "pose", "image": "pose.png"}, {"use": "detail"}, {"use": "upscale"}], + [{"block": "picture", "nodes": {"model": "1", "sampler": "5", "save": "7"}}, + {"block": "refine_other_model", "nodes": {"second_model": "20", "redraw": "24", "save": "26"}}, + {"block": "control_pose", "nodes": {"look": "30"}}, + {"block": "detail_face_hands", "nodes": {"face": "40", "hands": "41", "save": "42"}}, + {"block": "upscale", "nodes": {"redraw": "50", "save": "53"}}]) + self.assertEqual(calls, [ + ("new", "plan"), ("add", {"use": "picture", "model": A}), ("lora", 1), + ("add", {"use": "refine", "near_node_id": 5, "save_node_id": 7}), ("model", 20, B), ("lora", 20), + ("add", {"use": "follow", "want": "pose", "image": "pose.png", "near_node_id": 24, "save_node_id": 26}), + ("add", {"use": "detail", "near_node_id": 24, "save_node_id": 26}), + ("add", {"use": "upscale", "near_node_id": 24, "save_node_id": 42})], + "a pose changes the step it is put on; the details keep the redraw's sampler and give their own picture") + self.assertEqual(built["nodes"]["5.save"], "53") + self.assertEqual([step["block"] for step in built["steps"]][-1], "upscale") + + def test_a_copied_model_line_keeps_its_loras(self): + calls, _built = self.run_chain( + [{"use": "picture", "model": A}, {"use": "upscale", "want": "detail", "model": B, "loras": [LORA]}], + [{"block": "picture", "nodes": {"model": "1", "sampler": "5", "save": "7"}}, + {"block": "upscale_tile", "nodes": {"own_1": "9", "own": "10", "redraw": "12", "save": "14"}}]) + self.assertEqual(calls[-1], ("model", 9, B)) + + def test_a_plan_starts_with_its_picture_and_a_model_needs_a_loader(self): + with self.assertRaisesRegex(ToolError, "at least its first step"): + self.run_chain([], []) + with self.assertRaisesRegex(ToolError, "starts with the picture"): + self.run_chain([{"use": "upscale"}], [{"block": "upscale", "nodes": {"redraw": "2", "save": "3"}}]) + with self.assertRaisesRegex(ToolError, "no loader of its own"): + self.run_chain([{"use": "picture"}, {"use": "upscale", "model": B}], + [{"block": "picture", "nodes": {"model": "1", "sampler": "5", "save": "7"}}, + {"block": "upscale", "nodes": {"redraw": "12", "save": "14"}}]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_mcp_routes.py b/tests/test_mcp_routes.py index 0f68015..345f507 100644 --- a/tests/test_mcp_routes.py +++ b/tests/test_mcp_routes.py @@ -79,7 +79,8 @@ class RouteTests(unittest.TestCase): def test_the_tile_route_is_built_on_a_new_tab_with_the_second_model(self): calls = [] - added = iter([{"nodes": {"model": "1"}}, {"nodes": {"own_1": "9", "own": "10"}}]) + added = iter([{"block": "picture", "nodes": {"model": "1", "sampler": "5", "save": "7"}}, + {"block": "upscale_tile", "nodes": {"own_1": "9", "own": "10", "redraw": "12", "save": "14"}}]) with mock.patch.object(mcp_canvas_edit, "new_workflow", side_effect=lambda ctx, name: calls.append(("new", name))), \ mock.patch.object(mcp_canvas_edit, "add_nodes", side_effect=lambda ctx, **kw: (calls.append(("add", kw)), next(added))[1]), \ mock.patch.object(mcp_actions, "add_lora", side_effect=lambda ctx, lora, after_node_id: calls.append(("lora", after_node_id))), \ @@ -88,8 +89,8 @@ class RouteTests(unittest.TestCase): mock.patch.object(mcp_routes.ai_toolbox, "effective", return_value={"models": [A, LORA, B]}): got = mcp_routes.build({"task": "t"}, "tile_second") self.assertEqual(calls, [("new", "tile_second"), ("add", {"use": "picture", "model": A}), ("lora", 1), - ("add", {"use": "upscale", "want": "detail"}), ("model", 9, B)], - "the LoRA goes on before the way copies the model line") + ("add", {"use": "upscale", "want": "detail", "near_node_id": 5, "save_node_id": 7}), ("model", 9, B)], + "the LoRA goes on before the way copies the model line; the way goes on from the picture's sampler and output") self.assertIn("base_only=true", got["next"]) def test_a_route_needs_its_models(self): diff --git a/web/modules/canvas_roles.js b/web/modules/canvas_roles.js index 5a8d84a..d64db5a 100644 --- a/web/modules/canvas_roles.js +++ b/web/modules/canvas_roles.js @@ -13,7 +13,9 @@ * LoRAs as new nodes with their settings, past bypassed nodes and control patches (a Tile on the first * pass is not the second's); the block's key is the copy's last node, its loader titled as the node is * (a redraw's own model, which the AI swaps for a second one with set_model, the LoRAs kept). - * Several samplers: near_node_id names the one, else nothing changes and they are listed. + * Several samplers: near_node_id names the one, else nothing changes and they are listed. A chain of + * blocks (block_chain.py) also names the output node the next block goes on from (save_node_id): its + * picture is the step before's, where a sampler's own decode would reach an older one. */ import { linkOf } from './mcp_canvas_brief.js'; @@ -64,8 +66,9 @@ function loaderOf(g, node) { return at === node ? null : at; } -/** The roles on graph `g`: {sampler, decode, save, loader}; decode, save and loader may be missing. */ -export function findRoles(g, nearId = null) { +/** The roles on graph `g`: {sampler, decode, save, loader}; decode, save and loader may be missing. `saveId` + * names the output node itself (a chain's step before). */ +export function findRoles(g, nearId = null, saveId = null) { const samplers = (g._nodes || []).filter(node => !OFF.has(node.mode) && SAMPLER_INPUTS.every(name => inputNamed(node, name))); let sampler = nearId == null ? null : samplers.find(node => String(node.id) === String(nearId)); if (!sampler) { @@ -80,7 +83,8 @@ export function findRoles(g, nearId = null) { const decode = takers(g, sampler, latent).find(node => inputNamed(node, 'samples') && (node.outputs || []).some(item => upper(item.type) === 'IMAGE')) || null; const picture = decode ? decode.outputs.findIndex(item => upper(item.type) === 'IMAGE') : -1; - const save = decode ? outputOf(g, decode, picture) || takers(g, decode, picture)[0] || null : null; + const save = saveId != null ? g.getNodeById(saveId) : decode ? outputOf(g, decode, picture) || takers(g, decode, picture)[0] || null : null; + if (saveId != null && !save) throw new Error(`No node #${saveId} on the canvas to go on from. Nothing added.`); return { sampler, decode, save, loader: loaderOf(g, sampler) }; } @@ -97,9 +101,9 @@ function upstream(g, node, path, ref) { } /** Group `nodes` (add_nodes' API form) with its roles resolved on graph `g`; a group naming none comes back as it is. */ -export function placeRoles(g, nodes, nearId = null) { +export function placeRoles(g, nodes, nearId = null, saveId = null) { if (!JSON.stringify(nodes).includes('"$')) return nodes; - const roles = findRoles(g, nearId); + const roles = findRoles(g, nearId, saveId); const role = (name, ref) => { const node = roles[name]; const what = { decode: 'VAE Decode', save: 'Save Image', loader: 'model loader' }[name] || name; diff --git a/web/modules/mcp_canvas_edit.js b/web/modules/mcp_canvas_edit.js index 759fff1..ce59780 100644 --- a/web/modules/mcp_canvas_edit.js +++ b/web/modules/mcp_canvas_edit.js @@ -160,11 +160,11 @@ export function canvasEditActions({ recorded, nodeById }) { } /** A group of new nodes in API form, wired among themselves, to the canvas ("#12", or a block's roles: canvas_roles.js) and by wire_to, as one block and one Ctrl+Z step. */ - function addNodes({ nodes: asked, near_node_id: nearId, replace = false, roles = {} }, getOwner) { + function addNodes({ nodes: asked, near_node_id: nearId, save_node_id: saveId = null, replace = false, roles = {} }, getOwner) { return recorded(getOwner, 'mcpDidNodes', async (g) => { - const nodes = placeRoles(g, asked, nearId); + const nodes = placeRoles(g, asked, nearId, saveId); // A block's settings on the canvas nodes it goes with (the sampler's denoise), in the same Ctrl+Z step. - const found = Object.keys(roles).length ? findRoles(g, nearId) : {}; + const found = Object.keys(roles).length ? findRoles(g, nearId, saveId) : {}; const settings = Object.entries(roles).filter(([role]) => found[role]).map(([role, values]) => [found[role], valuePlan(found[role], values)]); const key = workflowKey(app), before = JSON.stringify(g.serialize()); const built = await withFreshChoices(() => prepare(nodes));