Let ready-made blocks say what they hand the next one, and build them as a chain
Every block in api/blocks names what it gives a block after it ("gives": its sampler and its
output node; {} for one that changes the step it is put on) and its own model 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, by near_node_id and a new page-only
save_node_id (canvas_roles.js findRoles pins the output node), so a canvas with several
samplers and outputs is no guess. add_nodes' answer names the block it built.
Redraw with another model and face/hand detailing now take the step before's picture
("$save.images") instead of the sampler's decode, which after an enlargement is the older
picture. The two-model routes are built as two-step chains; routes.json no longer names the
loaders.
Checked through the real page on the test ComfyUI: the Tile route on two Anima models (base on
Harem 1.5, Tile redraw on Kirazuri v4, from the base's picture and prompts), then face and hand
detailing after it took the Tile step's colour-matched picture.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
0f43385493
commit
8ecc261949
+5
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 "<step>.<key>"} 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()}}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
+9
-16
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
+1
-4
@@ -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": {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
@@ -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):
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user