From c6bacab7ff80907f954959feca80c2581af48cd8 Mon Sep 17 00:00:00 2001 From: DemonGatanjieu <207458273+DemonGatanjieu@users.noreply.github.com> Date: Wed, 4 Nov 2026 02:48:28 +0800 Subject: [PATCH] Add the picture way: an empty canvas starts on the picked model add_nodes(use="picture") builds a text-to-image graph on the model the user picked for the task, or add_nodes(model=...): one checkpoint for SDXL and its kin; for Anima the UNET with its text encoder and VAE, found in the model folders ("files"), and the tested base-pass sampling. A standalone block is built only on a canvas making no picture yet. Co-Authored-By: Claude Opus 5.5 --- ARCHITECTURE.md | 5 +- CHANGELOG.md | 2 + api/blocks/picture.json | 246 +++++++++++++++++++++++++++++++++ api/mcp_canvas_edit.py | 49 ++++++- api/reference_blocks.py | 24 +++- docs/guides/mcp.md | 2 +- tests/test_mcp_canvas_edit.py | 46 ++++++ tests/test_reference_blocks.py | 20 ++- 8 files changed, 381 insertions(+), 13 deletions(-) create mode 100644 api/blocks/picture.json diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ec75542..5238ce3 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -212,7 +212,10 @@ covered by the `styles.css` manifest. finds on the canvas by their sockets (the sampler, its decode, the output node its picture reaches, what feeds an input upstream; several samplers: near_node_id); a node "like" a role takes its settings (a path, "$sampler.positive", the node feeding it), one that "replaces" it its links and place too (LanPaint's sampler for the - KSampler), before canvas_wiring splices it in (`tests/canvas_roles.mjs`). A block's "variants" + KSampler), one "copy_of" "$sampler.model" is a copy of the canvas's model line, loader and LoRAs (a redraw's own model, + 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. A block's "variants" rebuild it for the canvas model's family (`reference_blocks.build`: on Anima an LLLite model patch instead of a ControlNet), and an "optional" node that only improves it (KJNodes' ColorMatch, the SAM3 pass before the hand detector) goes when its pack or its model (a block's diff --git a/CHANGELOG.md b/CHANGELOG.md index 67e336b..bd626ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 说要出图,AMB 就用你给任务挑的模型(没挑就用 AI 指定的)搭好出图的节点:SDXL 一类是一个 checkpoint;Anima 是模型、文本编码器、VAE 三个文件,AMB 自己在你的模型文件夹里找到后两个。设置用验证过的那套(Anima:35 步、dpmpp_2m_sde_gpu、beta、CFG 4.4、832×1216,就是你两段放大卡的底图那一段)。画布上已经有出图流程时不会再叠一套。之后要放大或换模型,再按用途加做法。 +- New way **Make a picture**: an empty canvas is a start too. When the AI wants a picture, Anomalous builds its nodes on the model you picked for the task (else the one the AI names): one checkpoint for SDXL and its kin; for Anima the model, its text encoder and VAE, the last two found in your model folders. With the tested settings (Anima: 35 steps, dpmpp_2m_sde_gpu, beta, CFG 4.4, 832×1216, the base pass of your two-pass card). A canvas already making a picture gets no second one. Enlarging or another model after it are ways by their use. - 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 重画。 diff --git a/api/blocks/picture.json b/api/blocks/picture.json new file mode 100644 index 0000000..d956e09 --- /dev/null +++ b/api/blocks/picture.json @@ -0,0 +1,246 @@ +{ + "job": "picture", + "rank": 1, + "short": "a picture from words", + "standalone": true, + "title": { + "zh": "出图", + "en": "Make a picture" + }, + "about": { + "zh": "在空画布上按模型的家族搭好出图的节点:SDXL 一类是一个 checkpoint,Anima 是模型、文本编码器、VAE 三个文件;设置用验证过的那一套。", + "en": "Builds the nodes that make a picture on an empty canvas, by the model's family: one checkpoint for SDXL and its kin, the model, text encoder and VAE files for Anima; with the tested settings." + }, + "source": { + "zh": "ComfyUI 的默认出图流程;Anima 的设置来自你验证过的两段放大卡的底图那一段", + "en": "ComfyUI's default text-to-image graph; Anima's settings from the base pass of the user's tested two-pass card" + }, + "packs": {}, + "nodes": { + "model": { + "class_type": "CheckpointLoaderSimple", + "_meta": { + "title": "Model" + }, + "inputs": { + "ckpt_name": "$model" + } + }, + "positive": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "", + "clip": [ + "model", + 1 + ] + } + }, + "negative": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "", + "clip": [ + "model", + 1 + ] + } + }, + "size": { + "class_type": "EmptyLatentImage", + "inputs": { + "width": 832, + "height": 1216, + "batch_size": 1 + } + }, + "sampler": { + "class_type": "KSampler", + "inputs": { + "model": [ + "model", + 0 + ], + "positive": [ + "positive", + 0 + ], + "negative": [ + "negative", + 0 + ], + "latent_image": [ + "size", + 0 + ], + "steps": 28, + "cfg": 5.5, + "sampler_name": "euler_ancestral", + "scheduler": "normal", + "denoise": 1.0 + } + }, + "decode": { + "class_type": "VAEDecode", + "inputs": { + "samples": [ + "sampler", + 0 + ], + "vae": [ + "model", + 2 + ] + } + }, + "save": { + "class_type": "SaveImage", + "inputs": { + "images": [ + "decode", + 0 + ], + "filename_prefix": "picture" + } + } + }, + "variants": { + "anima": { + "files": { + "$text_encoder": [ + "CLIPLoader", + "clip_name", + [ + "qwen_3_06b", + "qwen3_06b" + ] + ], + "$vae": [ + "VAELoader", + "vae_name", + [ + "qwenimage", + "qwen_image" + ] + ] + }, + "nodes": { + "model": { + "class_type": "UNETLoader", + "_meta": { + "title": "Model" + }, + "inputs": { + "unet_name": "$model", + "weight_dtype": "default" + } + }, + "text_encoder": { + "class_type": "CLIPLoader", + "inputs": { + "clip_name": "$text_encoder", + "type": "stable_diffusion" + } + }, + "vae": { + "class_type": "VAELoader", + "inputs": { + "vae_name": "$vae" + } + }, + "positive": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "", + "clip": [ + "text_encoder", + 0 + ] + } + }, + "negative": { + "class_type": "CLIPTextEncode", + "inputs": { + "text": "", + "clip": [ + "text_encoder", + 0 + ] + } + }, + "size": { + "class_type": "EmptyLatentImage", + "inputs": { + "width": 832, + "height": 1216, + "batch_size": 1 + } + }, + "sampler": { + "class_type": "KSampler", + "inputs": { + "model": [ + "model", + 0 + ], + "positive": [ + "positive", + 0 + ], + "negative": [ + "negative", + 0 + ], + "latent_image": [ + "size", + 0 + ], + "steps": 35, + "cfg": 4.4, + "sampler_name": "dpmpp_2m_sde_gpu", + "scheduler": "beta", + "denoise": 1.0 + } + }, + "decode": { + "class_type": "VAEDecode", + "inputs": { + "samples": [ + "sampler", + 0 + ], + "vae": [ + "vae", + 0 + ] + } + }, + "save": { + "class_type": "SaveImage", + "inputs": { + "images": [ + "decode", + 0 + ], + "filename_prefix": "picture" + } + } + }, + "after": "The picture's nodes are on the canvas, on the Anima model picked (the loader titled Model), with its text encoder and VAE and the tested sampling (35 steps, dpmpp_2m_sde_gpu, beta, cfg 4.4, 832x1216). 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." + } + }, + "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.", + "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", + "vram_gb": 6, + "date": "2026-11-03", + "runs": [ + { + "model": "Anima (MiaoMiao Harem 1.5)", + "size": "832×1216", + "seconds": 78 + } + ] + } +} diff --git a/api/mcp_canvas_edit.py b/api/mcp_canvas_edit.py index 9b75f6b..eae0c6d 100644 --- a/api/mcp_canvas_edit.py +++ b/api/mcp_canvas_edit.py @@ -7,13 +7,14 @@ from its own /object_info. Arguments are checked here for shape and size; the pa them against the nodes themselves before anything changes.""" import json +import os import threading import time from . import ai_gates, ai_pictures, ai_tasks, ai_toolbox, mcp_fill, model_advice, model_download, reference_blocks from .mcp_actions import _action, _local, _page from .media_routes import picture_size -from .mcp_tools import ToolError +from .mcp_tools import ToolError, _resolve_model MAX_FOUND = 20 MAX_SEARCH = 5 @@ -236,7 +237,40 @@ def _base_of(name): return model_advice.base_of(path) if path else "" -def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, image=None, scene=None, use=None, want=None): +def _picture_model(ctx, model_id, info): + """(loader value, base model) of the model a standalone block draws with: `model_id`, else the first main + model the user picked for the task. Among diffusion-model files only Anima's text encoder and VAE are known.""" + if not model_id: + task = ai_tasks.get_task(ctx["task"]) if ctx.get("task") else None + model_id = next((item for item in (ai_toolbox.effective(task) or {}).get("models") or [] + if item.split(":", 1)[0] in model_advice.MAIN_TYPES), None) + if not model_id: + raise ToolError("use picture needs the model to draw with: model (a model_id from search_models; ask_user which), " + "or the one the user picks for the task.") + kind, _index, root, path = _resolve_model(model_id) + base = model_advice.base_of(path) + loader = {"checkpoints": ("CheckpointLoaderSimple", "ckpt_name")}.get(kind, ("UNETLoader", "unet_name")) + if kind not in model_advice.MAIN_TYPES or (loader[0] == "UNETLoader" and model_advice.family(base) != "anima"): + raise ToolError(f"{model_id} is no model use picture draws with: a checkpoint, or an Anima diffusion model " + "(its text encoder and VAE are known). Open a saved workflow for it instead (list_workflows).") + wanted = os.path.relpath(path, root).replace("\\", "/") + value = next((name for name in reference_blocks._choices(info, *loader) if name.replace("\\", "/") == wanted), None) + if value is None: + raise ToolError(f"ComfyUI's {loader[0]} does not list {wanted}: is it in that model folder?") + return value, base + + +def _empty_canvas(): + """Refuses a standalone block on a canvas that already makes a picture: it would run as a second one.""" + try: + _page("block_model", {"near_node_id": None}) + except ToolError as error: + if "No sampler" in str(error): + return + 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): if not isinstance(replace, bool): raise ToolError("replace must be true or false.") if [nodes is not None, block is not None, use is not None].count(True) != 1: @@ -247,13 +281,17 @@ def add_nodes(ctx, nodes=None, near_node_id=None, replace=False, block=None, ima except reference_blocks.BlockError as error: raise ToolError(str(error)) if block is not None: + loader, base = None, None + if reference_blocks.BLOCKS.get(str(block), {}).get("standalone"): # a picture from words: the model it draws with, an empty canvas + loader, base = _picture_model(ctx, model, object_info(ctx)) + _empty_canvas() if reference_blocks.BLOCKS.get(str(block), {}).get("takes") in ("reference", "picture") and image: image = ai_pictures.to_input(image) # the picture to follow or work on, where LoadImage reads if reference_blocks.BLOCKS.get(str(block), {}).get("scene") and scene: scene = ai_pictures.to_input(scene) def build(): # a ready-made block (reference_blocks.py): its roles are found on the canvas, what it cannot splice it replaces - return reference_blocks.build(str(block), object_info(ctx), image, lambda: _main_base(near_node_id), _base_of, scene, - lambda name: picture_size(ai_tasks.folder_paths.get_annotated_filepath(name))) + return reference_blocks.build(str(block), object_info(ctx), image, (lambda: base) if loader else lambda: _main_base(near_node_id), + _base_of, scene, lambda name: picture_size(ai_tasks.folder_paths.get_annotated_filepath(name)), loader) try: try: built = build() @@ -350,7 +388,8 @@ CANVAS_TOOLS = dict([ "block": {"type": "string"}, "image": {"type": "string", "description": "A repaint block's inpaint_image; a control block's picture to follow."}, "scene": {"type": "string", "description": "The picture place_in_scene puts it into."}, - "near_node_id": _NODE_ID, "replace": {"type": "boolean"}}), + "near_node_id": _NODE_ID, "replace": {"type": "boolean"}, + "model": {"type": "string", "description": "use=picture: a model_id, else the task's picked one."}}), _action("connect_nodes", "Wire two nodes", "Wires two nodes: every unique matching type, and same-type sockets of the same name (positive to " "positive). Anything ambiguous (a lone CONDITIONING into positive or negative, wildcards) changes " diff --git a/api/reference_blocks.py b/api/reference_blocks.py index 7a1f148..931e597 100644 --- a/api/reference_blocks.py +++ b/api/reference_blocks.py @@ -41,7 +41,7 @@ NAMED_BASES = (("noob", "NoobAI"), ("illustrious", "Illustrious"), ("pony", "Pon ("sdxl", "SDXL 1.0"), ("xl", "SDXL 1.0")) # The uses an AI asks for (a block's "job"): add_nodes(use=…, want=…) and Anomalous picks the block, # the best of that use this ComfyUI can run; a block's "want" is the word that asks for it instead. -JOBS = {"repaint": "Repaint an area", "new_background": "New background, the character kept", "upscale": "Upscale", +JOBS = {"picture": "Text to picture", "repaint": "Repaint an area", "new_background": "New background, the character kept", "upscale": "Upscale", "refine": "Redraw with another model", "follow": "Follow a given picture", "detail": "Fix faces and hands", "process": "Picture work"} _lock = threading.Lock() @@ -306,6 +306,13 @@ def _lora(info, prefix): return sorted(found)[-1] if found else None +def _file_like(info, kind, field, marks): + """The first file node `kind`'s `field` offers whose name has one of `marks` (case, "_" and "-" aside), or None.""" + plain = lambda text: re.sub(r"[_\-\s]", "", str(text).lower()) # noqa: E731 + return next((name for name in _choices(info, kind, field) + if any(plain(mark) in plain(name.replace("\\", "/").split("/")[-1]) for mark in marks)), None) + + def _checkpoint(info, prefix): """The checkpoint file whose name has `prefix` this ComfyUI has (the newest version: SAM3.1 before SAM3), or None.""" found = [name for name in _choices(info, "CheckpointLoaderSimple", "ckpt_name") if prefix in name.lower().replace("\\", "/").split("/")[-1]] @@ -343,11 +350,11 @@ def enlargement(rule, size): return round(min(rule["most"], max(1.0, math.sqrt(rule["megapixels"] * 1_000_000 / (size[0] * size[1])))), 2) -def build(name, info, image=None, main_base=lambda: "", base_of=lambda name: "", scene=None, size_of=lambda image: None): +def build(name, info, image=None, main_base=lambda: "", base_of=lambda name: "", scene=None, size_of=lambda image: None, model=None): """Block `name` built for this canvas: {nodes (add_nodes' group, its values filled in), roles, after}, or BlockError saying why not. A block with variants or a ControlNet asks `main_base()` (once) for - the canvas's model's base model, and `base_of` for each ControlNet's; one that enlarges the picture - by its size ("enlarge") asks `size_of(image)`.""" + the canvas's model's base model (a standalone one's: `model`'s, the loader value it is built on), + and `base_of` for each ControlNet's; one that enlarges the picture by its size ("enlarge") asks `size_of(image)`.""" block = BLOCKS.get(name) if not block: raise BlockError(f"No block {name!r}; there are: {', '.join(BLOCKS)}.") @@ -375,7 +382,14 @@ def build(name, info, image=None, main_base=lambda: "", base_of=lambda name: "", raise BlockError(f"Block {name} needs image: {TAKES[block['takes']]}.") if block.get("scene") and not (isinstance(scene, str) and scene.strip()): raise BlockError(f"Block {name} needs {SCENE}.") - fills = {"$image": (image or "").strip(), "$scene": (scene or "").strip()} + if block.get("standalone") and not model: + raise BlockError(f"Block {name} needs the model to draw with: model (a model_id from search_models), or one the user picked for the task.") + fills = {"$image": (image or "").strip(), "$scene": (scene or "").strip(), "$model": model or ""} + for role, (kind, field, marks) in (block.get("files") or {}).items(): # files a family needs beside its model (Anima's text encoder, VAE) + fills[role] = _file_like(info, kind, field, marks) + if fills[role] is None: + raise BlockError(f"Block {name} needs a {kind} file named like {marks[0]} on this model; there is none here. " + "Ask the user to download it (search_models finds what is installed).") for role, prefix in (block.get("detectors") or {}).items(): fills[f"${role}"] = _detector(info, prefix) for role, prefix in (block.get("loras") or {}).items(): diff --git a/docs/guides/mcp.md b/docs/guides/mcp.md index 06df978..e18d112 100644 --- a/docs/guides/mcp.md +++ b/docs/guides/mcp.md @@ -148,7 +148,7 @@ claude mcp add --transport http anomalous http://127.0.0.1:8188/anomalous/mcp **让 AI 局部重绘**:标注里选 **▧ 涂重绘区**,用笔刷把要重画的地方涂上(笔刷粗细可调;框、圈、画笔圈出的地方也算),右边 **怎么改** 选 **只改标出的地方**(用笔刷时会自动选上)再发给 AI。另外两种是 **交给 AI 判断**(AI 看说明自己定)和 **整张重新出图**(不做局部重绘);你选的会作为你的要求写在 AI 收到的第一句,时间线里那条标注也写着“改法:…”。AMB 会在 ComfyUI 的 input 文件夹里放一张 `anomalous_inpaint_….png`:原图加上你涂的区域做遮罩。AI 收到时会被告诉怎么用它(LoadImage 读进来就带遮罩,用原图的模型和提示词,重绘强度 0.5~0.8 保留原样子,1.0 完全重画),不确定时用选择题问你强度,画完用前后对比给你看。这张文件删任务时不会删,因为你的工作流可能还在用它。 -**参考做法**:有几件常见的活,AMB 带着现成的做法,是从擅长这些活的工作流里提炼出来的:**局部重绘**(对 AI 只算一项,AMB 按你装的节点包挑:**裁切缝合**把圈出的地方裁出来放大到 1024 重画再缝回去,需要节点包 ComfyUI-Inpaint-CropAndStitch;**LanPaint** 融合最好、任何模型都行,但慢好几倍,要最好的效果时用,需要节点包 LanPaint;都没装就用**官方节点**)、**放大**(1.25 倍再轻轻重画一遍,只用自带节点,另存一张,文件名以 upscaled 开头)、**分块放大**(2 倍,需要节点包 ComfyUI_UltimateSDUpscale)、**照着姿势画**和**照着构图画**(你递给 AI 一张图,AMB 读出姿势或深度,挑一个和底模对得上的控制网,需要节点包 comfyui_controlnet_aux)、**Tile 锁图放大**(放大时用 Tile 钉住画面重画得更狠;放大那一段有自己的一份模型,标题 “Upscale model”,默认和底图同一个、LoRA 一起带上,换第二个模型只要换这一格;Anima 上用 Anima 的 Tile LLLite)、**背景移除**(需要节点包 comfyui-inspyrenet-rembg)、**读图出标签**(需要节点包 ComfyUI-WD14-Tagger,AI 跑完会收到读出的标签)、**部位细化(脸和手)**(需要 Impact Pack、Impact Subpack 和 models/ultralytics/bbox 里的检测模型,细化后的图另存一张,文件名以 detailed 开头)、**修手·先放大再修**(你递给 AI 一张图:整张放大到约 400 万像素轻轻重画,原图越小放得越大,最多 2 倍,让手变大,再逐只修手,另存一张,文件名以 hands_fixed 开头;Anima 模型上有 Anima Turbo LoRA 时只跑 10 步,1280×832 的图在 6GB 显卡上约 100 秒。AI 一次出 4 张,自己先放大看手,把手指数不对、粘连、大小不对的去掉,都不行就再出 4 张,最多两轮,再把好的放进只放大手那块的面板让你挑)。AI 工作台右边的**做法**面板列着全部做法:缺节点包或模型的变灰并写明缺什么,每块有个开关,关掉的 AI 就看不到。AI 开工时只收到开着又能用的那些;它说用哪块,AMB 就把那几个节点搭好、接进画布上那个出图的工作流里(采样器、VAE 解码、保存图片都由 AMB 按插口认出来),一次就好,也是一步 Ctrl+Z。画布上有好几个采样器时,AI 得指明用哪一个;缺节点包时,它会知道缺哪个、该用哪块代替。 +**参考做法**:有几件常见的活,AMB 带着现成的做法,是从擅长这些活的工作流里提炼出来的:**出图**(空画布起步,用你给任务挑的模型或 AI 指定的模型搭好出图节点;Anima 的文本编码器和 VAE 由 AMB 找)、**局部重绘**(对 AI 只算一项,AMB 按你装的节点包挑:**裁切缝合**把圈出的地方裁出来放大到 1024 重画再缝回去,需要节点包 ComfyUI-Inpaint-CropAndStitch;**LanPaint** 融合最好、任何模型都行,但慢好几倍,要最好的效果时用,需要节点包 LanPaint;都没装就用**官方节点**)、**放大**(1.25 倍再轻轻重画一遍,只用自带节点,另存一张,文件名以 upscaled 开头)、**分块放大**(2 倍,需要节点包 ComfyUI_UltimateSDUpscale)、**照着姿势画**和**照着构图画**(你递给 AI 一张图,AMB 读出姿势或深度,挑一个和底模对得上的控制网,需要节点包 comfyui_controlnet_aux)、**Tile 锁图放大**(放大时用 Tile 钉住画面重画得更狠;放大那一段有自己的一份模型,标题 “Upscale model”,默认和底图同一个、LoRA 一起带上,换第二个模型只要换这一格;Anima 上用 Anima 的 Tile LLLite)、**背景移除**(需要节点包 comfyui-inspyrenet-rembg)、**读图出标签**(需要节点包 ComfyUI-WD14-Tagger,AI 跑完会收到读出的标签)、**部位细化(脸和手)**(需要 Impact Pack、Impact Subpack 和 models/ultralytics/bbox 里的检测模型,细化后的图另存一张,文件名以 detailed 开头)、**修手·先放大再修**(你递给 AI 一张图:整张放大到约 400 万像素轻轻重画,原图越小放得越大,最多 2 倍,让手变大,再逐只修手,另存一张,文件名以 hands_fixed 开头;Anima 模型上有 Anima Turbo LoRA 时只跑 10 步,1280×832 的图在 6GB 显卡上约 100 秒。AI 一次出 4 张,自己先放大看手,把手指数不对、粘连、大小不对的去掉,都不行就再出 4 张,最多两轮,再把好的放进只放大手那块的面板让你挑)。AI 工作台右边的**做法**面板列着全部做法:缺节点包或模型的变灰并写明缺什么,每块有个开关,关掉的 AI 就看不到。AI 开工时只收到开着又能用的那些;它说用哪块,AMB 就把那几个节点搭好、接进画布上那个出图的工作流里(采样器、VAE 解码、保存图片都由 AMB 按插口认出来),一次就好,也是一步 Ctrl+Z。画布上有好几个采样器时,AI 得指明用哪一个;缺节点包时,它会知道缺哪个、该用哪块代替。 **skill**:和做法并列,是 AI 遇到某类活时按需读取的建议;做法负责自动搭节点。AI 开工时只看到每个 skill 的名字和一句话,真要做那类活时才用 `get_toolbox(skill=名字)` 取全文,不增加工具数量。**写提示词·动作适合模型**按画布主模型的大小给动作建议,用户指定的姿势和提示词照原样;**分阶段出图的思路**只讲一张图分几步画的路子:同一张图前后段换模型(`add_nodes(use="refine", want="midway")`)、锁住原图放大重画(`want="detail"`,可以在这一步顺手把放大用的模型换成同类的第二个)、画完换模型接着修;什么时候必须是同一类模型,你选的画风 LoRA 要不要一起挂到第二个模型上。你把路线存成过工作流卡或搭配,AI 就直接用你的;具体模型和数值在做法和你的搭配里,不写进 skill。重画力度大时脸、手和小细节还是会变,不保证复刻原图。做法面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。AI 每取一个 skill,时间线上多一行 **Skill**;之后那次跑图下面也写着参照了哪个。隐私任务只提供隐私 skill,不提供普通 skill。 diff --git a/tests/test_mcp_canvas_edit.py b/tests/test_mcp_canvas_edit.py index f16654c..ab58feb 100644 --- a/tests/test_mcp_canvas_edit.py +++ b/tests/test_mcp_canvas_edit.py @@ -177,6 +177,52 @@ class BuildToolTests(unittest.TestCase): self.page.assert_not_called() +class PictureTests(unittest.TestCase): + """add_nodes(use="picture"): the model picked for the task, an empty canvas, the family's files beside it.""" + + INFO = {"UNETLoader": {"input": {"required": {"unet_name": [["Anima\harem.safetensors"]]}}}, + "CLIPLoader": {"input": {"required": {"clip_name": [["Anima\qwen_3_06b_base.safetensors"]]}}}, + "VAELoader": {"input": {"required": {"vae_name": [["Anima\qwenImageGGUF_vae.safetensors"]]}}}} + + def setUp(self): + self.canvas = None # what the page's sampler reads: None, an empty canvas + + def page(action, args=None, timeout=30): + if action == "block_model": + if self.canvas is None: + raise ToolError("No sampler (KSampler) on the canvas: open a workflow that makes the picture first.") + return {"model": self.canvas} + return {"ok": True} + self.page = mock.MagicMock(side_effect=page) + root = str(Path("/models/diffusion_models")) + for patcher in (mock.patch.object(mcp_canvas_edit, "_page", self.page), + mock.patch.object(mcp_canvas_edit, "object_info", return_value=self.INFO), + mock.patch.object(mcp_canvas_edit, "_resolve_model", + return_value=("diffusion_models", 0, root, str(Path(root, "Anima", "harem.safetensors")))), + mock.patch.object(mcp_canvas_edit.model_advice, "base_of", return_value="Anima"), + mock.patch.object(mcp_canvas_edit.ai_tasks, "get_task", return_value={}), + mock.patch.object(mcp_canvas_edit.ai_toolbox, "effective", + return_value={"models": ["loras:0:style.safetensors", "diffusion_models:0:Anima/harem.safetensors"]})): + patcher.start() + self.addCleanup(patcher.stop) + + def test_the_picked_model_on_an_empty_canvas(self): + mcp_canvas_edit.add_nodes({"task": "t"}, use="picture") + nodes = next(call[0][1]["nodes"] for call in self.page.call_args_list if call[0][0] == "add_nodes") + self.assertEqual((nodes["model"]["inputs"]["unet_name"], nodes["text_encoder"]["inputs"]["clip_name"]), + ("Anima\harem.safetensors", "Anima\qwen_3_06b_base.safetensors"), "as ComfyUI lists them") + + def test_a_canvas_that_makes_a_picture_or_no_model_is_refused(self): + self.canvas = "Anima\harem.safetensors" + with self.assertRaisesRegex(ToolError, "already makes a picture"): + mcp_canvas_edit.add_nodes({"task": "t"}, use="picture") + self.canvas = None + with mock.patch.object(mcp_canvas_edit.ai_toolbox, "effective", return_value=None): + with self.assertRaisesRegex(ToolError, "needs the model to draw with"): + mcp_canvas_edit.add_nodes({"task": "t"}, use="picture") + self.assertFalse(any(call[0][0] == "add_nodes" for call in self.page.call_args_list), "nothing built") + + class RegistryTests(unittest.TestCase): def test_served_with_their_place_in_the_flow(self): for name in mcp_canvas_edit.CANVAS_TOOLS: diff --git a/tests/test_reference_blocks.py b/tests/test_reference_blocks.py index eda4dd8..9ebfe5e 100644 --- a/tests/test_reference_blocks.py +++ b/tests/test_reference_blocks.py @@ -108,7 +108,7 @@ class BlockTests(unittest.TestCase): self.assertEqual((placed["picture"]["inputs"]["image"], placed["place"]["inputs"]["image"]), ("a.png", "beach.png")) with self.assertRaisesRegex(reference_blocks.BlockError, "not installed here. Use block inpaint_basic"): reference_blocks.nodes_of("inpaint_crop_stitch", BUILT_IN, "a.png") - with self.assertRaisesRegex(reference_blocks.BlockError, "there are: inpaint_crop_stitch"): + with self.assertRaisesRegex(reference_blocks.BlockError, "there are: picture, inpaint_crop_stitch"): reference_blocks.nodes_of("nope", EVERYTHING) def test_detailing_takes_the_detector_models_this_comfyui_has(self): @@ -203,6 +203,24 @@ class BlockTests(unittest.TestCase): with self.assertRaises(reference_blocks.MissingModel): reference_blocks.build("upscale_tile", missing, main_base=lambda: "Anima") + def test_a_picture_from_words_is_built_by_the_model_family_on_the_model_picked(self): + anima = {**EVERYTHING, "CLIPLoader": {"input": {"required": {"clip_name": [["clip_l.safetensors", "Anima/qwen_3_06b_base.safetensors"]]}}}, + "VAELoader": {"input": {"required": {"vae_name": [["sdxl_vae.safetensors", "Anima/qwenImageGGUF_vae.safetensors"]]}}}} + self.assertEqual(reference_blocks.pick("picture", None, anima), "picture") + with self.assertRaisesRegex(reference_blocks.BlockError, "needs the model to draw with"): + reference_blocks.build("picture", anima, main_base=lambda: "Anima") + nodes = reference_blocks.build("picture", anima, main_base=lambda: "Anima", model="Anima\harem.safetensors")["nodes"] + self.assertEqual((nodes["model"]["class_type"], nodes["model"]["inputs"]["unet_name"]), ("UNETLoader", "Anima\harem.safetensors")) + self.assertEqual((nodes["text_encoder"]["inputs"]["clip_name"], nodes["vae"]["inputs"]["vae_name"]), + ("Anima/qwen_3_06b_base.safetensors", "Anima/qwenImageGGUF_vae.safetensors"), "its own files beside it") + self.assertEqual([nodes["sampler"]["inputs"][key] for key in ("steps", "cfg", "sampler_name", "scheduler")], [35, 4.4, "dpmpp_2m_sde_gpu", "beta"]) + no_encoder = {**anima, "CLIPLoader": {"input": {"required": {"clip_name": [["clip_l.safetensors"]]}}}} + with self.assertRaisesRegex(reference_blocks.BlockError, "needs a CLIPLoader file named like qwen_3_06b"): + reference_blocks.build("picture", no_encoder, main_base=lambda: "Anima", model="Anima\harem.safetensors") + sdxl = reference_blocks.build("picture", anima, main_base=lambda: "Illustrious", model="SDXL/nova.safetensors")["nodes"] + self.assertEqual((sdxl["model"]["inputs"]["ckpt_name"], sdxl["decode"]["inputs"]["vae"]), ("SDXL/nova.safetensors", ["model", 2])) + self.assertNotIn("text_encoder", sdxl) + def test_a_node_only_making_a_block_better_goes_when_its_pack_is_missing(self): nodes = reference_blocks.nodes_of("upscale", {**EVERYTHING, "ColorMatch": {}}) self.assertEqual((nodes["match"]["inputs"]["image_target"], nodes["save"]["inputs"]["images"]), (["decode", 0], ["match", 0]))