Compare commits
183
Commits
@@ -108,7 +108,7 @@ ComfyUI-Manager 收录审核检查的就是这几类口子(2026-10 审 Anomalo
|
||||
1. **不执行下载来的或用户文件里的代码。** 数据不用 `pickle`、`eval`、`exec`、动态导入读;配置文件当数据解析。模型文件只读 safetensors / GGUF 的文件头,`.ckpt` / `.pt` / `.pth` / `.bin` 不打开,界面提醒它们可能带代码。
|
||||
2. **下载要能核对。** 只从允许的 https 站点下载,先写 `.part`,核对 SHA-256 后才改名放入;没有可核对的指纹时在界面上说明。下载的东西只当数据用。
|
||||
3. **接口不接受任意本机路径。** 只收“类型 + 根目录序号 + 相对路径”,在后端用 `resolve_within` 校验;需要指向本机某个文件时,由服务端自己找到它,只给页面一个令牌。模型文件本身可以是链接(云服务器上模型文件夹里全是指向平台模型盘的链接):文件夹部分照样用 `resolve_within`,文件用 `model_file_within` / `model_reference_within` 按名字取,不跟着链接走出去,封面、信息和改动都在链接旁边。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。唯一的例外是云服务器用户在服务器上的插件文件夹里放了 `allow_remote_changes` 文件(启动时读一次,网页建不了也删不掉):这时 `local_guard.may_change` 也放行别的设备;接口里要自己判断时用 `may_change`,只有“浏览器就在这台电脑上”才成立的事(比如在本机的下载文件夹里找原文件)才用 `is_local`。MCP 始终只接受本机。
|
||||
4. **会写入、移动、删除文件或替用户联网的接口只接受本机请求**(请求来自本机,没有经过代理,Host 也是本机;ComfyUI 只监听局域网地址时,本机用这个地址打开也算本机)。用 `--listen` 开放到局域网时,别的设备只能查看。由 `api/local_guard.py` 的中间件统一把关:`/anomalous/` 下 GET 以外的请求默认只接受本机,确实只读的 POST 列进 `OPEN_POSTS`,不只是读的 GET(备份文件、联网、本机文件夹)列进 `LOCAL_GETS`;新接口要想清楚属于哪一类,不要为了方便放进 `OPEN_POSTS`。唯一的例外是云服务器用户在服务器上的插件文件夹里放了 `allow_remote_changes` 文件(启动时读一次,网页建不了也删不掉):这时 `local_guard.may_change` 也放行别的设备;接口里要自己判断时用 `may_change`,只有“浏览器就在这台电脑上”才成立的事(比如在本机的下载文件夹里找原文件)才用 `is_local`。MCP 始终只接受本机。
|
||||
5. **用户的文件删除一律进回收站,没有回收站就拒绝;从不覆盖**(同名另存为“名字 (2)”或拒绝)。插件自己产生的临时文件(`.part`、暂存区)可以直接删。
|
||||
6. **只读的接口也按第 3 条来。** “只是读”不是例外;能按名字、编号取的,就不收路径,由服务端在自己登记的范围里找到文件。
|
||||
7. **读用户文件要有上限。** 限制读取的大小和条数;读不了或超大的文件跳过并在结果里说明,不让一个坏文件拖垮整个请求。读到的内容进页面只当纯文本(本节第二条)。
|
||||
@@ -126,7 +126,7 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
|
||||
|
||||
几个功能对用户的承诺。改这些功能时不能破坏;确需例外,先问维护者:
|
||||
|
||||
1. **模型检查(模型医生)从不自己改工作流。** 检查只读;只有用户按了“换上”或“下载”才动节点,而且只换成同一个文件(SHA-256 指纹一致)。只是同名、没法核对的候选,标明“按文件名”由用户决定。下载的来源(C 站按指纹、工作流带的链接、ComfyUI-Manager 模型清单)在界面上写明。完整的判断规则(三档可信度、基础组件只认指纹、下载来源与核对)见 `docs/guides/model-check-rules.md`,改模型检查前先读、改了规则同时改它。
|
||||
1. **模型检查(模型医生)从不自己改工作流。** 检查只读;只有用户按了“换上”或“下载”才动节点,而且只换成同一个文件(SHA-256 指纹一致)。只是同名、没法核对的候选,标明“按文件名”由用户决定。下载的来源(C 站按指纹、工作流带的链接、ComfyUI-Manager 模型清单)在界面上写明。完整的判断规则(三档可信度、基础组件只认指纹、下载来源与核对)见 `docs/guides/model-check-rules.md`(英文版 `model-check-rules.en.md`,README 的按钮指向这两份),改模型检查前先读、改了规则两份一起改。
|
||||
2. **搭配只记住,从不修改参数数值。** 存的时候是什么,放回画布就是什么:强度、CLIP 层数、提示词等一律原样,不用节点默认值顶替,也不替用户“调”。想要别的数值,在画布上调好再存一个新搭配。放上画布只新放一组节点,从不改画布上已有的节点。(理由见 `docs/decisions/README.md` AD-021;模型检查的见 AD-005、AD-006、AD-020。)
|
||||
|
||||
## 6. 清理旧实现
|
||||
@@ -164,5 +164,6 @@ ComfyUI 的内存、显存和 CPU 是留给出图的。插件闲着的时候应
|
||||
- 架构文档只记录职责、数据流、契约和关键约束。颜色、像素值及审美描述以 CSS 为准,不每次 UX 更新都往架构入口追加长篇设计说明。
|
||||
- 该更新哪份文档见第 0 节的表。规范以本文件为唯一正文,避免几份要求各自漂移。
|
||||
- 完成前审查差异、死代码、临时文件和空白错误;保留现有文件编码及换行风格。
|
||||
- README 里的 `<!-- guide:名字 -->` 是 AI 说明书(read_guide)的分节标记,中英两半名字和顺序一致;改 README 时保留它们,新增一节就两种语言各加一个。
|
||||
|
||||
**交付自查:** 新职责放对位置了吗?是否只有一个状态来源?每条关闭路径会释放资源吗?迟到请求会写错对象吗?旧代码和旧 CSS 清掉了吗?新文案已本地化吗?用户文件失败时还在吗?新接口会动文件或联网吗,只接受本机请求了吗?接口收了路径吗,读用户文件有上限吗?插件闲着时还占着 CPU、内存或显存吗?测试验证了实际行为吗?
|
||||
|
||||
+300
-47
@@ -54,6 +54,7 @@ User-owned data
|
||||
ComfyUI model folders models and sidecars
|
||||
user/.../anomalous_recipes workflow recipes and recipe assets
|
||||
user/.../anomalous_parameters immutable parameter snapshots
|
||||
user/.../anomalous_saves saves of a workflow, a folder for each canvas
|
||||
user/.../anomalous_materials curated image/workflow material bundles
|
||||
user/.../anomalous_notebooks prompt notes, with legacy originals preserved
|
||||
```
|
||||
@@ -79,7 +80,10 @@ covered by the `styles.css` manifest.
|
||||
the queued prompt, with warnings (an upscaled picture sampled again, a size past the card,
|
||||
a huge decode). `api/ai_run_control.py` stops a run (`stop_run`, the workbench's stop
|
||||
button): only its own prompts leave the queue or are interrupted; the step ends `cancelled`
|
||||
and a user's stop reaches the AI as a `stop` message. `tests/test_ai_run_watch.py`.
|
||||
and a user's stop reaches the AI as a `stop` message. It also keeps the user's word on a
|
||||
finished run (`set_verdict`; the buttons under its pictures are `web/modules/ui_ai_verdict.js`, with what
|
||||
is off a click each under "not quite right", `ISSUES`),
|
||||
told to the AI as a `user_verdict` step. `tests/test_ai_run_watch.py`.
|
||||
- `api/ai_run_map.py` reads a run's shape from the prompt ComfyUI queued (plumbing wired
|
||||
through; boxes with a role and facts, links with ComfyUI's types) and what changed since the
|
||||
task's run before; `ai_task_runs.py` keeps both on the run step, `show_run_map` puts it on the
|
||||
@@ -92,6 +96,37 @@ covered by the `styles.css` manifest.
|
||||
alone would not load its graph) or copies the open workflow into a new one before the first
|
||||
change; `web/modules/ui_ai_task_canvas.js` is the header's chip saying which ("AI edits a
|
||||
copy ▾", opening the two to pick from with what each means) and the copy's "Show the copy".
|
||||
`api/ai_privacy.py` is privacy mode: a task kept private (its own switch, else the Gates
|
||||
window's default for new tasks) sends the AI none of its pictures and no prompt text. mcp_server.py
|
||||
hands every result to `guard` last, so no tool or later addition (run pictures, the user's
|
||||
messages) can put one through; a call tied to no task is private while the default is on or a
|
||||
private task is unfinished; the card shows none. `api/ai_sealed.py` seals prompt text and
|
||||
picture names as tokens ("[P1]", "[img1]", kept in task["sealed"]): `mask` on the way out (a
|
||||
node's STRING inputs by ComfyUI's node list, prompt fields, picture names, any sealed text),
|
||||
`expand` on a call's arguments, `seal_answer` for the user's prompt box (a "prompts" panel block,
|
||||
`web/modules/ui_ai_prompt_box.js`, with the prompt_pose skill's words for the canvas's model;
|
||||
its first field is the gist, the picture in words the AI may read, handed on as written but for the
|
||||
words the user marked to hide, which go as tokens inside it; `web/modules/ai_hide_mark.js` is that
|
||||
mark and its button, also in a private task's message box (`ai_sealed.seal_marked`);
|
||||
the privacy skill tells the AI how to work). `api/ai_sealed_models.py` keeps a private task's model names too
|
||||
(the Gates window's "Model names in a private task", on unless turned off): each model of the library is one
|
||||
token ("[M1]", sealed as its model_id) however an answer writes it (model_id, loader name, file name in a text,
|
||||
card name), its own entry loses what tells it apart and keeps type, base model, the author's settings and the
|
||||
user's note for the AI (`ai_note`, set in the model editor, `note_for_ai` in the tools); `expand` writes a
|
||||
model token back as the model_id, as the loader's name among a node's values, as the card's name inside the AI's words.
|
||||
`web/modules/ui_ai_privacy.js` is the header's chip, the switch in its ⋯ and what the model tokens stand for.
|
||||
`api/ai_darkroom.py` is the darkroom helper, an extension: a model on this computer (an OpenAI-style server on
|
||||
a loopback address, nothing else taken, no proxy, no redirect) that reads for a private task what the AI app
|
||||
may not: `write` rewrites a sealed prompt the way the AI says (set_prompt's `rewrite`; the result leaves as a
|
||||
token), `check_picture` answers zoom_image with a fixed list of checks, each one of a few allowed words
|
||||
(`CHECKS`), so its word cannot carry what the picture shows. `web/modules/ui_ai_darkroom.js` is its part of the
|
||||
Gates window (find the local servers, pick a model, drop it) (`tests/test_ai_darkroom.py`).
|
||||
`api/ai_sent.py` keeps what a private task's AI wrote and was answered, as it went (a line per call beside the
|
||||
task, the newest 300, gone with it), and `web/modules/ui_ai_sent.js` is the window that lists it and looks for a
|
||||
word in it (the task's ⋯ "What the AI was sent"). Privacy mode covers what Anomalous sends, not what an AI app
|
||||
opens by itself: `ai_callers.reads_disk` knows the apps that read files and run commands, and the head of their
|
||||
private tasks says so (`ui_ai_privacy.privacyReach`)
|
||||
(`tests/test_ai_privacy.py`, `tests/test_ai_sealed.py`).
|
||||
`ai_pictures.mark_gone` marks a task's pictures no longer in the output folder (`gone`) on its
|
||||
`GET`, which `ui_ai_images.gonePicture` shows instead of a broken picture.
|
||||
`api/ai_pictures.py` hands the AI output pictures (gallery card menu, the inspector's ✦): a
|
||||
@@ -160,22 +195,44 @@ covered by the `styles.css` manifest.
|
||||
stores the pages use: models, scans, output images, combos, recipes, saved prompts,
|
||||
generated audio, activity log) and `call_tool`; `api/mcp_actions.py` the acting ones:
|
||||
canvas actions done by the open page, scans and Anomalous TTS speech through this
|
||||
server's own routes. `api/mcp_bridge.py` hands a canvas action to the page over
|
||||
server's own routes. `api/mcp_bridge.py` (the page that did the last action gets the next first,
|
||||
so a second ComfyUI tab never takes half a task) hands a canvas action to the page over
|
||||
ComfyUI's websocket and waits for its answer; `web/modules/mcp_bridge.js` is the page
|
||||
side, doing it with Anomalous's canvas code (one Ctrl+Z step each, logged as the AI's; the
|
||||
outcome carries the log entry's id as `_activity`, for the AI workbench's undo).
|
||||
`api/mcp_fit.py` is the net under every answer: one that is a list and past Antigravity's size
|
||||
for answers it files away unread keeps the items that fit and names the rest (`left_out`,
|
||||
`more_names`); mcp_server.py runs it after the step is recorded (`tests/test_mcp_fit.py`).
|
||||
`api/picture_mask.py` is what LoadImage gets from an input or preview picture (size, MASK area)
|
||||
for get_image_info.
|
||||
`api/mcp_guide.py` is read_guide, Anomalous's manual for the AI a topic at a time, so the tool list
|
||||
keeps names and the details come when needed: the README's parts between `<!-- guide:name -->`
|
||||
marks (both languages carry the same names; keep them when editing the README), Model Check's
|
||||
rules by section, and the fields of show_panel's blocks (show_panel itself only names them).
|
||||
Longer topics come in parts that fit an answer (`tests/test_mcp_guide.py`). run_status with
|
||||
`downloads=true` tells the model downloads (the former download_status).
|
||||
`api/mcp_look.py` is zoom_image, the AI's closer look: part of a picture in the output, input
|
||||
or temp folder shown larger, the same part of a second picture beside it, read only (so an AI
|
||||
need not crop or compare pictures with code of its own).
|
||||
`api/mcp_model_use.py` offers search_models and get_model (mcp_tools.py finds the files)
|
||||
with what loads each model: the node types whose drop-down lists it (from `/object_info`),
|
||||
or, for a file whose header says it belongs in another folder (model_placement.py), where,
|
||||
and `move_model` (behind the "files" gate) moves it there as Tidy does.
|
||||
and `move_model` (behind the "files" gate) moves it there as Tidy does. search_models answers
|
||||
small enough to be read: loaded_by once for a type, no fields the model_id says, four trigger
|
||||
words, a LoRA's author weight, as many models as fit and the rest by name.
|
||||
`api/model_advice.py` is what Anomalous knows about using a model well: get_model's
|
||||
`author_examples` (sampler, steps, CFG, size and a LoRA's weight from the example pictures in
|
||||
the Civitai record a scan keeps), and add_lora / set_model's `base_model_note` when a LoRA and
|
||||
the main model on its line (`graph_splice.js` loraChain / modelLineStart, reported by the page)
|
||||
are for different base models. `tests/test_model_advice.py`.
|
||||
`api/model_guide.py` is what using a model takes besides its file: the model details'
|
||||
`GET /anomalous/model_guide` (type, folder index, subfolder, file name, never a path) and
|
||||
get_model's `header_words` and `usage`. The author's example settings
|
||||
(`model_advice.example_settings`), the words a LoRA's header suggests when Civitai gave none
|
||||
(`header_words.py`), and from `api/model_profiles.json` (data only: per base model family) the
|
||||
text encoder and VAE a diffusion models file needs, with the file of that name this ComfyUI
|
||||
has, and notes for the AI that say only what was tested here or ComfyUI's own templates do.
|
||||
`tests/test_model_guide.py`.
|
||||
`api/mcp_canvas_edit.py` holds the tools that build a workflow where the user watches:
|
||||
`find_nodes` reads this ComfyUI's node types from its own `/object_info` (kept two
|
||||
minutes); `add_node`, `connect_nodes`, `set_values`, `remove_nodes` and `new_workflow` (a
|
||||
@@ -183,25 +240,139 @@ covered by the `styles.css` manifest.
|
||||
checked for shape there and done by `web/modules/mcp_canvas_edit.js` through the bridge's
|
||||
`recorded`, after checking types, settings and choices against the nodes themselves;
|
||||
`web/modules/canvas_build.js` is its pure part (type fit, value checks, a free spot, the
|
||||
layered layout). `open_workflow` opens one of the user's saved workflows in a new tab.
|
||||
layered layout). `open_workflow` opens one of the user's saved workflows in a new tab, or the
|
||||
workflow a gallery picture carries (the page reads it from the PNG, gives it an id of its own and
|
||||
drops the marks of the tab it ran on: the saved workflow that tab came from and its group of
|
||||
saves); `mcp_fill.opening` says what a name opens, for run_workflow's `workflow` too. The tab's
|
||||
mark goes into the workflow before it loads, so ComfyUI finishing the new tab does not drop it.
|
||||
`web/modules/canvas_wiring.js` plans unique concrete-type connections, selected-branch insertion,
|
||||
splices, the wires of a group added in API form (add_nodes; `canvas_build.placedBlock` lays it
|
||||
out as one block), conflict/cycle checks and rollback on application failure (`tests/canvas_wiring.mjs`).
|
||||
`api/reference_blocks.py` holds the ready-made blocks distilled from workflows that do one
|
||||
thing well (repaints, upscales, ControlNets, face and hand detailing): begin_task hands the
|
||||
AI their directory, a line a use (a block's "job"; "want" words ask for its others), `add_nodes(use=…, want=…)` has `pick` choose the best block this ComfyUI can run and builds it (or `block=` by name), its nodes and
|
||||
the settings that workflow used, a block whose pack is missing naming the pack and a stand-in;
|
||||
an upscale block takes an upscale model this ComfyUI has (and on a card of 8 GB or less decodes with
|
||||
ComfyUI's VAEDecodeTiled, `small_card`: one 2x decode took 20 minutes on 6 GB), a control block a ControlNet whose
|
||||
base model fits the canvas's model (the page's `block_model`, `model_advice.py`) and the
|
||||
picture to follow, copied into the input folder (`ai_pictures.to_input`); a block marked
|
||||
`scene` takes a second picture the same way (`add_nodes(scene=…)`, `$scene`)
|
||||
(`tests/test_reference_blocks.py`). Its links name roles that `web/modules/canvas_roles.js`
|
||||
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), 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.
|
||||
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_plans.py` is `add_nodes(plan=…)`: the AI's big steps for a picture resolved to ready-made blocks
|
||||
(use/want as for add_nodes, a model for the first step or a block's own loader), shown as a plan card
|
||||
(蓝图, panel block "plan", which only it makes; `api/ai_plans.py` keeps the card's rules: the user drops
|
||||
steps but the first, swaps a step's model for one of the same family listed with it, says build or what
|
||||
to change, and "build without asking" in ai_plans.json, `POST /anomalous/ai_plans/auto`), built by
|
||||
block_chain.py when they say build; a card answered after the AI stopped waiting is built by
|
||||
`add_nodes(route=<its panel id>)` (`tests/test_mcp_plans.py`). `web/modules/ui_ai_plan.js` draws the card.
|
||||
A plan is two steps or more; a model may be named by its file or name when only one main model has it; a way
|
||||
for a picture the user hands over (fix_hands) gives way to its use's way that goes on from the step before.
|
||||
Once a plan of several steps is built, the user's rule holds in `mcp_actions.run_workflow`: its first run is
|
||||
three bases (task "plan_previews"), and the whole waits for their pick ("plan_pick": refused once without one).
|
||||
canvas_roles.js takes a "…model" path past size-bound patches (LLLite, ControlNet, T2I-Adapter) on that line.
|
||||
`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 (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.
|
||||
- The job sheet (任务单): many runs of one canvas one after another, each row a prompt, settings by name or the canvas
|
||||
as it is, with its runs, and a rest between pictures if wanted (the viewers' prompt rotation and run interval).
|
||||
`web/modules/ui_job_sheet.js` is its card on the canvas (a node's and the empty canvas's menu entry in `canvas_menu.js`),
|
||||
`web/modules/job_sheet_run.js` runs it on the page as saves run, from detached copies of the workflow kept at the start
|
||||
(the canvas stays as it is), the row filled in by `mcp_fill.js` fillPlan; with a rest the next run goes in once the one
|
||||
before has left ComfyUI's queue, with none one waits behind the one going. `web/modules/job_sheet_plan.js` holds the
|
||||
rules (`tests/job_sheet_plan.mjs`). `api/job_sheets.py` keeps the one sheet and where it stands, hears the page's
|
||||
reports, passes pause / stop / go on to it (from any page, the workbench's stop or the AI's stop_run; with its page
|
||||
gone another page takes it over) and writes a sheet the AI gave with `run_workflow(sheet=…)` on that call's run step,
|
||||
so run_status and the workbench follow it like a run (`tests/test_job_sheets.py`). Clearing or replacing a stale
|
||||
sheet stops its registered pending runs; a stop wins over a late pause report, and a queued run reported after
|
||||
stopping is cancelled. Attaching the AI step carries over the sheet's recent pictures once. Interrupted and
|
||||
removed runs count as unsuccessful (`tests/test_job_sheet_edges.py`).
|
||||
- A model's author text from Civitai in its details opens folded as the user last left it and can be translated
|
||||
into the window's language: `web/modules/detail_intro.js` (`tests/detail_intro.mjs`) keeps the fold and picks the
|
||||
language, `api/description_translation.py` translates it paragraph by paragraph through
|
||||
`translation_routes.translate_chain` (DeepL with a key, Google, MyMemory; long pieces cut smaller) and keeps the
|
||||
answer in user/anomalous/translations by the text's hash, never beside the models (`tests/test_description_translation.py`).
|
||||
- Covers of the voice characters: `api/voice_covers.py` keeps one small square PNG per character's name (its
|
||||
versions share it) in user/anomalous/voice_covers, never in Anomalous_TTS's folders, and takes one away to the
|
||||
Recycle Bin; `web/modules/voice_covers.js` draws the cover or the name's first letter in a card's avatar and, on
|
||||
a click, picks a picture from the gallery (`ui_ai_gallery_pick.js` pictureGrid) or the computer and cuts it
|
||||
square, a tall one keeping its top (`tests/test_voice_covers.py`, `tests/voice_covers.mjs`).
|
||||
- A prompt the canvas's model cannot read. `api/prompt_language.py` says whether the prompts a run sends are mostly
|
||||
Chinese, Japanese or Korean while a loader's model is of a family marked "reads": "English" in `api/model_profiles.json`
|
||||
(SD 1.5, SDXL, Pony, Illustrious, Flux, Chroma: CLIP and T5 read English only, and the picture would follow nothing).
|
||||
`web/modules/prompt_language.js` asks it before the AI's run_workflow and job sheet (refused once: called again
|
||||
unchanged, it runs) and before the job sheet's Start (said once) (`tests/test_prompt_language.py`). The AI's
|
||||
run is refused once too when the canvas draws from an empty latent and every positive prompt box is empty
|
||||
(`prompt_boxes.promptMissing`): an AI trying the tool out with no arguments drew pictures of nothing.
|
||||
- An Anima LLLite on a model it was not made for. `api/lllite_fit.py` counts, from the two files' headers, the
|
||||
blocks an LLLite has modules for and the blocks of the drawing model it is put on (28 for the 2B Anima models,
|
||||
40 for the 2.9B ones): with more blocks than modules the pack stops the run with "depth_embed slices missing".
|
||||
`web/modules/lllite_fit.js` reads the uses from the prompt a run would send (each LLLite node and the loader at
|
||||
the top of its model line) and asks before the AI's run_workflow, which is refused with the nodes to change; an
|
||||
example with a Tile opens on a model the Tile fits (`tests/test_lllite_fit.py`, `tests/lllite_fit.mjs`).
|
||||
- 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
|
||||
side of its routes, every change going through it so what shows a group hears of it. `web/modules/ui_snapshot_bar.js` is the bar
|
||||
under the canvas, following the open tab, with what is used while working there (a node's and the
|
||||
empty canvas's menu entry in `canvas_menu.js`; going back is one of ComfyUI's undo steps on the same tab and puts the save's pictures on their output nodes through the tab's `changeTracker`, a new tab is
|
||||
`loadGraphData` as ComfyUI opens a file). `web/modules/ui_saves_pictures.js` is a save's pictures there: a line of what fits, the rest folded into gallery cards. `web/modules/ui_saves_list.js` is the Workflows page's Saves view, a part for each
|
||||
canvas, open or not, with each save's pictures, its runs and the one thing a save becomes, a workflow card.
|
||||
`web/modules/snapshot_run.js` runs saves for both, on detached copies (`detached_graph.js`,
|
||||
`api.queuePrompt`), so no canvas changes and a closed one's saves run too; `web/modules/snapshot_plan.js` holds the rules (which widgets are seeds,
|
||||
what each run gets, compiling past a plugin that drops the graph; `tests/snapshot_plan.mjs`); `api/snapshot_routes.py` checks each save with
|
||||
ComfyUI's `validate_prompt` before anything is queued. 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
|
||||
"checkpoints") is missing, with the nodes it fed and fed on. `api/block_models.json` lists the models the blocks
|
||||
recommend (links, size, SHA256, licence, folder); a block's "models" names them and its
|
||||
"tested" says the card and a run's time, both shown in the Ways panel (a missing model is
|
||||
downloaded there with `model_download.downloadFrom`) and the time told to the AI with the block.
|
||||
`api/ai_skills.py` holds the skills beside them: advice the AI reads for a kind of job (how to
|
||||
write a prompt a model draws well), a data file each in `api/skills/`, building nothing.
|
||||
begin_task lists the ones on (name and a line), `get_toolbox(skill=…)` gives one, tool
|
||||
descriptions only point at them; each one fetched is a "skill" step of the task (`note_use`); advice that depends on the canvas's main model comes in the
|
||||
form for its size, its parameters counted from the file's header (`model_kind.diffusion_params`,
|
||||
`model_advice.main_params`). A skill on choosing among blocks names their uses ("ways"): its answer lists
|
||||
the blocks of those uses that can run here, each with when to take it, from the blocks' own "when"
|
||||
(`reference_blocks.whens`), so its words hold only the judgment and no block is written into them;
|
||||
every answer must fit `mcp_fit.BUDGET` (`tests/test_ai_skills.py`).
|
||||
`web/modules/mcp_canvas_brief.js` is the canvas in brief for an AI (pure): bounded path chains and node lines,
|
||||
the inputs a run needs that nothing feeds yet with the outputs that fit them, ComfyUI's
|
||||
errors; every canvas change answers with what is still unwired. `api/mcp_situation.py` is
|
||||
get_situation: the task, that brief (describe_canvas with `brief`), the latest run and the
|
||||
get_situation: the task, that brief (describe_canvas with `brief`; describe_canvas in full gives a
|
||||
big canvas's nodes as lines with their settings to stay readable, and says what to do on an empty one), the latest run and the
|
||||
next step in one answer kept under Antigravity's size for answers it files away, for an AI
|
||||
starting a new chat or lost (`tests/test_mcp_situation.py`, `tests/mcp_canvas_brief.mjs`).
|
||||
`api/mcp_directory.py` is the directory of the tools by what an AI wants to do (as this file is
|
||||
for the code): each tool under its stage (`action_catalog.TOOLS`) with its title, built from the
|
||||
registry, in begin_task's answer and in get_situation's while there is no task
|
||||
(`tests/test_mcp_directory.py`). AI apps are offered every tool (they load what they need
|
||||
themselves: Antigravity reads a tool's file before its first call); the module also keeps which
|
||||
registry, in begin_task's answer and in get_situation's while there is no task, the tools by
|
||||
name alone when the titles would make that answer too long to read (`tests/test_mcp_directory.py`). AI apps are offered every tool (they load what they need
|
||||
themselves: Antigravity reads a tool's file before its first call) unless the address names a shorter set:
|
||||
`api/mcp_tool_sets.py` serves `?tools=core` (drawing in the AI app's own chat, no workbench) and `?tools=mine`
|
||||
(the user's pick, `anomalous/ai_tool_sets.json`), with the instructions cut to the listed tools; only the list
|
||||
shrinks, tools/call takes every tool (`tests/test_mcp_tool_sets.py`). `api/ai_starters.py` gives the home page's AI
|
||||
cards what they need (GET /anomalous/ai_starters: saved workflows with their presets, whether downloads ask, the app
|
||||
that came last and which of those lists it holds; `tests/test_ai_starters.py`). mcp_directory.py also keeps which
|
||||
list each app fetched last (`anomalous/ai_tool_lists.json`), so a call from an app still holding
|
||||
the list of an older Anomalous is told so and the task header says how to reconnect it.
|
||||
`api/mcp_fill.py` gives run_workflow its blanks, a daily run in one call: a saved workflow
|
||||
opened in the task's tab once and reused (the page marks the tab with the workflow's file),
|
||||
`api/mcp_fill.py` gives run_workflow its blanks, a daily run in one call: a saved workflow, or
|
||||
one of its 方案 ("workflow / plan": a saved parameter set of it, `mcp_tools.plans`, which
|
||||
list_workflows lists, each card with its route in one line from `api/workflow_route.py`: its passes,
|
||||
models, enlargement, control models and finishing nodes, read from the saved graph as `recipe_flow.js`
|
||||
reads it on the page; the page opens the workflow and applies the set as its Apply button does),
|
||||
opened in the task's tab once and reused (the page marks the tab with the workflow's file, and the set's),
|
||||
then the prompts, seed, size and the picture a LoadImage reads filled in by the page's
|
||||
`fill_slots`, planned by `web/modules/mcp_fill.js` (pure). Any other setting goes by its name
|
||||
(`values`, also set_values without a node): the one node that has it, else the AI names the
|
||||
@@ -239,6 +410,16 @@ covered by the `styles.css` manifest.
|
||||
`web/modules/mcp_run.js` captures exact prompt IDs from one native queue request,
|
||||
preserving native seed/widget callbacks and excluding later manual batches. Busy submissions
|
||||
and unsupported host request IDs are refused before queueing; wrappers/listeners are released.
|
||||
`run_workflow(base_only=true)` queues the first pass alone (`web/modules/base_only.js` cuts each
|
||||
queued prompt down to the samplers nothing samples before and what they feed, picture nodes made
|
||||
PreviewImage, a base handed on unfinished finished and decoded; a picture made from the base, as the
|
||||
mask a later step previews, is not one of them; the canvas never changes); when it
|
||||
ends `api/ai_bases.py` puts the previews on a picks panel with their seeds, copies the picked one
|
||||
into the output folder and holds its seed, which the task's next run puts on that sampler "fixed"
|
||||
and keeps under every try; until newer bases come or the task moves to another canvas, another
|
||||
preview may be taken instead (answer `change: true`), kept and held the same way, the AI told. Where the rest takes the base's picture, that run takes the kept picture
|
||||
(LoadImage) and the base is not drawn again (`onKeptBase`); a base handed on as a latent (the mid-way
|
||||
switch) is drawn again from its seed and the base that run saves is compared with the kept picture.
|
||||
`api/mcp_prompts.py` serves `prompts/list` / `prompts/get`: ready-made requests (the AI app's
|
||||
slash commands) that tell the AI how to do a common job so the user can follow it on the AI
|
||||
workbench. `api/mcp_apps.py` serves the MCP Apps extension (`io.modelcontextprotocol/ui`):
|
||||
@@ -275,7 +456,9 @@ covered by the `styles.css` manifest.
|
||||
`change`); `activity_log.tag` names the task on the entry, and the activity page links to
|
||||
it. `api/ai_gates.py` holds the user's gates: each gate is `auto`, `ask` or `never`
|
||||
(`anomalous/ai_gates.json`, `/anomalous/ai_gates`); `mcp_server.py` asks it before running a
|
||||
tool. It also keeps (`anomalous/ai_tools.json`) how long in all an AI waits for the user's answer (`answer_wait`: 1, 3 or 10 minutes): `api/ai_answer_wait.py`
|
||||
tool. Its three safety levels (`LEVELS`: `careful`, `balanced`, `free`, or `custom` when a
|
||||
row differs; `level()`, `set_level()`) set the five gates together with `node_rule`,
|
||||
`route_rule`, `no_answer` and `ai_plans.auto()`, leaving unrelated settings alone. It also keeps (`anomalous/ai_tools.json`) how long in all an AI waits for the user's answer (`answer_wait`: 1, 3 or 10 minutes): `api/ai_answer_wait.py`
|
||||
tells an AI whose question is still open to wait on with get_answers until then, and while the
|
||||
user types (the page posts `POST /anomalous/ai_tasks/{id}/typing`) until a minute after their last
|
||||
key; report and show_panel remind it to end each round with ask_user. With `ask` the step waits in state `asking` (what it would do listed) for up to 50 s;
|
||||
@@ -306,10 +489,16 @@ covered by the `styles.css` manifest.
|
||||
Options Anomalous makes name a page word (`label_key`). `tests/test_ai_repaint.py`.
|
||||
`web/modules/ui_ai_gate.js` draws the card under a step that asks and the Gates window;
|
||||
`ui_ai_flow.js` the flow over a task (its stages in order, each done, running, waiting or
|
||||
failed; a stage leads to its first step). `ui_ai_handoff.js` is "Hand to AI" on a model's
|
||||
failed; a stage leads to its first step; a job sheet's run with how far it is), and under it the blueprint's line
|
||||
(`ai_task_view.js` planFlow: the newest plan card's kept steps, then picking a base, lit as the bases run, the pick
|
||||
and the whole run after the card go; coming steps dashed; read from the steps, nothing asked of the AI). `ui_ai_handoff.js` is "Hand to AI" on a model's
|
||||
page, an output image's inspector and a combo card: a task the user starts with `about`
|
||||
(the model's `model_id`, the image, the combo's name; checked in `ai_tasks.start_task`),
|
||||
which `begin_task` hands to the AI, and the sentence to paste into the AI app. It tells the page through the
|
||||
which `begin_task` hands to the AI, and the sentence to paste into the AI app. `ui_ai_canvas_hand.js` is "Hand to
|
||||
AI…" in a node's and the empty canvas's right-click menu: ready wishes for the canvas or the node, sent to the task
|
||||
an AI is on now as a message or as a new task about the canvas (`about` {type: canvas, tab}: `ai_task_canvas.spec`
|
||||
gives the page that tab as `from`, so the task's reads, runs and copy start from it, `mcp_task_tab.js`); a run that
|
||||
fails on the page offers "Ask the AI" with ComfyUI's words. It tells the page through the
|
||||
`anomalous.ai_task` event; `api/ai_tasks_routes.py` serves `/anomalous/ai_tasks` (list, one
|
||||
task, start, rename or remove, a panel's answer, a message). The user can start a task first
|
||||
(New task): it waits, and the next app that calls takes it over, so its work starts there
|
||||
@@ -323,7 +512,7 @@ covered by the `styles.css` manifest.
|
||||
results strip and timeline with links (picture viewer, model page, combo, canvas locate and
|
||||
undo, scan page, ▶ for audio), updated live; also the home page's "What the AI did lately"
|
||||
and the rail's dot (`owner.railNews`). A task's parts stay in the page between redraws: a
|
||||
step is drawn again only when it changed (`syncRows`), so a scrolled diagram or strip and
|
||||
step is drawn again only when it changed or the language did (`syncRows`), so a scrolled diagram or strip and
|
||||
half-typed text survive the AI's steps coming in. `ui_ai_message.js` is the message box, made
|
||||
once per task and docked at the foot of its column (in view while the timeline scrolls; a task
|
||||
opens at its newest step, and `ui_ai_newest.js` is the button back to it over the box): a growing input, the gallery and materials buttons beside it, and
|
||||
@@ -341,7 +530,8 @@ covered by the `styles.css` manifest.
|
||||
`api/ai_message_refs.py` checks what the message names and gives each to the AI
|
||||
with the message (in full, pictures attached; get_toolbox shares its `lookup`),
|
||||
`tests/test_ai_message_refs.py`. `ui_ai_step_body.js` is what a timeline step says under its
|
||||
title (the user's own steps with whether the AI has them, a decision's card, the AI's note, what
|
||||
title (the user's own steps with whether the AI has them, a decision's card, the AI's note, a
|
||||
skill fetched and, under a run, the skills fetched before it (`ai_task_view.skillsBefore`), what
|
||||
happened outside Anomalous). `api/ai_outside.py` is that last: when the AI calls (never in the
|
||||
background) it looks at the top of the output and input folders, by the files' own times, for
|
||||
pictures no ComfyUI run saved and pictures rewritten in place since the AI's previous call
|
||||
@@ -362,14 +552,21 @@ covered by the `styles.css` manifest.
|
||||
starts one while looking at a task; changed or taken off by the user, never a loop); `GET` of a
|
||||
task adds `links` {from, next}, begin_task gives an AI the task it continues and what was
|
||||
decided there (`continues`). `ui_ai_task_links.js` shows them under the task's title and picks
|
||||
one to link. `tests/test_ai_task_links.py`. `ui_ai_recent.js` the home page's list, `ui_ai_results.js` what a
|
||||
one to link; `ui_ai_task_menu.js` is the ⋯ of a task, in its head and on its row, and what it does; removing a task
|
||||
(`ai_task_links.remove`) joins those after it to the one before. `tests/test_ai_task_links.py`. `ui_ai_recent.js` the home page's list, `ui_ai_results.js` what a
|
||||
step made and where it leads, `ui_ai_shared.js` what the page's parts share. `ui_ai_outputs.js` is what the whole task made: a line over the
|
||||
timeline counting it, and the side column's Results view (`ai_task_view.taskOutputs`: the
|
||||
pictures by the step that brought them, the workflow, the combos, the newest picture's prompts,
|
||||
the models used and download requests — `ai_tasks.results_of` names the downloads — and the sounds),
|
||||
which keeps what made the newest picture as a recipe, a combo or a saved prompt through the
|
||||
keep menu's own `keepPictureAs`; its button counts what came since it was last seen
|
||||
(`stage.outputsSeen`). For a run the AI did not wait
|
||||
(`stage.outputsSeen`). `ui_ai_blocks.js` is the side column's Ways view (做法): the ready-made
|
||||
blocks that can run here by job (GET /anomalous/ai_blocks, `reference_blocks.status`), a short row each
|
||||
(a line of its words from its data file, the rest once opened) with a switch that keeps it from the AI
|
||||
(POST, `user/anomalous/ai_blocks.json`); then the skills (`ai_skills.status`), each with the same
|
||||
switch (`user/anomalous/ai_skills.json`), those of the task's kind only (a private task's, else the
|
||||
others), as the AI gets; last, folded, the blocks lacking a pack (a way into ComfyUI-Manager) or a
|
||||
model, each saying what. For a run the AI did not wait
|
||||
for (`ai_gates.run_wait` "call") the server marks the ended run `call_ai` until the AI is told,
|
||||
and the lines over the message box are about it meanwhile. `ui_ai_side.js` is the
|
||||
stage, the column on the right where the AI's panels are shown (the timeline keeps a card per
|
||||
@@ -395,7 +592,7 @@ covered by the `styles.css` manifest.
|
||||
the button (or moving left onto the page's left edge) slides it over the page at once.
|
||||
`ui_ai_task_list.js` draws it (New task, Connect an AI
|
||||
and the gates, which AI came last, quick filters by state, the tasks by day with a state
|
||||
icon each), making anew only the rows that changed (`web/modules/sync_rows.js`, the timeline's
|
||||
icon each, those going on from a task under it by `ai_task_view.taskFamilies`), making anew only the rows that changed, all of them after a switch of language (`web/modules/sync_rows.js`, the timeline's
|
||||
way too). `web/modules/ai_task_cache.js` keeps the tasks as last read: a task picked shows at
|
||||
once from there (read ahead while the pointer rests on its row) and again, what changed, when the
|
||||
server answers; the activity entries a task lacks follow. The stage's pictures, like the
|
||||
@@ -406,12 +603,23 @@ covered by the `styles.css` manifest.
|
||||
`ui_ai_connect.js` says how to connect an AI app (the address, a sentence to send the AI so
|
||||
it sets itself up and starts on a waiting task, each common app's way), in a window from the
|
||||
list's "Connect an AI", and as a card (`connectHint`) that opens the guide in place on the
|
||||
empty page and under a task waiting for an AI. `ui_ai_ready.js` is that waiting task's one
|
||||
page: the sentence to send the AI first, kept in view at the top while the page scrolls, then
|
||||
folds to open for what may be put ready (what to do, a line naming the task; the gallery grid
|
||||
of `ui_ai_gallery_pick.js` feeding the message box) and the user's own steps so far. The pictures (`web/assets/ai_*.webp`)
|
||||
empty page and over a task waiting for an AI until one has ever come; without a waiting task
|
||||
`ui_ai_tool_set.js` picks above the address which tools the app is offered (all, the short set, or the
|
||||
user's checklist by stage) and the address follows. `ui_ai_starters.js` is the home page's "Let the AI do it"
|
||||
(the app that came last, a warning when its list is a short one, six starter cards); `ui_ai_starter_sheet.js`
|
||||
holds the cards and the sheet each opens (fields, what happens, what stays, what the AI does not get), which starts a
|
||||
prepared task with its switches set (a new tab; privacy for the private card) through `ui_ai_handoff.js` startTask;
|
||||
a card's tags name only what Anomalous makes sure of. `ui_ai_tour.js` holds a newcomer's notes: the workbench's
|
||||
spotlight tour (new task, connecting, gates, the list, a task's ⋯, the timeline, the message box), by itself the
|
||||
first time a task shows and from the home section's "How it works", and `firstHint`, one line with Got it under a
|
||||
control that only shows up later (a run's verdict, the undo of the AI's canvas changes, a private task's Hide).
|
||||
`ui_ai_ready.js` is that
|
||||
waiting task's one page: the sentence to send the AI, and what the user sent so far; what to say
|
||||
first and pictures go in the message box. The pictures (`web/assets/ai_*.webp`)
|
||||
were made with ChatGPT. `ai_task_view.js` holds
|
||||
its rules without DOM (folding reads and chains of the AI's work, tool words, counts, result addresses);
|
||||
`ai_task_title.js` names a task on every page: its given title, else the one Anomalous took from the AI's
|
||||
first step that did something (`title_from` "auto") in the page's tool words, else Untitled;
|
||||
`ui_ai_chain.js` draws the folded rows: reads in a row, and a chain of reads, canvas changes and
|
||||
decisions as one row of chips with undo for its canvas changes, opening to its steps.
|
||||
`ask_user` (`api/mcp_task_tools.py`) is the AI's question in the workbench: a one-block panel
|
||||
@@ -420,8 +628,9 @@ covered by the `styles.css` manifest.
|
||||
this way rather than in its own chat.
|
||||
Panels the AI draws (`show_panel`, `update_panel`): `api/ai_panels.py` checks every block
|
||||
against a fixed set (heading, text, images from the output folder, models by id with their
|
||||
covers, table, list, params, choices, input, progress, divider; plain strings of bounded
|
||||
length, nothing run or read as markup) and keeps the panel as a step. `update_panel` preserves
|
||||
covers, table, list, params, choices, input; plain strings of bounded
|
||||
length, nothing run or read as markup; the parts of a block: `api/ai_panel_parts.py`, also a
|
||||
slider's or picks' `zoom`, the part of every picture shown enlarged) and keeps the panel as a step. `update_panel` preserves
|
||||
the order already seen: new blocks append; omitted blocks remain; unchanged resubmitted
|
||||
blocks match without their ids. Every new block gets a stable id, returned by the tools;
|
||||
an explicit existing id edits that block in place without changing its type or position.
|
||||
@@ -455,7 +664,7 @@ covered by the `styles.css` manifest.
|
||||
receipts never reconstruct history from the current graph. The workbench retains only
|
||||
its selected task's activity entries and ignores superseded refresh/navigation responses.
|
||||
- `api/backup.py` is Settings → Backup (`/anomalous/backup/*`): one .zip of the user's stores
|
||||
(recipes, combos, materials, parameters, optionally ComfyUI's saved workflows), the plugin
|
||||
(recipes, combos, materials, parameters, saves, optionally ComfyUI's saved workflows), the plugin
|
||||
settings without the Civitai key, and each model's user layer and own cover; putting it
|
||||
back adds what is missing, replaces differing files only on request (Recycle Bin first)
|
||||
and finds models again by SHA-256, else by folder, path and size. Its model list (every
|
||||
@@ -470,7 +679,9 @@ covered by the `styles.css` manifest.
|
||||
existing file; also where downloads go by default (`download_settings.json`).
|
||||
`api/download_sources.py` finds the one exact file: Civitai by hash, a Civitai version
|
||||
link, or a Hugging Face / GitHub file link the workflow carries, else a file of the same
|
||||
name in ComfyUI-Manager's model list (`api/manager_catalog.py`), checked by the hash.
|
||||
name in ComfyUI-Manager's model list (`api/manager_catalog.py`), checked by the hash;
|
||||
`civitai_versions` lists a Civitai model page's versions with their files
|
||||
(`GET /anomalous/download/versions`, this computer only, asked from a model's details).
|
||||
`api/hf_card.py` gives a model downloaded from Hugging Face its model card's example image
|
||||
as cover and its trigger words and example prompts as notes (never over the user's).
|
||||
- `api/model_import.py` puts model files the user drops on the models page into the right
|
||||
@@ -502,7 +713,8 @@ covered by the `styles.css` manifest.
|
||||
holds the size and count limits shared by recipes, parameters and materials.
|
||||
- `api/recipe_packages.py` owns bounded inspect-stage-commit package handling.
|
||||
- `api/parameters.py` owns Parameter Notebook persistence and lookup.
|
||||
- `api/notebooks.py` owns combo (搭配, formerly Prompt Note) persistence and recoverable legacy copying.
|
||||
- `api/notebooks.py` owns combo (搭配, formerly Prompt Note) persistence and recoverable legacy copying, and
|
||||
gives Current node a combo's nodes of a type, read only.
|
||||
- `api/material_schema.py`, `api/material_assets.py`, and `api/material_store.py`
|
||||
own curated material shaping, private assets, persistence/cache, search and
|
||||
lifecycle; `api/materials.py` owns HTTP mapping and compatibility entry points.
|
||||
@@ -512,7 +724,9 @@ covered by the `styles.css` manifest.
|
||||
`/anomalous/`; other devices may look), as one middleware, and lets other devices change
|
||||
things too when the server has an `allow_remote_changes` file (cloud servers); see backend.md.
|
||||
- `api/media_routes.py`, `api/gallery_routes.py`, `api/translation_routes.py`, and
|
||||
`api/folder_types.py` own the formerly mixed utility route families.
|
||||
`api/folder_types.py` own the formerly mixed utility route families. `api/huge_picture.py`
|
||||
is run by `media_routes.shrunk_in_child` as a child process to shrink a picture past Pillow's
|
||||
pixel limit (a card image, or the viewers' 4096 px copy from `gallery_routes` output_preview).
|
||||
- `model_policies.py` owns shared backend rename and protected-category policy.
|
||||
- `model_sidecars.py` says where a model's side files are, shared with the scanner: beside it,
|
||||
or the plugin's own ones (`.info`, `.anomalous.json`, `.civitai_bak.*`) in the folder's hidden
|
||||
@@ -526,8 +740,17 @@ covered by the `styles.css` manifest.
|
||||
tells, shared with the standalone scanner (which runs as its own process, so the API
|
||||
never imports `scraper.py`). `model_kind.py` reads a .safetensors / .gguf header (never
|
||||
formats that can carry code) and says which models folder the file belongs in and its base
|
||||
model (model patches such as Anima LLLite go to `model_patches`, not `controlnet`); model
|
||||
import and the scanner's offline base model use it.
|
||||
model (model patches such as Anima LLLite go to `model_patches`, not `controlnet`, though a
|
||||
node pack may read them there: `form`; an IP-Adapter carrying LoRA layers stays an
|
||||
IP-Adapter; a file two loaders read from different folders, such as SUPIR or a whole CLIP,
|
||||
is never `sure`); model import and the scanner's offline base model use it. Its rules are
|
||||
checked in `tests/test_model_import.py`, layouts once misread included. Like ComfyUI it tells
|
||||
Flux Schnell from Dev (no guidance input) and Z-Image from Lumina 2 (the caption embedder's
|
||||
width; a GGUF by its parameter count). `header_words.py` reads what a LoRA's header suggests
|
||||
for its prompt when Civitai gives no trigger words: the maker's `modelspec.trigger_phrase`,
|
||||
else the tags most training pictures share (kohya's `ss_tag_frequency`, generic ones left
|
||||
out); the scanner keeps them in the `.info` (`anomalous_header_words`), metadata gives them as
|
||||
`headerWords`, never inside `trainedWords` (`tests/test_header_words.py`).
|
||||
- `recycle_bin.py` is the only way the plugin deletes the user's files (models, covers,
|
||||
outputs, recipes, materials, notes, audio): to the system Recycle Bin, together, or not
|
||||
at all (`TrashUnavailable` on drives without one). `api/trash.py` is the API's import of it
|
||||
@@ -699,7 +922,8 @@ covered by the `styles.css` manifest.
|
||||
page `script`) and Audio (shell page `audio-gallery`, `ui_audio_gallery.js`).
|
||||
`ui_shell_nav.js` keeps the rail on Voices and reopens the view used last, from the
|
||||
rail and from the 9 key; the rail stays within the nine number keys.
|
||||
`ui_audio_studio.js` owns the Characters view: the character cards, preview playback,
|
||||
`ui_audio_studio.js` owns the Characters view: the character cards (laid out like a profile: cover, name big,
|
||||
version under it, one line of facts, plain buttons, voices as thin-line rows), preview playback,
|
||||
tag copying, dragging a card header onto a node through the shared `bindMaterialDrag`
|
||||
with `targetHint`/`rejectHint` telling the user what releasing does, each card's
|
||||
"Voice-over" button (`owner.openScript(group)`), and its Delete character button
|
||||
@@ -791,7 +1015,7 @@ covered by the `styles.css` manifest.
|
||||
it is open (remembered per page; closed and overlaying below 760 px), the header's
|
||||
page title, and the page reopened next time. `ui_home.js` owns the home page (task
|
||||
cards, first steps). `ui_shell_frame.js` owns dragging, resizing and keeping the
|
||||
floating window on screen, and the full-window button (double-click on the header too); `ui_scan_watch.js` polls scan status for the rail's scan
|
||||
floating window on screen, the docked window's width (dragged at its right edge; the limits are in the CSS), the full-window button (double-click on the header too) and the move between the three (`morphFrame`: laid out once, only the picture moves; the window and pages coming and going are `styles/51-shell-motion.css`); `ui_scan_watch.js` polls scan status for the rail's scan
|
||||
button, the progress panel and the model reload afterwards.
|
||||
The activity log (docs/decisions AD-018): `api/activity_log.py` keeps the entries
|
||||
(newest first, bounded, in the ComfyUI user folder), records this plugin's and
|
||||
@@ -877,9 +1101,9 @@ covered by the `styles.css` manifest.
|
||||
only one side has, seeds apart), writing ticked image values onto the canvas as one Ctrl+Z
|
||||
step with Undo; `param_compare.js` reads and compares the two sides without DOM.
|
||||
`key_params.js` keeps the starred key parameters (node type + widget name, a hidden ComfyUI
|
||||
setting); the window shows them first. `ui_param_set_keys.js` puts parameter sets in view: a
|
||||
recipe card's ▤ N chip (first in its specs line) (`GET /anomalous/parameters/signatures`; sets equal to the recipe not counted) opens its Parameters tab,
|
||||
and each set there shows its key values, how many values differ from the recipe, and ⇄.
|
||||
setting); the window shows them first. `ui_param_set_keys.js` puts presets in view: a
|
||||
recipe card's ▤ N chip (first in its specs line) (`GET /anomalous/parameters/signatures`; sets equal to the recipe not counted) opens its details,
|
||||
and `setDifferences` counts the values a preset sets unlike the recipe (its tab and head say so).
|
||||
`ui_keep_menu.js` is the star's keep menu (also the workbench's Keep): the whole workflow, the combo or the prompts,
|
||||
each saying where it goes, opening what was kept already or removing it (to the Recycle Bin through the recipe,
|
||||
notebook or material delete route); `image_keep.js` does the keeping (an image as a combo,
|
||||
@@ -904,8 +1128,9 @@ covered by the `styles.css` manifest.
|
||||
read-only translation panel beside its prompts. `lora_nodes.js` knows the LoRA nodes a default
|
||||
combo puts its LoRAs in (ComfyUI's chain, rgthree's Power Lora Loader, Easy-Use's LoRA stack, the
|
||||
third-party ones only when installed) and a Power Lora Loader's rows (for structures, and as
|
||||
drop-down-like boxes for Model Check); `ui_lora_node_choice.js` is the default combo editor's
|
||||
choice among them.
|
||||
drop-down-like boxes for Model Check); `lora_insert.js` puts several LoRAs into a workflow at once
|
||||
as any of them, for the model picker's Insert LoRA; `ui_lora_node_choice.js` is the choice among
|
||||
them, in the default combo editor and the picker.
|
||||
- `detached_graph.js` lays a workflow on a graph of its own, off the canvas, under an id of its own: ComfyUI keeps widget
|
||||
values by graph id, so a copy under the canvas's id would write its values onto the canvas.
|
||||
- `recipe_save.js` saves Workflow Recipes for the canvas save and for an output image's workflow (the gallery's keep menu,
|
||||
@@ -951,13 +1176,21 @@ covered by the `styles.css` manifest.
|
||||
card placeholder ergonomics (eliminating misleading unclickable text in favor of pure centered icon and status),
|
||||
card action buttons (one-click canvas addition with plus icon, model metadata editor, direct precision scanner without wizard modal popups)
|
||||
with absolute positioning cascades immune to tooltip target conflicts, vibrant hover affordance,
|
||||
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_detail.js`
|
||||
safe docked sidebar preservation upon node addition, and multi-type node dispatch; `ui_model_guide.js`
|
||||
(the details' trigger words, header words, author settings and needed files); `ui_detail.js`
|
||||
coordinates detail display (cover column, then the head from `model_detail_head.js`: back crumb, name, source line,
|
||||
Add node / Edit / Folder / Outputs; the foot with the path, Copy and Delete, and Show in folder, in
|
||||
`model_location.js`), `ui_model_editor.js` owns metadata editing (cover, name, source link, notes;
|
||||
styles `styles/31-model-info.css`), and
|
||||
`model_widget_path.js` puts a model path into a loader's widget. `ui_gallery.js` (cards in
|
||||
`ui_gallery_card.js`, filters in `gallery_filter.js` / `ui_gallery_filter.js`) and `ui_gallery_detail.js` own generated-output browsing and workbench lifecycle,
|
||||
`model_widget_path.js` puts a model path into a loader's widget. Versions of one model:
|
||||
`model_versions.js` (pure) groups the listed files by their Civitai model page (metadata
|
||||
`model_id`, never a file name), newest version first, so the grid folds a model into one card;
|
||||
`ui_model_versions.js` draws the card's tab that opens the versions side by side, the pins and
|
||||
"Open all versions" (the shown version and the open models are kept in this browser), animates
|
||||
the cards moving, and draws the details' row of the versions here;
|
||||
`ui_civitai_versions.js` lists the page's versions on Civitai when asked, each opened or
|
||||
downloaded beside this one (`tests/model_versions.mjs`, styles `styles/49-model-versions.css`). `ui_gallery.js` (cards in
|
||||
`ui_gallery_card.js`, filters in `gallery_filter.js` / `ui_gallery_filter.js`, the large view in `ui_gallery_viewer.js`) and `ui_gallery_detail.js` own generated-output browsing and workbench lifecycle,
|
||||
with stage interaction in `ui_image_stage.js` and metadata tabs in
|
||||
`ui_image_inspector.js`.
|
||||
- `ui_recipes.js` / `ui_recipe_detail.js` and `ui_notebooks.js`
|
||||
@@ -970,10 +1203,21 @@ covered by the `styles.css` manifest.
|
||||
`notebook_canvas.js` puts a combo on the canvas as a new wired group of nodes (following the pointer, or at a dropped
|
||||
card's position; Esc takes a following group off again); it never changes nodes already there. Combos are a rail page. `ui_recipe_detail.js`
|
||||
is a workflow's details, built around its values (styles `styles/29-workflow-detail.css`): a bar
|
||||
(Back, the name, the models' state, Open, ⋯ edit and export), then `ui_recipe_sets.js`'s left
|
||||
column (the workflow's own values, the saved sets unlike them with their key values, Save the
|
||||
canvas, New; then all pictures and versions) and the chosen view on the right.
|
||||
`ui_recipe_set_view.js` is one set: Apply to canvas, ⇄ (with the workflow's own or the canvas),
|
||||
(Back, the name, the models' state, Open, ⋯ edit and export), then one column: the flow, the row
|
||||
of tabs under it (`ui_recipe_sets.js`: the workflow's own values, each preset unlike them with how
|
||||
many values it changes, + to save the canvas or copy the one shown; all pictures and versions at
|
||||
the end; the row stays in view while the rest scrolls) and the tab chosen in a panel joined to it.
|
||||
`ui_recipe_flow.js` draws the flow small, only its structure, for the values shown: the stages in
|
||||
the order they run (base, goes on, enlarge and redraw; one these values do not run dashed, "not
|
||||
run"), the picture nodes after the samplers (a colour match) and the saves; over the stages one
|
||||
lane each for the model files, the LoRAs and the control models, each a bar over the stages it
|
||||
feeds (`flowLanes`); a box's values (steps, split, strength) are its tooltip; what the preset
|
||||
changes from the workflow's own orange, what it leaves unused dashed. Every box opens its
|
||||
node's values under it (`ui_recipe_flow_inspect.js`: changed values against the workflow's own,
|
||||
the presets that use an unused box). All of it is read from the saved graph alone by
|
||||
`recipe_flow.js` (`readFlow`, `changedNodes`, `touched`; `tests/recipe_flow.mjs`).
|
||||
`ui_recipe_set_view.js` is one set: Apply to canvas, ✦ hand it to the AI (`workflow / preset`),
|
||||
⇄ (with the workflow's own or the canvas),
|
||||
⋯ (copy as a new set, rename, delete); the cover, notes and tags for the workflow's own
|
||||
(`ui_recipe_info.js`, which also holds the name and the models' state); the workflow's focus
|
||||
values (重点参数: `recipe_focus.js` keeps them in the recipe's params and tells comparisons which
|
||||
@@ -982,7 +1226,8 @@ covered by the `styles.css` manifest.
|
||||
prompts; the pictures made with these values; the models (the fold says whether all are here)
|
||||
and every node's values, folded, each opening as the viewer last left it (this browser's
|
||||
storage); a tip while the workflow has no set, and one pointing at ⇄ on a set until it is used.
|
||||
Narrow (docked) details turn the left column into one row of pills (a container query). A new
|
||||
Narrow (docked) details keep the tabs at a readable width and scroll them sideways, as the flow
|
||||
(a container query). A new
|
||||
set is named after the workflow with the next number (`nextSetName`). `ui_recipe_parameters.js`
|
||||
holds what the views share: prompt roles (automatic or set by hand) and their boxes, every node's
|
||||
values (unnamed ones labelled by their node type's widgets, `comfyWidgetLabels`), the new set's editor.
|
||||
@@ -1010,6 +1255,13 @@ covered by the `styles.css` manifest.
|
||||
models, LoRAs, samplers and prompts (linked prompt nodes before embedded loader
|
||||
prompts) and keeps the summary in step with widget edits using the same node rules; which
|
||||
LoRAs a node loads is `recipe_loras.js`, and reading a node's boxes `recipe_widgets.js`.
|
||||
`api/example_workflows.py` serves the examples Anomalous ships (`api/examples/*.json`: plans, a version per
|
||||
model family, no model or picture named: filled in from what is here, a version with a Tile on a model the
|
||||
Tile fits, what is missing said; what holds for a whole family is written once, in `model_profiles.json` for
|
||||
an AI and in the page's words for the user);
|
||||
`ui_recipe_examples.js` is their one card and window on the Workflows page, and list_workflows gives those
|
||||
that can run here to an AI when no saved workflow fits, and names them when one does. A long list goes without
|
||||
settings: an AI app files an answer over about 4,000 characters away, and it may not read it.
|
||||
`ui_recipe_catalog.js` is the Workflows page: one bar (search, the filter menu with kind and tag,
|
||||
the count, grid or list, share and import, save the canvas), the list, the empty page's three-step
|
||||
guide (`renderRecipeEmptyGuide`) and the catalog-wide model readiness check
|
||||
@@ -1071,9 +1323,10 @@ covered by the `styles.css` manifest.
|
||||
follows the downloads and puts each finished file into its nodes (the placement rules
|
||||
are `download_places.js`, without imports so they are tested alone); `ui_model_download.js`
|
||||
is the page's Download buttons, progress and the dialog that shows and changes where
|
||||
each file goes. `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
|
||||
each file goes. `file_size.js` says a file's size in KB/MB/GB, without imports: a module low
|
||||
in the import graph (the gallery's viewer) takes it from there, not from a page module. `ui_node_assistant.js` owns the current-node panel (model actions, LoRA
|
||||
insertion, model cards and history), `ui_node_model_picker.js` owns native combo
|
||||
replacement, `ui_node_parameters.js` renders the panel's parameters section (each saved
|
||||
replacement (its cards are the models page's card face, `ui_grid.js` `modelCardFace`), `ui_node_parameters.js` renders the panel's parameters section (each saved
|
||||
set as a card with the picture made with it and where it came from, values as bars or
|
||||
old → new, or all sets side by side), and
|
||||
`node_parameter_sets.js` merges the node type's saved values (material files and recipe
|
||||
|
||||
+318
@@ -1,5 +1,323 @@
|
||||
# 📈 Anomalous Model Browser Changelog
|
||||
|
||||
## v1.64.0 — unreleased
|
||||
|
||||
- 任务单停止时,刚提交、迟到回报的运行也会取消,暂停回报不会覆盖停止请求;清除或替换失联任务单会同时停止它已登记的运行。很快完成、早于 AI 步骤绑定的图片会补回工作台,重复绑定不重复添图;中断或被移出队列的运行计入未成功次数。
|
||||
- Stopping a job sheet also cancels runs reported just after the stop; a pause report cannot erase a stop request. Clearing or replacing a stale sheet stops its registered runs. Pictures completed before the AI step was attached are carried into the workbench without duplicates on reattachment; interrupted or removed runs count as unsuccessful.
|
||||
|
||||
- 没有 C 站触发词的 LoRA(自己炼的、从别处下的),扫描时会从模型文件里读出训练图片大多都带的标签(作者写了触发词就用作者的),和 C 站的触发词分开存,搜模型时也能用这些词搜到。
|
||||
- For a LoRA without trigger words from Civitai (trained yourself, downloaded elsewhere), a scan reads from the file the tags most of its training pictures share (or the trigger phrase its maker wrote) and keeps them apart from Civitai's trigger words; model search finds them too.
|
||||
- 从模型文件认底模时分得清 Flux.1 Dev 和 Schnell(Schnell 没有引导输入),也认得出 Z-Image 和 Lumina 2 这两种结构相近的模型:以前 Z-Image 的 GGUF 被当成 Lumina,safetensors 两种都认不出。已经扫过的模型重新扫描后才会更新。
|
||||
- When the base model comes from the file itself, Flux.1 Dev and Schnell are told apart (Schnell has no guidance input), and Z-Image and Lumina 2, two models of one layout, are recognised: a Z-Image GGUF was taken for Lumina, and neither was recognised as safetensors. Models scanned before keep their old answer until scanned again.
|
||||
- AI 查模型时多知道三件事:没有触发词的 LoRA 从文件里推测的词(标明是推测);分开加载的模型(Anima、Flux、Chroma、Qwen-Image、Z-Image、Lumina 2)还要哪个文本编码器和 VAE,你这里有没有这个名字的文件;这一类模型要注意什么,只写这里测过的和 ComfyUI 官方模板里的做法,比如 Anima 出多张要跑几次而不是调批量,Flux Dev 的 CFG 保持 1。
|
||||
- Looking up a model, the AI learns three more things: for a LoRA without trigger words, the words guessed from its file (said to be a guess); for a model loaded in parts (Anima, Flux, Chroma, Qwen-Image, Z-Image, Lumina 2), the text encoder and VAE it needs and whether you have a file of that name; and what to mind with its kind of model, only what was tested here or ComfyUI's own templates do, such as running Anima several times for several pictures instead of raising the batch, and keeping Flux Dev at cfg 1.
|
||||
- 模型详情里多了三样,放在描述上面:没有 C 站触发词的 LoRA 显示「文件头里的常用词(推测)」(虚线框,点一下复制,以前扫过的打开时现读);扫描时拿到作者例图的,显示「作者例图常用设置」(采样器、步数、CFG、尺寸,LoRA 还有权重),以前只有 AI 看得到;Anima、Flux 这类要分开加载的模型显示「还需要的文件」(文本编码器和 VAE),写明你这里有没有这个名字的文件。
|
||||
- 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.
|
||||
- AI 工作台多了「蓝图」:AI 想用几块现成做法一步步画一张图时(比如先出图、换个模型精修、修脸和手、再放大),先把步骤画成一张卡给你看。每一步写着做法和模型,第一步以外都能去掉,有模型的步骤能从同一类模型里换一个;点「照这个搭」AMB 就在新标签页一次搭好、连好线,有第二步时先出三张小样让你挑。想改就点「我想改一下」写一句话交给 AI。勾上「以后直接搭,不用问我」后不再问,卡上的「以后先问我」能改回来。整条要等你挑好底图才跑:AI 抢先跑会被拦一次并告诉它原因。
|
||||
- The AI workbench has a Blueprint: when the AI means to draw a picture in several ready-made steps (the picture, a redraw with another model, faces and hands, an enlargement), it shows them on a card first, each with its way and model. Any step but the first can be left out, and a step's model swapped for another of its kind; "Build it like this" builds and wires the whole of it on a new tab, with three base previews to pick from first when there is a second step. "Change something" hands the AI your words instead. With "From now on build without asking me" ticked it no longer asks; "Ask me first next time" on the card turns that off. The whole plan waits for the base you pick: an AI that runs it before is stopped once and told so.
|
||||
- 新的「任务单」:画布右键「任务单(批量换提示词)」。一次贴很多条提示词(一行一条),每条跑几次、每张之间歇几秒、要不要每条用同一组种子,然后「开始」。跑的是开始时那张画布的副本,你的画布不动;可以暂停、继续、停止,出错会自己暂停并写明原因,刷新页面会接着跑。一条也不写,就是照画布原样隔一段时间跑一张。AI 也能排任务单:跟它说"这几条提示词各跑 4 张"或"cfg 4、5、6 各跑 3 张",它交给 AMB 一张张跑,工作台里看进度和图。
|
||||
- New "Job sheet": right-click the canvas, "Job sheet (many prompts)". Paste many prompts (a line each), say how many runs each gets, the seconds to rest between pictures and whether every prompt gets the same seeds, then Start. It runs copies of the canvas as it was at the start, so your canvas stays as it is; pause, go on and stop any time, a failed run pauses it with the reason, and a reload goes on. With no prompt it runs the canvas as it is, with a rest between pictures. An AI can hand over a sheet too ("these prompts, 4 each"; "cfg 4, 5 and 6, 3 each"): AMB runs it one by one and the workbench shows how far it is and its pictures.
|
||||
- 模型详情页的开头重新排了:大字写模型名,版本放在下面一行灰色小字(原来那串长文件名挪到下面的清单里),一行蓝色小字写类型、大小、底模和 Civitai 链接,按钮只留「插入节点」一个实心的。训练词、作者常用设置、还需要的文件、版本、文件名排成一张细线隔开的清单;作者的介绍默认收起,点开再看。中文界面里那行 “Size:” “Base:” 也改成了中文。
|
||||
- 角色语音的角色卡可以设封面了:点卡片左边的头像,从图库里点一张或从电脑选一张,自动裁成方形(竖图保留上面一截),同一个角色的几个版本共用。没设封面时显示名字的第一个字。卡片也按模型详情的思路重新排了:大字角色名、灰色版本、一行蓝色小字,按钮变成文字按钮,声音列表用细线隔开。
|
||||
- Voice characters can have a cover: click a card's avatar and pick a picture of the gallery or the computer; it is cut square (a tall one keeps its top) and a character's versions share it. Without one, the name's first letter shows. The cards are laid out like the model details too: the name big, the version in grey, one small blue line, plain buttons, voices parted by thin lines.
|
||||
- 模型详情里「作者的介绍」:记住你上次是展开还是收起;英文的介绍旁边多了「翻译」,一段一段翻成中文(有 DeepL 的 key 用 DeepL,没有用谷歌,再不行用 MyMemory,走设置里的代理),「看原文」切回去。翻过的存在 AMB 自己的文件夹里,下次打开直接显示。
|
||||
- In a model's details, its author's text remembers whether you left it open or folded, and English text gets a Translate (Chinese text on the English window too): paragraph by paragraph through DeepL with your key, else Google, else MyMemory, by your proxy; Original goes back. A translation is kept in AMB's own folder and shows at once next time.
|
||||
- The model details open like a profile: the model's name big, its version under it in small grey (the long file name moved to the list below), one small blue line with its type, size, base and the Civitai link, and only Add node filled among the buttons. Trigger words, its author's settings, the files it needs, its versions and its file sit in one list parted by thin lines; its author's text from Civitai starts folded.
|
||||
- 工作台时间线顶上的流程,在 AI 交过蓝图时多一行:蓝图的每一步(带模型名)和“挑一张小样”,做完的点亮、正在做的闪、还没做的是虚线框,点一下跳到对应的那一步。AI 直接搭(你设了不用问)还是先问你,都在这里看得到。AI 排任务单时,跑图那一格写“任务单 6/10”。
|
||||
- The flow over a task in the workbench gets a second line once the AI handed a blueprint: its steps with their models and picking a base, done ones lit, the running one pulsing, the coming ones dashed; a click goes to that step. It shows whether the AI asked you first or built it as your setting says. A job sheet's run shows "Job sheet 6/10".
|
||||
- 画布右键(节点或空白处)新增「交给 AI…」:几个现成说法(出 4 张挑最好的、找问题帮我修、讲讲每一步、后面加放大和修脸修手;节点上是讲讲这个节点、帮我调参数),点一下就能改了发出。AI 正在做任务时直接作为留言发给它,也可以开新任务;新任务的 AI 从这张画布开始。节点上有图时可以一起发。运行出错时右下角会出现「让 AI 看看」,报错已经写好。
|
||||
- New "Hand to AI…" in the canvas's right-click menu (a node or the empty canvas): ready wishes (run 4 and show the best, find what is wrong and fix it, explain each step, add an enlargement and face and hand fixes; for a node, explain it or tune it), one click to fill in and change. With an AI on a task it goes to that task as a message, or starts a new task that begins from this canvas; a node's pictures can go along. When a run fails, "Ask the AI" appears with the error already written.
|
||||
- 画布上的模型只读得懂英文(SD 1.5、SDXL、Pony、Illustrious、Flux 这类)而提示词是中文、日文或韩文时,跑之前先说一声:这样出的图和提示词无关,是模型自己偏爱的东西。AI 来跑会先被拦一次,让它改写成英文(你就是要照原样跑,它再叫一次就放行);任务单点「开始」时提醒一次,再点就照样跑。Anima、Qwen-Image、Z-Image 这些读得懂中文的不拦。
|
||||
- When the canvas's model reads English only (SD 1.5, SDXL, Pony, Illustrious, Flux and the like) and the prompt is Chinese, Japanese or Korean, AMB says so before running: such pictures follow nothing of the prompt and show whatever the model leans to. An AI's run is stopped once and told to write the prompt in English (called again unchanged, it runs as written); the job sheet's Start warns once and runs on the second press. Models that read Chinese (Anima, Qwen-Image, Z-Image) are not stopped.
|
||||
- 现成做法接在别的步骤后面时,不再接上一步模型线上的 Tile LLLite、ControlNet 这类跟图片尺寸绑定的补丁:那一步的补丁只属于那一步。以前在 Tile 放大后面接脸手细化或再放大,可能出错甚至让 ComfyUI 整个崩掉(测试里崩过一次)。
|
||||
- A ready-made way going on from another step no longer takes the size-bound patches on that step's model line (a Tile LLLite, a ControlNet): they belong to their own pass. Face and hand detailing or another enlargement after a Tile redraw could fail, or crash ComfyUI (it did once in a test).
|
||||
- 换模型精修多了两种:**只改人物**和**只改场景**。AMB 先自动抠出人物,人物那一种只让第二个模型重画人物,边缘慢慢减弱,场景一个像素都不动;场景那一种只重画背景,人物和它边上一圈原样保留。这样可以先用一个模型把整张图的构图和动作定好(人坐不坐得住椅子,要两样一起画才对),再让擅长人物或擅长场景的模型只改它擅长的那一部分。在 Anima 上,有 Tile 模型(animaTileRepair,AMB 的 Tile 放大也用它)时会自动加上 Tile,让第二个模型照着原图的线条画,强度用 0.7,画风能换得更明显(改多少还跟采样器有关:AMB 画 Anima 默认的 dpmpp_2m_sde 改得多,euler 改得少,线条都保得住):实测强度开到 0.70 时,不加 Tile,背景里的窗户会消失、包会被画成另一个;加了 Tile,它们都留在原位,只换画法。没有这个模型,或者不是 Anima 时,不加 Tile,强度用 0.45。重画的是哪一块,会在预览里显示(白色的部分)。自动抠图常会把人坐着的椅子、手里拿的伞和包也算进人物:只改人物时它们跟着人一起重画,接触的地方还对得上;只改场景时它们跟人一起原样保留。原来的整张重画保持不变。实测 RTX 4050 6GB、Anima(Harem 出图,RealSkin 只改人物、Kirazuri 只改场景),1440×1024,加 Tile,一次跑完三张约 337 秒。
|
||||
- Refining with another model has two more kinds: **only the character** and **only the background**. AMB cuts the character out first; the character kind has the second model redraw only the character, fading out at its edge, the scene not changed by a single pixel; the background kind redraws only the background, the character and a band around it left as they were. So one model can settle the whole picture's layout and pose first (a character only sits on a bench when both are drawn together), and a model good at characters or at scenery changes only its part. On Anima, with the Tile model there (animaTileRepair, the one Anomalous's Tile upscale uses), a Tile is added so the second model follows the picture's lines, and the strength is 0.7 for a clearer change of look (how much it changes follows the sampler: the dpmpp_2m_sde Anima pictures are drawn with more, euler less, the lines kept either way): at 0.70 without the Tile the background's windows vanished and a bag was redrawn as another; with it they stayed in place in the new look. Without that model, or on other models, no Tile and 0.45. The preview shows what is redrawn (white). The cut-out often takes the bench the character sits on and the umbrella or bag it holds along: redrawn with the character in the first kind, so where they touch still fits, and kept with it in the second. The whole-picture refine is unchanged. Tried on an RTX 4050 6 GB with Anima (Harem draws, RealSkin only the character, Kirazuri only the background), 1440×1024 with the Tile: about 337 s for the three pictures in one run.
|
||||
- 新做法 **放大·只重画人物**,给背景本来就虚化的图用:整张用放大模型拉大 1.5 倍,背景不再动;自动抠出人物,只裁出那一块重画,再缝回去。实测整张重画的 Tile 放大会把远处的铁轨、树画得清清楚楚,图也发灰,景深就没了;这样做虚化原样保住,人物变清楚,只裁人物来重画也快得多(270 秒降到 150 秒左右)。Anima 上有 Tile 模型时照着原图线条画(强度 0.7),没有就用 0.45。背景清楚的图还是用 Tile 锁图放大。官方示例的「两段放大」也多了一个版本「Anima · 背景虚化,只重画人物」,手动跑也能用(背景用普通拉大,不需要放大模型)。AI 的「分阶段出图的思路」多了一段按部位换模型:先把整张画好,再只改人物或只改场景,想明显换画风就加 Tile,背景虚化的图放大时只重画人物。实测 RTX 4050 6GB、Anima,832×1216 出图再放大到 1248×1824,一次跑完约 223 秒。
|
||||
- New way **Upscale, only the character redrawn**, for a picture whose background is blurred on purpose: an upscale model enlarges it all 1.5x and the background stays as it is; the character is cut out, only its crop is redrawn and stitched back. A whole Tile redraw drew the far tracks and trees sharp and hazed the picture, and the depth was lost; this way keeps the blur, sharpens the character, and the crop is much faster (about 150 s where 270). On Anima with the Tile model it follows the picture's lines (0.7), without it 0.45. A sharp background still takes the Tile upscale. The official example "Two passes" has a version "Anima · blurred background, only the character redrawn" for running it by hand (the background enlarged plainly, no upscale model needed). The AI's skill "Drawing a picture in stages" gained the by-part way: the whole picture first, then only the character or only the background, a Tile for a bigger change of look, and on enlarging a blurred background only the character redrawn. Tried on an RTX 4050 6 GB with Anima, 832×1216 drawn and enlarged to 1248×1824: about 223 s for a whole run.
|
||||
|
||||
- AI 工作台的「做法」面板变短了:先只列这里能用的做法,每块一行(名字、一句话、「这次用它」、开关),点名字前的小三角才展开全文、来源、测过的耗时和推荐的模型;接着是 skill;缺节点包或模型的做法收进最下面的「还差东西的」,默认折起,点开每块写着缺什么。以前 24 行排了五屏多,能用的和缺东西的混在一起,skill 压在最底下。
|
||||
- The Ways panel in the AI workbench is short now: first only the ways that can run here, a row each (name, a line, "Use this time", the switch), opening by the small triangle for all its words, where it came from, how long it took when tried and the models it recommends; then the skills; the ways lacking a node pack or a model are folded at the bottom, each saying what it lacks. It used to be 24 rows over five screens, usable and missing ones mixed and the skills last.
|
||||
- Anima 的 Tile(和别的 Anima LLLite)是给 2B 的 Anima 模型做的,放到 2.9B 这类更大的模型上,跑到一半会报 "depth_embed slices missing"。现在 AMB 从两个文件的文件头里数出块数(2B 是 28 块,2.9B 是 40 块),AI 要跑不适配的画布时直接拦下,告诉它是哪个节点、该换哪个加载器的模型;带 Tile 的官方示例打开时也只挑适配的模型。原来写在四块做法和两个示例里的那段提醒删掉了。
|
||||
- Anima's Tile (and its other LLLite models) is made for the 2B Anima models; on a larger one such as a 2.9B the run stops midway with "depth_embed slices missing". Anomalous now counts the blocks in the two files' headers (28 for 2B, 40 for 2.9B): an AI's run on a canvas where one does not fit is refused with the node and the loader to change, and an official example with a Tile opens on a model the Tile fits. The warning written into four ways and two examples is gone.
|
||||
- 官方示例窗口:Anima "要多张图就多跑几次,别调批次" 只在 Anima 的版本上面说一次,不再每个版本各写一遍;一个版本的两个节点来自同一个节点包时,「缺节点包」只列一次(以前 ComfyUI-Inpaint-CropAndStitch 会出现两次)。AI 那边,这句话也只写在模型档案一处,AMB 自动附在每个 Anima 示例后面。
|
||||
- The official examples window says Anima's "run several times, not a bigger batch" once, above the Anima versions, not in each of them; a pack two nodes of a version come from is named once under what it lacks (ComfyUI-Inpaint-CropAndStitch showed twice). For an AI the sentence is written once too, in the model profile, and goes with every Anima example.
|
||||
- skill「分阶段出图的思路」只留怎么拿主意(先出底图给你挑、两个模型什么时候必须同类、画风 LoRA 要不要带到第二个模型),不再用文字把每条路子抄一遍:有哪些路子、各自什么时候用,写在每块做法自己的文件里,AI 取这个 skill 时 AMB 只列出这里装得上、开着的那几块。以后加做法不用再改 skill。原来的全文已经略超过 AI 软件在聊天里能直接显示的长度(3,991 字节,线是 3,800),再长就会被存成文件、AI 看不到;现在最长 3,586 字节,有测试看着。
|
||||
- The skill "Drawing a picture in stages" keeps only the judgment (the base shown first for you to pick, when two models must be of one family, whether a style LoRA goes onto the second model) and no longer retells every way in words: which ways there are and when each fits is written in each way's own file, and when the AI fetches the skill Anomalous lists only those installed and switched on here. Adding a way no longer means editing the skill. Its full text had grown just past what an AI app shows in the chat (3,991 bytes against a line of 3,800), beyond which it is filed away unread; it is 3,586 at its longest now, and a test keeps it there.
|
||||
- 给 AI 的叫法改了一个词:Tile 锁图放大从 `want="detail"` 改成 `want="tile"`,免得和"修脸和手"那个用途(也叫 detail)混在一起。
|
||||
- One word changed for the AI: the Tile upscale is asked for with `want="tile"`, not `want="detail"`, which was also the name of the use that fixes faces and hands.
|
||||
- 「挑一张底图」里不会再混进一张黑白遮罩了。用“只重画人物”这类做法时,后面那一步有个“要重画哪块”的遮罩预览,它也被当成了一张底图;挑中它,后面就照着遮罩去放大,成品是废的。现在只把真正的底图拿出来给你挑,遮罩预览留到整条跑的时候才出。
|
||||
- "Pick a base" no longer shows a black-and-white mask among the bases. With a way that redraws only the character, the later step's preview of what it will redraw was taken for a base too; picked, the rest enlarged the mask and the picture was ruined. Only the bases themselves are offered now, and the mask preview comes with the whole run.
|
||||
- AI 不带任何参数试跑一下时,如果画布是从零开始画、提示词框还空着,先拦一次,告诉它把提示词一起给出来(你就是要空着跑,它再叫一次就照跑)。实测 Gemini Flash 习惯先这样空着试一次,蓝图刚搭好时这一下会白画三张没有内容的小样,还把“先出三张给你挑”用掉了。
|
||||
- When an AI tries a run with no arguments and the canvas draws from nothing with its prompt box still empty, the run is refused once and it is told to give the prompt with the run (called again unchanged, it runs). Gemini Flash tries tools this way; on a blueprint just built that drew three bases of nothing and used up the three bases you pick from.
|
||||
- Tile 不适配被拦时,如果那个模型是你点名要用的,AI 会被提醒先问你再换,不是自己悄悄换掉。AI 记决策时把“否决了什么”写成一句话,不再被拆成一个个字。
|
||||
- When a run is refused because a Tile does not fit and you named that model yourself, the AI is told to ask you before changing it. A decision whose rejected option the AI wrote as one sentence is no longer split into letters.
|
||||
- AI 往展台上画面板时少了两种积木:它自己报数的进度条和分隔线。出图、任务单、蓝图的进度现在都由 AMB 自己显示,是真实的数,AI 另报一条反而可能是旧数;分隔线只是装饰。以前的任务里 AI 画过的进度条不再显示,其余内容照旧。模型卡、表格、列表、流程图都留着。
|
||||
- Two blocks are gone from the panels an AI draws on the stage: the progress bar with its own numbers, and the divider. A run's, a job sheet's and a blueprint's progress are shown by Anomalous itself, with real numbers, where the AI's own bar could be stale; the divider was decoration. A bar an AI drew in an earlier task is no longer shown; the rest of that panel stays. Model cards, tables, lists and diagrams remain.
|
||||
- **隐私模式的提示词框多了一格“画面大意”**。以前整段提示词都变成 [P1],AI 什么都看不到,没法帮你想光影、镜头和画风。现在最上面这一格是原样给 AI 看的:用不介意让它看到的话说说想要什么,它照着补全,再把你的私密提示词以代号嵌进去。两格可以只填一格。
|
||||
- **The prompt box of privacy mode has a new field, the gist.** With the whole prompt a token, the AI could not help with light, lens or style. The first field now goes to the AI as written: say what you want in words you do not mind it reading, and it writes the rest around the tokens of your private prompts. Either field alone can be handed in.
|
||||
- **隐私模式把模型名也藏起来了**。模型和 LoRA 的文件名、卡片名、所在文件夹、C 站链接和指纹都不再发给 AI,它拿到的是 [M1] 这样的代号,一个模型不管出现在哪都是同一个;AI 把代号填到要用模型的地方,AMB 换回真模型。它凭类型、底模、作者推荐参数和触发词(代号)来挑。任务标题栏 ⋯ 里的“模型代号对照”能查每个代号是哪个模型。不想藏,在关卡窗口把“隐私任务里的模型名”改成“照常给 AI”。
|
||||
- **Privacy mode keeps model names too.** The file names, card names, folders, Civitai links and hashes of models and LoRAs no longer go to the AI; it gets tokens like [M1], one per model wherever it shows, writes the token where the model goes, and Anomalous puts the model back. It picks by type, base model, the author's settings and the trigger words (as tokens). "Model tokens" in the task's ⋯ says which model each is. To send names as before, set "Model names in a private task" to "Sent to the AI" in the Gates window.
|
||||
- 模型的编辑窗口多了一栏 **给 AI 的用途说明**:一句不介意让 AI 看到的话,说这个模型是干什么用的(比如“双人互动姿势”)。AI 搜模型时能看到、也能按它搜;隐私模式藏起模型名时,它就靠这句话来分辨。不填就不给。
|
||||
- The model editor has a new field, **Note for the AI**: a line you do not mind an AI reading on what the model is for. The AI sees it when it searches models and can search by it; with names hidden in privacy mode, it tells the models apart by it. Left empty, nothing is sent.
|
||||
- 隐私模式补了一个漏洞:搭配的名字现在也换成代号。没改过名的搭配,名字是自动拿“模型名 + 提示词开头”拼的,以前会原样给到 AI。AI 用代号照样能把搭配放上画布。
|
||||
- A hole in privacy mode is closed: a combo's name is a token now. A combo you never renamed is called by its model's name and the start of its prompt, which went to the AI as written. The AI places a combo by its token as before.
|
||||
- **隐私任务能查“发给 AI 了什么”**。任务标题栏 ⋯ 里新增这一项:隐私模式开着时,AI 每一次调用发来的参数和 AMB 回给它的文字都原样记着,可以搜一个词(某个模型名、提示词里的词)看它有没有出去过。只留最近 300 次调用,任务移除时一起删掉。
|
||||
- **A private task shows what the AI was sent.** New in the task's ⋯: while privacy mode is on, every call is kept as it went, what the AI wrote and the text Anomalous answered, and you can look for a word (a model name, a word of a prompt) to see whether it ever went out. The newest 300 calls are kept and removed with the task.
|
||||
- **说清了隐私模式管不到的地方**:它过滤的是 AMB 发给 AI 的内容。Antigravity、Cursor、Claude Code 这类能自己读硬盘、跑命令的 AI 软件,可以直接打开出图文件夹和 AMB 的任务记录,这个拦不住;用它们开隐私任务时,标题栏下面会有一行提醒,隐私开关的说明里也写明了。只会调用工具的聊天软件不受影响。
|
||||
- **Privacy mode now says what it cannot cover**: it filters what Anomalous sends to the AI. An AI app that reads the disk and runs commands by itself (Antigravity, Cursor, Claude Code and the like) can open the output folder and Anomalous's task records, and this cannot stop it; a private task run by such an app says so under its header, and so do the switch's hints. A chat app that only calls tools is not affected.
|
||||
- 隐私模式里,**你起的名字也都是代号了**:工作流卡片名、提示词库的条目名、它们的标签,以及图片信息里“用哪张卡片、哪个搭配做的”。这些名字里常常写着模型名或画的内容。AI 用代号照样能放搭配、用卡片;你在聊天里说出名字,它照样搜得到。
|
||||
- In privacy mode **the names you gave are tokens too**: workflow card names, saved prompt names, their tags, and which card or combo a picture was made with. Such names often say the model or what is drawn. The AI still places a combo and uses a card by its token, and still finds one by a name you say in the chat.
|
||||
- 隐私模式新增 **藏**:在提示词框的“画面大意”里,或隐私任务的留言框里,选中几个字点“藏”,这几个字 AI 只拿到代号,其余的它照常看、照常改。不用再为了几个词把整段都藏起来。
|
||||
- Privacy mode has **Hide**: in the gist of the prompt box, or in a private task's message box, select a few words and press Hide; the AI gets a token for them and reads and edits the rest. A few words no longer mean hiding the whole text.
|
||||
- 跑完的图点了 **不太行**,下面多一排 **哪里不行**:手坏了、脸不对、构图偏了、太暗、太亮、糊、画风不对……点哪个,AI 就知道该改哪个,可以点几个。隐私模式下 AI 看不到图,你点一下就是它的眼睛;普通任务里也省得打字。
|
||||
- Under a run you marked **not quite right** there is now **what is off**: hands, face, framing, too dark, too bright, blurry, wrong style and more. A click tells the AI which to fix, and you may pick several. In privacy mode the AI is not sent the pictures, so your click is its eyes; in any task it saves the typing.
|
||||
- 新增拓展功能 **暗房助手**:在你自己电脑上跑的本地模型(Ollama、LM Studio 这类),替隐私任务读云端 AI 看不到的东西。云端 AI 说怎么改,它拿原文去改写提示词,云端 AI 仍然只拿到代号;要看图时图交给它,它只回答固定的六项(清晰度、明暗、手、脸、身体、构图),每项只能是规定的几个词。只连本机地址,内容不出这台电脑。在关卡窗口最下面设置;没装本地模型就当它不存在。
|
||||
- New extension, the **darkroom helper**: a model running on your own computer (Ollama, LM Studio and the like) reads for a private task what the cloud AI may not. The cloud AI says what to change and the helper rewrites the prompt from its text, the cloud AI still getting only a token; a picture goes to the helper, which answers a fixed list of six checks (sharpness, exposure, hands, face, body, framing), each with one of a few set words. It connects to this computer only. Set it up at the bottom of the Gates window; without a local model it is simply not there.
|
||||
- 「连接 AI」里能选**给 AI 哪些工具**:全部工具(默认,AMB 里交给 AI 的任务要用这一份)、**跑图精简**(你在 AI 软件的聊天框里直接叫它跑图时用,图还是你的 ComfyUI 画;去掉了 AMB 面板里的问答、挪动/扫描/下载模型和配音,AI 每轮少读约三分之一的工具说明),或者**我选的**(按类别自己勾,保存后用)。下面的地址会跟着变,比如末尾多了 `?tools=core`;只是清单变短,AMB 的功能没有少;用精简清单时,AI 看现状(get_situation)会顺带拿到 AMB 的现成做法和技能,不会一个节点一个节点地硬搭。点子来自议题 #27。
|
||||
- "Connect an AI" picks **which tools the AI gets**: every tool (the default; tasks handed over in Anomalous need it), **Drawing only** (for when you tell the AI to draw right in your AI app's chat, your ComfyUI still drawing: without Anomalous's panel questions, moving, scanning or downloading models and the voice, so the AI reads about a third less of tool descriptions every round), or **My pick** (tick them by kind, then save). The address below follows, ending in `?tools=core` for example; only the list gets shorter, nothing in Anomalous is taken away; on the short list, the AI's look at where things stand (get_situation) also names Anomalous's ready-made ways and skills, so it does not wire everything node by node. The idea came from issue #27.
|
||||
- AI 没起名的任务(多半是你在 AI 软件聊天框里叫它做的),AMB 按它第一个动手的步骤起的名字改成中文显示:以前是「run_workflow」「new_workflow: …」,现在是「运行工作流」「新建了工作流:…」。
|
||||
- A task the AI never named (mostly one you asked for in your AI app's chat) shows the name Anomalous took from its first step that did something in the page's words: "Ran the workflow", "Started a workflow: …" instead of "run_workflow", "new_workflow: …".
|
||||
- 显卡 8G 及以下时,AMB 的放大重绘做法(轻放大、Tile 锁图放大、只重画人物的放大)最后一步改用 ComfyUI 自带的「VAE 分块解码」。以前在 6G 笔记本上放大两倍,光解码就卡了 20 分钟,桌面和浏览器也跟着卡;分块解码不用装插件,大显存的卡照旧。
|
||||
- On a graphics card of 8 GB or less, Anomalous's upscale-and-redraw ways (the light upscale, the Tile upscale, enlarging only the character) decode the last step with ComfyUI's own VAE Decode (Tiled). A 2x upscale on a 6 GB laptop spent 20 minutes decoding alone and froze the desktop; the tiled decode needs no extra pack, and bigger cards stay as they were.
|
||||
- 主页多了「让 AI 帮你做」:顶上一行写最近连接的 AI 软件(连的是精简工具清单时会提醒它接不了 AMB 里的任务),下面六张卡:先试一次、跑我存的预设、照我说的出图、改一张图、隐私模式出图、看懂我的画布。点开先看说明页:点「开始」会发生什么、什么不会动、AI 拿不到什么、要注意什么;再点「开始」,AMB 把任务建好、开关设好(在新标签页里做,隐私卡直接是隐私任务),给你一句复制给 AI 的话。卡上的小标签只写 AMB 自己能保证的事。
|
||||
- The home page has "Let the AI do it": a line on the AI app that came last (with a warning when it holds a short tool list that cannot take a task handed over here), and six cards: Try it once, Run my preset, Draw what I describe, Change a picture, Draw in privacy mode, Explain my canvas. A card opens a sheet first: what happens on Start, what stays as it is, what the AI does not get, what to mind; Start makes the task with its switches set (a new tab; the privacy card makes a private task) and gives the sentence to copy to the AI. A card's tags name only what Anomalous itself makes sure of.
|
||||
- AI 工作台有了新手引导:第一次打开、里面有任务时,自动带你看一遍新任务、连接 AI、关卡、任务列表、任务的 ⋯、时间线和留言框;以后在主页「让 AI 帮你做」点「怎么用」可以再看。要等 AI 做过事才出现的东西(跑完图的「不太行」、AI 改画布的「撤销这一串」、隐私任务里的「藏」),第一次出现时下面有一行说明,点「知道了」就不再出现。引导遇到看不见的按钮会跳过,很高的一块只框住看得见的部分。
|
||||
- The AI workbench has a newcomer's tour: the first time it shows a task it walks you through New task, Connect an AI, the gates, the list, a task's ⋯, the timeline and the message box; the home page's How it works (under Let the AI do it) runs it again. What shows up only after the AI has done something (Not quite under a run, the AI's canvas undo, Hide in a private task) gets one line the first time, gone after Got it. A tour skips a control that cannot be seen and lights only the visible part of a tall one.
|
||||
- 「关卡」窗口最上面多了「安全级别」:点一下 **谨慎**(每件事都先问、只用现成做法里的节点)、**平衡**(默认:改画布、用显卡和联网直接做,下载和挪文件先问)或 **放手**(下载也不问、蓝图直接搭、没及时回答按推荐走;挪文件仍然先问),一次性设好下面那一串开关,不用一个一个改;单独改过下面某一行会变成自定义。
|
||||
- The Gates window has a **Safety level** row at the top: one click sets **Careful** (asks before everything, uses only nodes of the ready-made ways), **Balanced** (the default: canvas, graphics card and going online go ahead; downloads and moving files ask first) or **Hands-off** (downloads without asking, builds blueprints at once, takes its recommendation when you do not answer in time; moving files still asks) across the switches below; changing any row on its own shows Custom instead.
|
||||
- 「挑一张底图」选错了可以改:AI 出新底图之前,卡上别的几张都有「改选这张」,点了就换成新的那张(同样存进输出文件夹、锁住它的种子),AI 会收到;以前只能留言告诉 AI,它得停掉已经开跑的放大、把底图重画一遍。
|
||||
- Took the wrong base on "Pick a base"? Until the AI draws new bases, the other previews have Take this instead: the new one is kept in the output folder and its seed held the same way, and the AI is told. Before, you could only write to the AI, which had to stop the upscale it had started and draw the base again.
|
||||
|
||||
## v1.63.0 (AI Ways and Skills, Saves, Official Example Workflows, Privacy Mode) — 2026-11-10
|
||||
|
||||
- 画布左上角的立方体入口不再压在 ComfyUI 自己的菜单上面:以前它在最顶层,打开左上角的 C 菜单时会盖住“新建”那一行。现在它在画布上面、顶栏和菜单、对话框下面。README 的安装第 2 步也配上了图,写明装好后入口在哪、看不到时怎么办。
|
||||
- The cube entry at the canvas's top left no longer sits over ComfyUI's own menus: it was on the very top layer and covered the "New" row of the C menu. It is now over the canvas and under the top bar, menus and dialogs. Step 2 of the README's install has a picture of where the entry is after installing and what to do when it can't be seen.
|
||||
|
||||
- 在设置里切换语言后,AI 工作台右边的展台、留言框和画布下方的存档条也马上换成新的语言。以前它们要刷新页面才变。
|
||||
- After switching the language in Settings, the AI workbench's stage and message box and the saves bar under the canvas change at once too. They used to wait for a page reload.
|
||||
- 浏览器里同时开着两个 ComfyUI 页面时,AI 干活不再时好时坏:以前两个页面会各抢一半动作,一个页面开了任务的标签页,另一个页面接着去跑却找不到它("标签页关了")。现在任务的动作先交给上次干活的那个页面,它关掉了别的页面才接手。
|
||||
- With ComfyUI open in two browser tabs the AI no longer fails now and then: the two pages took half the actions each, one opened the task's tab and the other then could not find it ("the tab is closed"). Now the page that did the last action gets the next first, and another takes over only when it is gone.
|
||||
- AI 用 `run_workflow` 时可以直接写图库里一张图的名字:一步打开这张图的工作流、改好提示词或种子、跑出来;同一张图再跑就接着用那个标签页。打开图的工作流时换一个新的工作流编号,原来那个标签页还开着也不会串;打开后的标记在加载之前就写进去,不会被 ComfyUI 收尾时冲掉(以前偶尔会说"标签页关了"或"没打开")。
|
||||
- With run_workflow the AI can name a picture of the gallery: its workflow opens, gets the new prompt or seed and runs, in one call, and a run of the same picture again reuses that tab. A picture's workflow opens with an id of its own, so the tab it ran on, still open, is not mixed up with it, and the tab's mark goes in before it loads, so ComfyUI finishing the tab does not drop it (it said now and then that the tab was closed or did not open).
|
||||
- 偶尔打开 ComfyUI 时 AMB 整个没出来(刷新一下又好了)的问题修好了。ComfyUI 会同时加载插件的全部 js 文件,碰巧某个文件先跑时,会用到另一个还没准备好的变量,整个插件就加载失败;重启或更新后第一次打开最容易碰上。新加的测试会把每个文件都当成第一个加载试一遍。
|
||||
- AMB is no longer missing now and then when ComfyUI opens (a reload brought it back). ComfyUI loads every js file of the plugin at once, and when one file happened to run first it used a value of another file not made yet, and the whole plugin failed; the first open after a restart or update met it most. A new test runs each file first.
|
||||
- AI 看得到工具的回答了:AI 软件收到太长的回答(Antigravity 大约 4,000 字节以上)会存成文件不看,AI 就什么也没拿到,只好缩小范围重查(考试里为挑一个 LoRA 搜了 8 次)。现在搜模型的回答只留有用的(同类模型的加载节点只写一次,触发词列前四个,LoRA 带上作者示例图的权重),读大画布时后面的节点写成一行,其他列表放不下的列出名字,告诉 AI 怎么查剩下的。
|
||||
- AI apps get to read the tools' answers: an app files an answer past its size (about 4,000 bytes in Antigravity) away unread, so the AI had nothing and searched again with smaller limits (eight searches for one LoRA in the exam). Model search now keeps what is used (a type's loaders said once, four trigger words, a LoRA's weight from its author's pictures), a big canvas gives its last nodes a line each, and any other list names what did not fit and says how to get it.
|
||||
- 画布是空的时候,AI 跑图或读画布会被告知先打开一个工作流(存的、示例,或者图库里某张图自带的),不再让它去"加一个保存节点"。AI 查一张图的参数时,也会知道这张图的工作流能直接打开。
|
||||
- On an empty canvas the AI is told to open a workflow first (a saved one, an example, or a gallery picture's own) instead of being told to add a save node. Reading a picture's settings, the AI learns that its workflow can be opened as it was.
|
||||
- AI 发来只写着"test"这类试探的问题,不再放到你面前等回答,AI 会收到正确的问法。任务上的"出图 N 张"只数真正跑出来的图,AI 找到给你看的旧图不算。
|
||||
- A question that only says "test" or the like no longer waits in front of you: the AI is told how to ask. A task's picture count counts the pictures its runs made, not old ones the AI found to show you.
|
||||
- AI 想改图库里的某一张图时,可以直接打开那张图自带的工作流(`open_workflow` 写图片名),不用再打开原来的工作流卡,把换过的模型、LoRA 和提示词一样一样设回去。打开的是新标签页,你开着的那页不动。Gemini 实测,这类题从 28 次调用降到 21 次。
|
||||
- When the AI changes a picture from the gallery, it can open the workflow that picture carries (open_workflow with the picture's name), instead of opening the saved card and putting back the model, LoRA and prompt that had been changed one by one. It opens in a new tab and leaves yours as it was. In a real Gemini run such a task went from 28 calls to 21.
|
||||
- 在设置里切换语言后,AI 工作台的任务列表和时间线马上跟着换。以前有一部分要刷新页面才变。
|
||||
- Switching the language in Settings now changes the AI workbench's task list and timeline at once. Part of them used to wait for a page reload.
|
||||
- 任务上的“出图 N 张”不再把同一张图算两次。以前 AI 汇报时附上刚跑出的那张图,会被多数一次。
|
||||
- A task's picture count no longer counts one picture twice. When the AI's report showed the picture its run had made, it was counted again.
|
||||
- AI 看工作流清楚多了(Gemini 实测发现的):列表以前一次两万多字,AI 软件直接存成文件不看了,现在卡片多时只给名字、路线和备注。每张卡的路线说明以前一直是空的,现在有了;按模型找卡片也恢复了。官方示例总会列出名字,用中文(图生图、两段放大)也搜得到。
|
||||
- AI apps see your workflows better (found in a real Gemini run): the list was over 20,000 characters, which an AI app files away unread, and is now names, routes and notes when there are many. Each card's route line was always empty and now says the route, and finding cards by model works again. Official examples are always named, and found by their Chinese names too.
|
||||
- 新任务里先写了留言的话,任务列表就用这句话当标题,不再都叫“接手任务”。
|
||||
- A new task you wrote to takes its name from what you wrote, instead of the AI's "take the task".
|
||||
- 两个 Anima 两段放大示例写明了:要多张图就多跑几次,别调批次。Anima 的 VAE 会把一批图当成视频帧,第二段只剩一张。
|
||||
- The two Anima two-pass examples now say to run several times for several pictures, not to raise the batch: Anima's VAE takes a batch for video frames, and the second pass is left with one.
|
||||
- 当前节点的提示词卡片现在都标着“正面提示词”或“负面提示词”:从图片存下来的也按它原来接在采样器哪个口上来认,以前只有工作流来源的才标,图片和工作流同时有时标注还会丢。
|
||||
- Current node's prompt cards all say positive or negative now: values kept from a picture are told by what they were wired to in that workflow. Before, only a workflow's said so, and the label was lost when a picture held the same text.
|
||||
- 回到一个存档时,输出节点上的图也跟着换:画布是哪个存档,节点上就是哪个存档跑出来的图。跑了几次就都排在节点里,和批量出图一样,新的在前,最多 16 张。这个存档没有图时节点空着,不留别的存档的图,免得看错。只对这个版本之后跑的图有效。
|
||||
- 回到存档时数字有时不亮、撤销多出一步的问题修好了:画布上有 Preview Image 这类节点时,每隔一次就会这样。
|
||||
- Going back to a save brings its pictures back too: the output nodes show what that save made. Several runs sit on the node together as a batch does, the newest first, up to 16. A save with no picture leaves the node empty rather than showing another save's. This works for runs from this version on.
|
||||
- Going back to a save no longer loses the lit number or leaves an empty undo step every other time, as it did with a node like Preview Image on the canvas.
|
||||
- 发版前把每个页面按中英文、深浅色和 480 窄宽走了一遍,修了一批小问题。浅色主题下,模型整理页的标题和文件名、任务上“接着做”的标签原来几乎看不见。窄窗口下,任务的“接着哪个任务”标签伸出窗口、× 点不到,设置页的主题按钮把页面撑出横向滚动,模型页的搜索框压住标题,工作流详情的“新预设”被切掉。
|
||||
- 英文界面的列表和冒号不再是中文标点,“1 saves”这类单复数对了,左边栏的 AI 工作台写作 “AI”,一条漏翻的提示补上了。深色主题下浅紫色按钮和角标上的字改成深色,看得更清楚。
|
||||
- A round through every page in both languages, all themes and at 480 fixed small things. In the light theme, the Tidy page's headings and file names and a task's "goes on in" chip could hardly be read. In a narrow window, a task's "goes on from" chip ran out of the window with its × out of reach, the theme buttons in Settings pushed the page sideways, the Models search sat over the title, and "New preset" was cut off.
|
||||
- The English window no longer shows Chinese punctuation in lists and after labels, "1 saves" and the like have the right plural, the rail says "AI" for the AI workbench, and one line that was left in Chinese is translated. In the dark themes, text on pale accent buttons and badges is dark now, so it reads better.
|
||||
- 侧栏下面的工具按常用程度排了:当前节点在最上面,然后是模型检查,模型扫描在最下面。
|
||||
- The rail's tools go by how often they are used: Current node first, then Model Check, with the scan last.
|
||||
- 当前节点的参数卡清爽了:来源不再一条一行,改成按工作流、搭配、图片、这里存的分开的小标签,同一渠道几条合成一个,点标签直接过去,几条的话先列出来。
|
||||
- 当前节点现在也列出搭配里同类节点的数值,可以对比和套用;普通搭配给提示词框,节点结构搭配给每个框。
|
||||
- Current node's cards are easier to read: where a set came from is a chip per channel (workflow, combo, picture, saved here) instead of a line per source, a press goes there, and several in a channel are listed first.
|
||||
- Current node now lists the values combos hold for that node type, to compare and put in: a plain combo's prompt on a prompt box, every box of a node structure.
|
||||
- 给 AI 挑模型时和模型页一样好找了:类型用平常的叫法,有基础模型的分类条,版本和名字分开写,同一模型的几个版本合成一张卡,点版本就能换。
|
||||
- Picking models for the AI works like the Models page: types by their usual names, base model chips, the version apart from the name, and a model's versions on one card, changed from its version.
|
||||
- 工作流页多了“官方示例”:文生图、两段放大、图生图、局部重绘,打开时自动选一个你已有的模型。两段放大有 SDXL / SD 1.5、Anima 和推荐的 Anima · Tile 锁图三个版本,缺节点包或模型的版本会写明缺什么。新装的人和 AI 都有现成路线可用;AI 没有合适的已存工作流时会用它们。
|
||||
- The Workflows page has "Official examples": text to picture, two passes, picture to picture and repainting an area, each opening on a model you already have. Two passes comes for SDXL / SD 1.5, for Anima, and as the recommended Anima held by Tile; a version says what pack or model it lacks. New installs and the AI have routes to start from; the AI gets them when no saved workflow fits.
|
||||
- 可以自己选做法了:在“做法”里点“这次用它”,只对当前任务生效。AI 会收到,这一类活 AMB 直接按你选的搭,AI 想用别的也不行。
|
||||
- You can pick the way yourself: "Use it this time" in Ways, for the current task only. The AI is told, and Anomalous builds your pick for that kind of job, whichever one the AI asks for.
|
||||
- 点“做法”马上出来,不再每次重画:打开 AI 工作台时就提前读好,再点时停在你上次看的位置,展开的详情也还在;开关一个做法也不会跳回顶部。
|
||||
- Ways opens at once and no longer draws itself twice: it is read when the AI workbench opens, and comes back where you left it, details still open; turning a way on or off no longer jumps to the top.
|
||||
- 新任务的页面清爽了:只留一句发给 AI 的话和一个复制按钮。要先交代的话和图都放下面的留言框,不再有重复的两行。连过 AI 以后也不再看到“第一次用?”;从没连过时,先告诉你怎么连。
|
||||
- The new task page is plain now: one sentence for the AI and a Copy button. What to say first and pictures go in the message box below, with no second place for them. Once an AI has connected, "First time?" is gone; until then, how to connect comes first.
|
||||
- AI 工作台的任务列表:一条线上的任务排在一起,后续任务缩进挂在最早的任务下面,一眼看出谁接着谁。每个任务行上多了 ⋯,改名、隐私模式、从列表移除不用先打开任务。移除中间的任务时,线不会断。
|
||||
- The AI workbench's task list keeps a line of tasks together: follow-ups hang under the first task, so you see what goes on from what. Each task row has a ⋯ for rename, privacy mode and remove, without opening the task first. Removing a task in the middle does not break the line.
|
||||
- 存档条和存档页上的按钮按一下就管用:刚在提示词框里打完字、刚改完次数,或者运行还在跑的时候,第一下不再被吃掉;运行途中正在输入的次数也不会被清掉。改完次数马上点运行,也一定按新的次数跑。次数没存上时会提示,框里退回实际的次数。按住存档页的图时刚好出了新图,这一下也算数,新图等松手再排进来。
|
||||
- The controls of the saves bar and the Saves page answer the first click: right after typing in a prompt box or changing a count, or while runs go, the click is no longer lost, and a count being typed stays. Run right after changing a count goes by the new count. A count that could not be saved says so, and the box goes back to the real one. A press on a picture on the Saves page counts even if a new picture arrives during it; the new one joins after you let go.
|
||||
- **插入 LoRA 可以一次选多个**:选择窗口里点卡片加入,再点一下取消,卡片上的数字就是接上去的顺序,线自动连好,Ctrl+Z 一次全部退回。默认用 ComfyUI 自带的节点串成一串;装了 rgthree 或 Easy-Use 时可以改放进一个 Power Lora Loader 或 LoRA 堆叠节点,会记住上次的选择。
|
||||
- 在采样器前面插入 LoRA 时,如果前面已经有一个 LoRA,新的 LoRA 也能接上 CLIP 线了。
|
||||
- **Insert LoRA takes several at a time**: in the picker, click cards to add them and again to drop one; the number on a card is the order they go in. They are wired for you, and one Ctrl+Z takes them all out. They chain as ComfyUI's own nodes by default; with rgthree or Easy-Use installed they can go in one Power Lora Loader or LoRA stack, and the choice is remembered.
|
||||
- A LoRA inserted before a sampler now gets the CLIP line even when another LoRA already stands there.
|
||||
- **操作手感更顺了**:打开和关闭窗口、切换页面都有短暂的过渡,左边栏的高亮会滑到新页面,切换侧边停靠和全屏时窗口平滑过去,不再每一帧重排页面。系统开了“减少动态效果”时这些都关掉。
|
||||
- **Moving around feels smoother**: the window and its pages come and go with a short transition, the rail's highlight slides to the new page, and going between docked, floating and full window glides there instead of laying the page out on every frame. All of it stays off when the system asks for reduced motion.
|
||||
- **侧边停靠的宽度可以拖了**:按住窗口右边缘左右拖,最窄 480,最宽给画布留 240,宽度会记住,双击边缘恢复原来的 660。窗口变小时也会给画布留位置。各页面都按 480 宽走过一遍:模型页的关闭按钮不再被挤出去,工作流的搜索框放不下时换到第二行,图库拖宽后自动加列。
|
||||
- 窗口窄时提示词页不再只占上半截,词卡和提示词框上下排好。
|
||||
- **The docked window's width can be dragged**: hold its right edge and drag, from 480 up to leaving 240 of the canvas. The width is remembered, and a double-click on the edge returns to the usual 660. A smaller browser window leaves room for the canvas too. Every page was gone through at 480: the Models page keeps its close button, the Workflows search moves to a line of its own when there is no room, and the gallery adds columns as it gets wider.
|
||||
- In a narrow window the Prompts page no longer fills only its top half: the cards and the prompt boxes stack.
|
||||
- **工作流卡的“参数方案”改叫“预设”**,详情页也重新排了:左边那一栏去掉,从上往下是 **流程图**、一排 **预设标签**、选中那个预设的内容。流程图只画结构,格子都很小:最下面一排按出图顺序画出每一段(出底图、接着画、放大重画),后面接 ColorMatch 这类后处理和保存;上面几排是底模、LoRA 和 Tile 锁图这类控制模型,每个横跨在它管的那几段上面,一眼看出哪个模型给哪几段用。这次不跑的一段画成虚线,写着“不跑”;没用上的模型也是虚线。步数、强度这些数字放在鼠标停上去的提示里;点一格,图下面列出这个节点的全部参数,改了的写成“默认 → 这个预设”,虚线的那格会告诉你哪个预设会用上它,点一下就切过去,× 收起。图下面的标签:默认参数、每个预设(写着和默认有几处不同)、“+ 新预设”(把当前画布存成新预设,或照着正在看的这个新建),最右边是全部出图和版本;选中的标签连着下面的内容,往下翻时这排标签一直停在顶上。选一个预设,它和默认参数不一样的格子在图上标成 **橙色**(只是不跑的那段换了模型不算改动)。“交给 AI”挪到了预设旁边,交的就是你正看着的那个预设。AI 也能用预设了:`list_workflows` 会列出每张卡的预设,写成“工作流名 / 预设名”;`run_workflow` 带上它,AMB 先打开工作流,再像点“应用到当前画布”一样套上那个预设,然后填题材、种子出图。AI 只要选哪个预设,改数值的事由 AMB 做。
|
||||
- **A workflow card's parameter sets are now presets**, and its details are laid out anew: no left column; from the top, the **flow**, a row of **preset tabs**, and the preset chosen. The flow is small and shows only the structure: a bottom row of the stages in the order they run (base picture, goes on, enlarge and redraw), then finishing nodes such as ColorMatch and the saves; above them a row each for the models, the LoRAs and control models such as a Tile LLLite, each a bar over the stages it feeds, so which model works on which stage shows at a glance. A stage these values do not run is dashed and says "not run"; an unused model is dashed too. Steps, strength and the like are in a box's tooltip; a click lists the node's values under the flow, changed ones as "default → this preset", and a dashed box names the presets that use it, one click away (× closes it). The tabs: the default values, each preset (with how many values it changes), "+ New preset" (save the canvas as one, or copy the one shown), and at the end all pictures and versions; the tab chosen joins the panel under it, and the row stays in view as you scroll. Pick a preset and what it changes from the default turns **orange** in the flow (a model swapped only in a stage that does not run is no change). "Hand it to the AI" now sits beside the preset and hands the one shown. AI apps can use presets too: `list_workflows` lists each card's as "workflow / preset"; with that name `run_workflow` opens the workflow, applies the preset as its Apply button does, then fills in the subject and seed. The AI only picks the preset; Anomalous changes the values.
|
||||
- 放大做法从 6 个合成 3 个:**放大**(1.25 倍轻轻重画)、**Tile 锁图放大**、**分块放大**(2 倍)。Tile 锁图放大合并了原来的平铺放大、Anima Tile 放大细化和双模型放大:放大那一段有自己的一份模型,标题是 “Upscale model”,默认和底图同一个模型、LoRA 一起带上(AMB 把画布上的模型和 LoRA 那一串复制一份);想换第二个模型(更稳、换皮肤质感)只要换这一格,LoRA 照样在。Anima 上走你验证过的那条路线:潜空间放大 1.22 倍,Tile LLLite 锁图,降噪 0.85。迭代放大去掉了(要 Impact Pack,和上面几个重叠)。局部重绘对 AI 只算一项,AMB 按你装的节点包挑裁切缝合、LanPaint 或官方节点;AI 也不再看到全部做法的名字,只按用途要。怎么用写在按需 skill **分阶段出图的思路** 里(见下面)。
|
||||
- The upscale ways went from six to three: **Upscale** (1.25× with a light redraw), **Upscale held by Tile** and **Tiled upscale** (2×). Upscale held by Tile takes in the tile upscale, the Anima Tile detail upscale and the second-model upscale: the redraw has its own copy of the model, titled "Upscale model", the base's model with its LoRAs by default (Anomalous copies the canvas's model line); a second model (steadier, other skin or texture) is one swap of that loader, the LoRAs staying. On Anima it is your tested route: a 1.22× latent enlargement, a Tile LLLite holding it, denoise 0.85. The iterative upscale is gone (it needed Impact Pack and overlapped the others). Repaint counts as one for the AI, Anomalous picking crop-and-stitch, LanPaint or the built-in nodes by the packs you have; the AI no longer sees every way's name, only the uses. How to use them is in the on-demand skill **Drawing a picture in stages** (below).
|
||||
- 新增 **半途换模型接着画** 做法:AI 把画布上的采样器拆成两段,前面的步数用原模型定构图,后面换另一个模型画完(默认第 21 步换,共 35 步的 60%)。Anima 上直接用验证过的采样设置(dpmpp_2m_sde_gpu + beta);er_sde 拆成两段在两个 Anima 模型上都出了颗粒,AI 会避开(在一个 SDXL 模型上试过是干净的)。SDXL 上也实际跑通过(Nova Anime XL 换 MiaoMiao Harem v2.0)。**换模型精修** 也能用在 Anima 上了:Anima 只换模型文件,文本编码器和 VAE 沿用画布上的。两个都在 6 GB 显卡上实际出过图。
|
||||
- New way **Switch models mid-way**: the AI splits the canvas's sampler in two, the first steps on the canvas's model for the composition, the rest on another model (by default from step 21, 60% of 35). On Anima it uses the tested sampling (dpmpp_2m_sde_gpu, beta); split er_sde came out grainy on two Anima models, so the AI avoids it there (on one SDXL model it split cleanly). It ran on SDXL too (Nova Anime XL to MiaoMiao Harem v2.0). **Refine with another model** now works on Anima too: only the model file changes, the text encoder and VAE stay the canvas's. Both made pictures on a 6 GB card.
|
||||
- 新的按需 skill **分阶段出图的思路** 取代原来的「Anima 插画·底图与 Tile 细化」:只讲一张图分几步画的几种路子(同一张图前后段换模型;锁住原图放大重画,可以在这一步顺手换成同类的第二个模型,不用放大完再精修一轮;画完换模型接着修,接几个都行),什么时候必须是同一类模型(沿用前一步提示词和 VAE 的都要同类;自带加载器的单文件换模型精修可以跨类,但要按新模型的习惯改写提示词),以及你选的画风 LoRA 要不要一起挂到第二个模型上(想保持画风就挂,想换画风才不挂)。先出底图给你挑时,AI 只跑第一段,你挑中的那张就是第二步用的底图(见下面)。不写具体数值,数值在做法里;你存过的路线卡和搭配优先原样用,你没选的画风和 LoRA 不加。
|
||||
- New on-demand skill **Drawing a picture in stages** replaces "Anima illustration: base and Tile detail": the ways to make one picture in passes (different models for its early and late steps; a locked redraw while enlarging, which may switch to a second model of the same family in that same redraw, with no extra refine pass after it; model after model, as many as wanted), when the models must be of one family (any switch reusing the first stage's prompts and VAE; the single-file refine with a loader of its own may cross families, its prompts rewritten for the new one), and whether your style LoRA goes onto the second model too (kept look: yes; a new look: no). The AI shows you the first pass alone, and the base you pick is the one the second pass grows from (below). No values: those live in the ways. A route card or combo you saved comes first, as it is; no style or LoRA you did not pick.
|
||||
- **修手·先放大再修** 多了一轮 SAM3:放大重画之后,先用 SAM3 按文字找手(一张图最多 8 只),只在贴着手形的轮廓里重画,再用手部检测框补修一遍(参考作者就是这样两轮)。SAM3 的节点是 ComfyUI 自带的,模型(sam3.1,1.7 GB)在做法面板里一点就能下载,下载完核对 SHA-256;没有它时这个做法照旧只跑手部检测那一轮。
|
||||
- **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.
|
||||
- 新增 **存档**:在画布上改任何东西(参数、模型、节点连接),右键节点或画布空白处选“存档当前工作流”,就把整个工作流存成一个存档,画布下方出现存档条,之后按条上的 + 接着存。存档条放在画布区域里,不会盖住底部日志,收起来是贴在画布底边的小把手;条上只放最新的 6 个,更早的在存档页。存档跟着工作流走,切标签就换成那个工作流的;刷新、重启都还在,备份也带上它们。点存档的数字回到那一步,就在当前标签页,Ctrl+Z 能回到回退之前,也可以接着改出新的分支。每个存档设好跑几次,种子选 **随机**(每次换新,不管节点原来是固定还是随机)或 **锁种**(每轮一个种子,所有存档共用,公平对比),按 **运行**:AMB 先让 ComfyUI 检查每个存档,有问题(断线、模型没了)就一次都不排,并说明是哪个存档;没问题就按顺序全部排进队列。跑的是副本,你的画布和上面的种子都不动,可以接着改。条上的 **看图** 打开 **存档页**(“工作流”页顶上的 **存档**):所有存档按画布分块,标签关了、文件删了的也在,默认只展开当前画布。每个存档一张卡,左边是和上一个比改了什么(改了参数还是节点变了),右边是它出的图,放不下的折叠成最后一格 **+N**,点开是这个存档的图库,操作和图库页一样。点图看大图,**查看参数** 打开参数页,左右键在这个存档的图里切换。块顶上的 **运行** 按顺序跑这个画布的存档,再点别的块就排在后面,画布关了也能跑;每个存档也能单独 **运行**,多跑几张,每次换新种子。**收藏为工作流** 把存档存成一张新的工作流卡,之后互不影响;⋯ 里能回到这里、在新标签页打开(画布开着算一条新分支,画布没了这组存档接着用)或删掉(进回收站,图不受影响)。
|
||||
- New **saves**: change anything on the canvas (values, models, how nodes connect), right-click a node or the empty canvas and choose "Save this state", and the whole workflow is kept as a save in a bar under the canvas; the bar's + keeps the next ones. The bar sits in the canvas's area, clear of the logs under it, and folds to a handle on its bottom edge; it holds the newest six, the rest are on the Saves page. Saves go with their workflow: another tab shows its own, and they stay through a reload or a restart and go into backups. A save's number goes back to that state on the same tab, Ctrl+Z returns to where you were, and you can branch from it. Set each save's runs, pick **Random** seeds (new every run, whatever the nodes are set to) or **Locked** (one seed a round, shared by all saves, for a fair comparison), and **Run**: ComfyUI checks every save first, and a broken link or a missing model stops it before anything is queued, naming the save; else all of it is queued in order. Copies run, so your canvas and its seeds stay as they are and you can work on. **Pictures** on the bar opens the **Saves** page (**Saves** at the top of the Workflows page): every save, by canvas, those of closed tabs and deleted files too, only the open canvas's unfolded. Each save is a card, what changed from the save before (values or nodes) on the left, what it made on the right, those that do not fit folded into a last **+N** tile that opens the save's pictures as gallery cards, working as on the Gallery page. A picture opens large, and **View Parameters** opens its parameters, the arrow keys stepping through that save's pictures. **Run** at the top of a part runs that canvas's saves in order, and another part's goes in after it, closed canvases too; a save can also **Run** alone for more pictures, a new seed each. **Keep as a workflow** makes a new workflow card of a save, and the two go their own ways; ⋯ goes back to it, opens it on a new tab (a branch while its canvas is open; with the canvas gone the saves go on there) or removes it (to the Recycle Bin; its pictures stay).
|
||||
- 侧边停靠和窄窗口下,AI 工作台不再是一排看不懂的图标:任务上方是一排带字的标签,时间线、展台、节点结构、决策、素材、成果、做法,一点就切。“在右侧看”这类放不下侧栏时说不通的字也改了。任务列表是从左边盖上来的抽屉,后面露出当前任务,一看就知道是会话记录;按 Esc 先收起它,再按才关窗口。
|
||||
- 提示词页在宽窗口下铺满:左边词卡更宽,正面和负面两个框并排,占满高度,不再是两条细框加一大片空白。工作流的存档页在宽窗口下卡片并排,顶上写着有几个画布、几个存档。
|
||||
- With the browser docked or in a narrow window, the AI workbench no longer hides behind a row of icons: labelled tabs over the task lead to the timeline, Stage, Node map, Decisions, Materials, Results and Ways, and words such as "on the right" went where there is no right side. The task list is a drawer over the task shown, so it reads as the records of conversations; Esc puts it away first, and closes the window after that.
|
||||
- The Prompts page fills a wide window: wider cards on the left, the positive and negative boxes side by side and full height, instead of two thin boxes over empty space. The Saves page puts its cards side by side there, and says how many canvases and saves there are.
|
||||
- **AI 工作台的标题栏清爽了**:固定两行,常用的只留运行状态、改副本和切到副本,改名、隐私模式、接着哪个任务、移除都收进 ⋯;侧栏打开时也不再挤成四五行。时间线上面的阶段和本次成果并成一行,时间线多露出一截。出过错的任务在列表里不再整行标红,AI 试错的那一步也不再是一片红字。**新任务** 一点就开,不再先弹一个输入框。修好“做法”面板里显示成内部代号的标题。
|
||||
- **The AI workbench's task head is tidier**: two lines however wide, keeping the run status, the canvas choice and the way to the copy; rename, privacy mode, what it goes on from and remove are in ⋯, and an open side column no longer squeezes it into four or five lines. The stages and what the task made share one line over the timeline. A task with a failed step is no longer red in the list, and a step the AI got wrong is a mark, not a wall of red. **New task** opens at once, with no box to fill in first. A heading in the Ways panel no longer shows its internal name.
|
||||
- 你给任务挑了两个主模型时,AI 不用自己琢磨怎么分工:AMB 准备好几种用法问你(比如“第一个出底图,第二个用 Tile 放大重画”“第一个画前面,第二个接着画完”“只用第一个一次出图”),你存过同时用这两个模型的卡时,它排第一并标推荐。你选好后,AMB 在新标签页里把整条路线搭好,LoRA 也挂到每个模型上,AI 只写提示词。AI 看模型时还能看到这个模型在你哪些卡里用过、每张卡是什么路线。
|
||||
- 关卡里新增 **新路线**:AI 不用现成的卡和做法、自己往画布上放节点时,默认提醒一次“这条路线还没验证过,跑完看看效果,好的话存成卡”;选“先问我”,每个任务要先等你点头。
|
||||
- With two main models picked for a task, the AI need not work out who does what: Anomalous prepares the ways to use them and asks you ("the first draws the base, the second redraws it enlarged under Tile", "the first draws the early steps, the second finishes", "only the first, in one pass"), a card you saved with both on top and recommended. Once you choose, it builds the whole route on a new tab, the LoRAs on each model, and the AI only writes the prompt. The AI also sees which of your cards use a model and each card's route.
|
||||
- **New routes** in the Gates window: when the AI places nodes itself instead of using a card or a way, it reminds you once that the route is untried: see how the run turns out and save a card if you like it; with "Ask me first" it waits for your OK once a task.
|
||||
- 新增做法 **出图**:空画布也能起步。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 重画。
|
||||
- **Bases to pick from**: for a picture in passes, the AI can run the first pass (the base) alone, a few at once, each with its own seed. They are previews only, nothing goes into the output folder, and the workflow on your canvas stays as it is. When they are done, **Pick a base** opens in the AI workbench; **Take this one** keeps only that one in the output folder (where the card saves its bases) and holds its seed. When the AI then runs the whole card, a card that hands on the finished base (the two-pass upscale) has its second pass read the picture you picked, and the base is not drawn again (on a 6 GB card the run went from about 190 s to about 110 s); the mid-way switch hands on an unfinished picture, so its base is drawn again from the held seed, and when that run saves its base Anomalous compares it with yours and tells you and the AI if they differ. Previews live in ComfyUI's temp folder, emptied when ComfyUI restarts: picked after a restart, the panel tells you the seed to draw it again.
|
||||
|
||||
- 跑完的图下面新增 **效果好 / 不太行** 两个按钮,点了直接告诉 AI(点“不太行”它就换做法、设置或提示词再跑),再点一下收回。**关卡** 里新增 **第三方节点**:选“只用做法里的”,AI 自己加节点时只能用做法里搭好的组合和你给任务挑的,ComfyUI 自带的节点照常能用。等不到回答时设成按推荐的继续,挑图面板里 AI 推荐的那张也会替你选上。
|
||||
- Under a finished run's pictures, **Turned out well / Not quite** tells the AI at once (with "Not quite" it changes the way, a setting or the prompt before running again); a click again takes it back. The **Gates** window has a new **Custom nodes** row: with "Only the ways'" the AI may add only the nodes of the ready-made ways and what you picked for the task; ComfyUI's built-in nodes stay allowed. With "go on with the recommended" for unanswered questions, a picks panel takes the picture the AI recommended too.
|
||||
- 新增 **隐私模式**:任务标题栏的 **🔒** 一点,这个任务的**图和提示词原文**都不会发给 AI。图一张都不给(放大看图、图片信息、跑完的预览、你递给它的图、AI 软件里任务卡片上的图都一样),它只知道出了几张、成没成功;提示词和图片文件名换成 **[P1]**、**[img1]** 这样的代号:画布上、图片信息里、提示词库、搭配、模型的触发词和介绍、你自己改画布的记录里都一样(只有节点的“文字输入”被换掉,模型名、数字、选项照常给,第三方节点也一样)。AI 拿代号去跑图、写提示词、放挑图面板,AMB 再换回原文,所以它照样能干活,你那边看到的也都是原样。要新的提示词时,AI 会放一个 **提示词框**:你在里面写几条提示词、反向提示词,可以从图库选或拖进参考图,原文只存在 AMB 里;框上方还会按画布主模型的大小提醒你(小模型手容易画坏,可以选简单动作或藏手,只是可选)。新增 skill“隐私模式·怎么干活”,隐私任务里的 AI 开工时会被指过去;隐私任务里 AI 只拿得到这一个 skill,**做法** 面板的 skill 一栏也只显示它,关掉隐私后才换回普通的 skill。**关卡** 窗口里设新任务默认开不开;打开、关掉都记在时间线上并告诉 AI。所有工具的结果出门前都过同一道关,测试会在每个工具的结果里埋“暗号”提示词和图片名,确认一个都漏不出去。
|
||||
- New: **privacy mode**. The **🔒** in a task's header keeps that task's **pictures and prompt text** from the AI. No picture goes (a closer look, picture info, a run's previews, pictures you hand over, the task card in the AI app alike): it hears how many came out and whether they did. Prompts and picture file names become tokens like **[P1]** and **[img1]**: on the canvas, in picture info, saved prompts, combos, models' trigger words and descriptions, and your own canvas changes (only the nodes' text inputs are swapped; model names, numbers and choices stay, third-party nodes too). The AI runs, writes prompts and shows pick panels with the tokens and Anomalous puts the texts back, so it still works and you see everything as it is. For new prompts the AI shows a **prompt box**: you write prompts and a negative one and add reference pictures from the gallery or by dropping them; the texts stay in Anomalous, and above the box a word on what suits the canvas's model (a smaller model often gets hands wrong: a simple pose, or hands hidden, as an option). A new skill, "Privacy mode: how to work", is where a private task's AI is pointed; it is the only skill a private task's AI gets, and the only one the **Ways** panel lists for it, the usual skills coming back once privacy mode is off. The **Gates** window sets it for new tasks; turning it on or off is on the timeline and told to the AI. Every tool's result passes one check on its way out, and a test plants canary prompts and picture names in every tool's result to make sure none gets through.
|
||||
- AI 可以一次排一串提示词:跑图时给几条提示词(最多 8 条),AMB 按顺序一条条填进去排队,其余设置(比如同一个种子)每条都一样,算同一次跑图,图都在时间线的同一步里。以前要一条条跑、每次一个来回。哪一条排不上就停在那里,前面排上的照常跑完。
|
||||
- An AI can queue a list of prompts: a run given several prompts (up to 8) fills them in and queues them in order, the other settings (the same seed, say) the same for each, as one run whose pictures share one step of the timeline, instead of a round trip per prompt. One that cannot be queued stops the rest; those queued before it run as usual.
|
||||
- 同一个模型的几个版本放在一起:扫描认出来自同一个 Civitai 模型页的文件,在模型列表里收成一张卡片,卡片右边有一条带数字的箭头,点一下其余版本就从它右边滑出来、并排挨着,同一个模型的卡片顶上是同一道橙色边;最后一张右边的箭头点一下又收回去。展开后每张卡片的版本名旁边有个图钉,点哪个,收起时就显示哪个版本(封面、名字、插入节点都跟着换)。展开过的模型下次打开还是展开的;版本多的话,类型下面那排最右边的 **展开全部版本** 一下全展开,再点一下全收起。只认 Civitai 的模型编号,文件名再像也不会合在一起;没扫描、Civitai 上找不到的模型照旧各自一张。模型详情里名字下面一排列出这里有的几个版本,点一下就过去;再下面的 **Civitai 上的全部版本** 点了才联网,列出这个模型页的每个版本(底模、日期、大小),这里有的可以直接打开,没有的点 **下载** 放到这个模型旁边,下载完按 SHA-256 核对;名字和已有文件重了,就在名字后面加上版本名,不覆盖。
|
||||
- Versions of one model sit together: files a scan found on the same Civitai model page fold into one card in the model list with a numbered arrow on its right edge: a click slides the other versions out beside it, side by side, the cards of one model under one orange top edge, and the arrow on the last folds them back. Open, each card has a pin by its version name: the pinned version is the one the folded card shows (its cover, name and Add node too). A model left open stays open; **Open all versions**, at the right end of the row under the types, opens them all, and again folds them all. Only Civitai's model id groups them, never a similar file name; models not scanned or not on Civitai keep a card each. A model's details list the versions here under its name, one click each, and **All versions on Civitai**, asked only when pressed, lists every version of the model page (base model, date, size): the ones here open, the others **Download** next to this one, checked by their SHA-256; a taken file name gets the version's name added, nothing is replaced.
|
||||
- 新增 **skill**,和做法并列:做法是 AMB 替 AI 搭好的节点,skill 是 AI 遇到某类活时自己去取的建议,不搭节点。第一个是“写提示词·动作适合模型”,按画布主模型的大小给建议:AMB 读模型文件头数出画图部分的参数量(压缩过的 GGUF 也一样,不看文件大小),100 亿参数以上的大模型(Flux.1、Flux.2、千问图像这类)手和复杂动作画得好,AI 照画面需要写,不藏手;小模型(SD1.5、SDXL、Illustrious、Anima 这类)没指定姿势就选画得清楚的动作,手不是重点时可以自然遮挡或构图不露手,但这只是可选,不是规定;认不出模型时两种情况都告诉它。你指定的姿势和你给的提示词照原样。AI 开工时只看到 skill 的名字和一句话,要写提示词时才去取全文,工具说明里只留一句提示,给 AI 的工具清单不变长。AI 工作台右边的**做法**面板最下面列着 skill,每个有开关,关掉的 AI 就拿不到。AI 取了哪个 skill、按哪一档给的建议,时间线上都有一行 **Skill**;之后那次跑图下面也写着“这次跑图前参照了:……”。
|
||||
- New: **skills**, beside the ways. A way is nodes AMB builds for the AI; a skill is advice the AI fetches when a kind of job comes up, and it builds nothing. The first: "Writing prompts: poses the model can draw", by the size of the canvas's main model: AMB counts its drawing model's parameters from the file's header (a compressed GGUF alike; the file size is not the yardstick). A large one, 10 billion or more (Flux.1, Flux.2, Qwen-Image and the like), draws hands and hard poses well, so the AI writes what the picture wants with no hiding; on a smaller one (SD 1.5, SDXL, Illustrious, Anima…), with no pose asked for, it picks one the model draws cleanly, and hands that are not the point may be hidden naturally or framed out, as an option, not a rule; a model it cannot tell gets both cases. Poses you ask for and prompts you give stay as they are. At the start of a task the AI sees only each skill's name and a line, and fetches the advice when it writes a prompt; the tool descriptions only point at it, so the tool list AI apps hold does not grow. The skills are at the bottom of the **Ways** panel on the right of the AI workbench, each with a switch: the AI can't fetch the ones turned off. Each skill the AI fetches is a **Skill** row on the timeline, with the form its advice came in, and the run after it says "Went by before this run: …".
|
||||
|
||||
- 当前节点里的 **⇄ 对比** 只比这个节点了:以前会把整个工作流的参数都列出来。现在只列这个节点存下的值和画布上现在的值哪里不一样(提示词按标签对比),每张参数卡片都能对比。
|
||||
- **⇄ Compare** in the current-node panel now compares that node alone: it used to list the whole workflow's values. It shows how the saved values differ from the node's values on the canvas (prompts tag by tag), on every saved set.
|
||||
- **更换模型** 时弹出的模型卡片和模型页的一样了:封面(视频封面先显示第一帧,指着才播放)、底模角标、版本、好认的名字和文件名。以前大多是空白卡片、只有文件名。同一模型的几个版本仍然各占一张卡,不折叠。
|
||||
- The model cards in **Change model** are now the models page's: the cover (a video's first frame until pointed at), the base model badge, the version, the readable name and the file name. Most used to be blank with the file name only. Versions of one model still get a card each, not folded.
|
||||
- 修正点击穿透:当前节点 →「查看全部」的历史生成图库往下滚以后,点“关闭图库”会点到滚上去的图片。
|
||||
- Fixed a click passing through: in the generated-pictures gallery (current node → View all), once scrolled, “Close gallery” opened the picture scrolled up under it.
|
||||
- 修正 AI 工作台在**侧边栏模式**下标题栏放不下:右上角的放大、停靠、关闭按钮被挤出窗口。现在按 AMB 窗口自己的宽度(不再按整个浏览器的宽度)收起标签文字,只留图标。
|
||||
- Fixed the AI workbench's title bar when **docked**: the maximize, dock and close buttons were pushed out of the window. Its tabs now fold to icons by the width of the Anomalous window, not of the whole browser.
|
||||
- 修正模型页 **整理** 的几处误判:装了 ComfyUI-Anima-LLLite 节点包时,放在 controlnet 里的 Anima LLLite 不再算放错(这个节点包就从 controlnet 读,挪走反而用不了;没装才建议挪到模型补丁);带 LoRA 层的 IP-Adapter(比如 Anima IP-Adapter)不再被认成 LoRA;同一个文件在 controlnet 和 model_patches 各一份、两边各有节点在读时,不再算“重复、白占硬盘”,也不再建议删一份,只在下面写明两处都在用。
|
||||
- 模型类型识别又查了一遍:拿 ComfyUI-Manager 模型清单里 315 个模型的真实文件头逐个核对,修掉了会认错的几种:混元 DiT 整合包被当成 VAE、LBM 打光模型被当成 VAE、SUPIR 被当成 ControlNet、XLabs 的 Flux LoRA 和 IP-Adapter 被当成扩散模型、完整 CLIP(文字+图像)被当成文本编码器、PhotoMaker 被当成 LoRA、只存差值的 LoRA 被当成扩散模型、去掉 VAE 的整合包被当成扩散模型。两种加载器从不同文件夹读的文件(SUPIR、完整 CLIP)只标“不确定”,放哪边都不报放错。只有模型主体的文件放在 checkpoints 里也不再算放错:ComfyUI 的大模型加载器会把它当扩散模型读进来。新认得:diffusers 格式的扩散模型(IC-Light、可图、Lotus)、Stable Cascade、SDXL 的 T2I-Adapter、RCAN / SPAN / 小型 Real-ESRGAN 放大模型。核对后 315 个里 301 个和清单一致,剩下的要么是有意标的“不确定”,要么认不出(LTX 的潜空间放大、Stable Cascade 的 VAE 这类),认不出的照旧不下结论。扫描页和模型卡片上原来写“看起来不是绘图模型”,其实只是认不出,现在改成 **认不出类型**;规则改了以后,存下的识别结果会自动重读一遍。
|
||||
- Model types were checked again, against the real headers of 315 models in ComfyUI-Manager's list, and the layouts it misread are fixed: a Hunyuan-DiT checkpoint and LBM's relighting model taken for VAEs, SUPIR for a ControlNet, XLabs' Flux LoRAs and IP-Adapter for diffusion models, a whole CLIP (text and vision) for a text encoder, PhotoMaker for a LoRA, a LoRA of weight differences and a checkpoint saved without its VAE for diffusion models. Files two loaders read from different folders (SUPIR, a whole CLIP) are only "not sure", never misplaced in either. A lone drawing model in checkpoints is no longer misplaced: ComfyUI's checkpoint loader reads it as a diffusion model. Newly told: diffusers diffusion models (IC-Light, Kolors, Lotus), Stable Cascade, SDXL T2I-Adapters, RCAN / SPAN / compact Real-ESRGAN upscalers. Checked again, 301 of the 315 agree with the list; the rest are "not sure" on purpose or not told (LTX latent upscalers, Stable Cascade's VAEs), which still get no verdict rather than a wrong one. The scan page and model cards said "looks like no drawing model" when it only could not tell: now **Kind not told**. Answers kept from older rules are read again.
|
||||
- The models page's **Tidy** misjudged a few files: an Anima LLLite in controlnet is no longer misplaced while the ComfyUI-Anima-LLLite pack is loaded (it reads them from there; moving one broke it; without the pack it still suggests model patches); an IP-Adapter carrying LoRA layers (the Anima IP-Adapter) is no longer taken for a LoRA; one file in both controlnet and model_patches, each read by a node, is no longer "identical, wasting space" with a copy to delete: a note says both are in use.
|
||||
|
||||
- 跑图前的尺寸提醒放宽一档:6GB 显卡采样到约 420 万像素才提醒(以前 210 万),12GB 约 840 万。今天在 6GB 的笔记本显卡上实测,400 万像素一次约 100 秒,并不吃紧;以前的门槛会让 AI 动不动就问你要不要停。解码大图的提醒不变。
|
||||
- The size warning before a run is less eager: on a 6 GB card from about 4.2 megapixels (2.1 before), 8.4 on 12 GB. A 6 GB laptop card sampled 4 megapixels in about 100 s today; the old line had the AI ask you to stop runs that were fine. The warning for decoding a large picture is as before.
|
||||
|
||||
- 新做法 **修手·先放大再修(备选)**:你递给 AI 一张手画坏的图,AMB 先把整张图放大到约 400 万像素(原图越小放得越大:1280×832 这种放 2 倍,已经很大的图不放,最多 2 倍)轻轻重画一遍,让手变大、模型有地方画手指,再自动找出每只手、放大重画后贴回去。这是照着参考作者的做法,在 6GB 的笔记本显卡上一组组试出来的:只修手不先放大,大约一半能成;先放大 1.5 倍再修,大约 4 张里 3 张能成;放大 2 倍,同样的种子手指更清楚。Anima 模型上有 Anima Turbo LoRA(做法面板里可以下载)时只跑 10 步,1280×832 的图约 100 秒;没有也能用,慢三倍左右。AI 会一次出 4 张,先自己放大看手,把手指数不对、粘在一起、大小不对的去掉,都不行就换种子再来,最多两轮,再让你在只放大手那一块的面板里挑。整张图会有些细小变化(脸、水花),构图不变。实测不如 Codex 用 FLUX-Fill 每只手单独裁一块重画的办法,所以只作备选:AI 手里有那个办法时先用那个。
|
||||
- New way **Fix hands: enlarge, then fix (fallback)**: from a picture with bad hands, Anomalous enlarges the whole picture to about 4 megapixels (the smaller it is, the more: 2× for 1280×832, not at all for a large one, at most 2×) and redraws it lightly, so the hands grow and the model has room for fingers, then finds every hand and redraws it larger. Taken from the reference author's workflows and tried step by step on a 6 GB laptop card: the hand pass alone fixed about half the tries, enlarging 1.5× first about 3 in 4, and 2× gave sharper fingers in the same seeds. On an Anima model with the Anima Turbo LoRA (downloadable in the ways panel) it is 10 steps, about 100 s for 1280×832; without it, about three times slower. The AI makes 4 tries, checks the hands itself first, drops wrong finger counts, fused fingers and wrong sizes, tries new seeds at most twice more, and lets you pick in a panel zoomed on the hand. Small things change over the picture (faces, splashes); the layout stays. In the user's tests it did worse than Codex's FLUX-Fill redraw of one crop per hand, so it is a fallback: the AI uses that one when it has it.
|
||||
|
||||
- **修一处(比如一只手)更容易成**:从 Codex 这几天修手的试验看,重画的力度太大时,新画的手会撑满你圈的范围,于是手越修越大。现在“局部重绘”默认力度降到修东西用的 0.45(官方节点那块 0.5),AI 也会被告知:圈得贴着手、保持低力度、一次出 4 张,再用一个**只放大手那一块**的挑选面板给你挑,挑完用同样放大的前后对比给你看。展台上的“挑一张”和前后对比滑块都能只显示每张图的同一小块。
|
||||
- **Fixing one spot (a hand) works more often**: Codex's hand tests showed that with a strong repaint the new hand fills the whole marked area and grows each time. The repaint block's strength is now 0.45 by default, a fix (0.5 for the built-in one), and the AI is told to mark tightly, keep it low, make 4 tries and let you pick in a picks panel **zoomed on the hand**, then show before and after zoomed the same. The stage's picks and slider can show the same small part of every picture.
|
||||
|
||||
- **AI 交给“加载图像”的图不再被误报“没选图片”**:AI 拿输出文件夹里的图去重画时,AMB 以前在节点里写“名字 [output]”,ComfyUI 重启、运行记录清空后,新版前端会把它标成“缺少图片”,其实图还在。现在和图库右键“放到画布”一样,先把图复制一份进 input 文件夹(同一张图只存一份,透明部分保留)再填进去。ComfyUI 页面上标红的“缺少图片/视频”,AI 用 get_situation 看现状时也能看到,并知道怎么补。
|
||||
- **Pictures the AI gives a Load Image are no longer flagged missing**: for an output picture Anomalous used to write "name [output]", which ComfyUI's newer page marks as a missing file once its run history is gone (after a restart), though the file is there. Now it is copied into the input folder first (one copy per picture, transparency kept), as the gallery's "Put on the canvas" does. Files ComfyUI's page marks missing also reach the AI in get_situation, with how to fix them.
|
||||
|
||||
- **让 AI 一次出几张时,每张都不一样**:以前种子设成“固定”时,AI 说一次出 4 张,ComfyUI 会把 4 次当成同一个任务,只出 1 张。现在一次出几张时,固定的种子会一张一张往上加(123、124、125…),出完画布上的种子还是原来的数、还是“固定”。修手、修脸这种看运气的活,可以一次出 4 张让你挑。
|
||||
- **A batch of tries from the AI gives different pictures**: with a seed set to "fixed", asking for 4 used to queue the same prompt 4 times and ComfyUI made 1 picture. Now a fixed seed counts up for the batch (123, 124, 125…) and is as it was afterwards, still fixed. Hands and faces, which depend on luck, can come 4 at a time for you to pick from.
|
||||
|
||||
- 跑图前的尺寸提醒多算了一步:放大模型之后**最后要存多大**。要存出超过约 6400 万像素的图(比如 5120×3328 再放大 4 倍,存出来 20480×13312、PNG 约 300 MB),时间线上会出现黄色提醒,AI 也会收到,建议放大倍数小一点或者从小一点的图开始放大。
|
||||
- The size check before a run now also counts **what is finally saved** after an upscale model: a picture past about 64 megapixels (5120×3328 upscaled 4x is 20480×13312, about 300 MB as PNG) gets a yellow warning on the timeline, and the AI is told, with the advice to upscale less or start from a smaller picture.
|
||||
|
||||
- **超大图不再卡住图库**:几亿像素的图(比如把大图再放大 4 倍,存出来几百 MB),以前缩略图做不出来时,AMB 会把整张原图当缩略图发给浏览器,图库和 AI 工作台一翻到它就卡。现在缩略图在后台单独的进程里做一次就存下来(2.7 亿像素的图约 6 秒),不占 ComfyUI 出图的内存;大到连这样也做不了的,卡片上显示尺寸和大小,不再加载原图;截不出第一帧的视频(文件坏了、格式解不开)也一样,卡片显示文件大小,不再把整个视频发给浏览器。卡片角上标“超大图 · 324 MB”。点开看大图、图片详情、AI 工作台里放大看的,都是缩到 4096 像素的预览,下面写着原图多大,要看原图点“打开原图”(在浏览器新标签页里打开)。图片详情读参数也不再把整张图下载下来。
|
||||
- **Very large pictures no longer freeze the gallery**: for a picture of hundreds of megapixels (a big one upscaled 4x, hundreds of MB), a card image that could not be made used to be the whole original, sent to the browser every time the gallery or the AI workbench showed it. Now it is made once in a separate background process and kept (about 6 s for 272 megapixels), outside ComfyUI's own memory; one too large even for that shows its size instead, never the original, and so does a video whose first frame cannot be read (a broken file, no decoder). Its card says "Very large · 324 MB". The large view, the image details and the AI workbench's larger pictures show a 4096 px preview with a note saying how large the original is, and "Open the original" opens it in a new browser tab. Reading a picture's settings in its details no longer downloads the whole file.
|
||||
|
||||
- 做法按用途合并成 7 个:局部重画、换背景、放大、换模型精修、照着画、修脸修手、图片处理。AI 只说"用途 + 想要哪种"(比如"放大,2x"、"照着画,姿势"),AMB 按你装了什么、画布上是什么模型,自动挑能跑的、效果最好的那一块;没装的照样按节点包列出,AI 能告诉你该装什么。开新任务时 AI 只看到 7 行,做法再多也不会变长。
|
||||
- The ways are grouped into 7 uses: repaint, new background, upscale, redraw with another model, follow a picture, faces and hands, picture work. The AI names a use and the kind it wants ("upscale, 2x", "follow, pose") and Anomalous picks the best block this ComfyUI can run, by what is installed and the canvas's model; the missing ones are still listed by pack, so the AI can say what to install. A new task's directory is 7 lines, however many ways there are.
|
||||
|
||||
- 新做法 **换画风·构图不变**:你递给 AI 一张图,AMB 读出它的轮廓线(ComfyUI 自带的 Canny),用 Union 控制网钉住构图,再按提示词里的新画风整张重画:人物、姿势、位置不变,画法换掉(比如水彩、厚涂、平涂)。实测强度 0.8 只是变亮一点,1.0 才真正换了画风,轮廓线照样守住构图,所以默认 1.0。RTX 4050 6GB、SDXL 832×1216 约 41 秒。
|
||||
- New way **New style, same layout**: from a picture handed to the AI, its outlines (ComfyUI's own Canny) hold the layout through the union ControlNet while the whole picture is redrawn in the prompt's new style (watercolour, painterly, flat colours): who, pose and place stay. Tried: at denoise 0.8 it only got lighter, at 1.0 the style changed and the outlines still held the layout, so 1.0 it is. About 41 s for SDXL 832×1216 on an RTX 4050 6 GB.
|
||||
|
||||
- 新做法 **换模型精修**:出完图以后,换另一个单文件模型(SD1.5、SDXL 各家族之间都能换,配它自己的 LoRA;Anima 这种分三个文件的还不行)把整张图轻轻重画一遍(强度 0.4),另存一张(文件名以 refined 开头)。一个模型的构图加上另一个模型的画风和细节。新模型有自己的提示词节点,先照抄原来的提示词。实测 RTX 4050 6GB、Illustrious 出图 → NoobAI 精修,832×1216 整次约 66 秒。
|
||||
- New way **Refine with another model**: after a picture is made, another single-file checkpoint (any SD 1.5 or SDXL family, with its own LoRAs; not yet a three-file model like Anima) redraws it lightly (0.4) and saves it beside it (prefix refined): one model's composition with another's style and detail. The new model has prompts of its own, copied from the first. About 66 s for a whole run, Illustrious then NoobAI at 832×1216, on an RTX 4050 6 GB.
|
||||
|
||||
- 新做法 **放进另一张图**:你递给 AI 两张图,一张有人物、一张是背景。AMB 抠出人物,把背景缩放裁切成同样大小,贴上人物,再把人物以外的地方轻轻重画一遍(强度 0.5),让脚下的影子、地面和光线接上,背景的样子基本不变。实测 RTX 4050 6GB、SDXL 832×1216 约 40 秒。做法现在可以接第二张图了(`add_nodes` 的 `scene`)。
|
||||
- New way **Put a character into a picture**: from two pictures handed to the AI, a character and a place, the character is cut out, the place scaled and cropped to its size, the character pasted on, and everything else lightly redrawn (0.5) so shadow, ground and light meet it while the place keeps its look. About 40 s for SDXL 832×1216 on an RTX 4050 6 GB. A block can take a second picture now (`add_nodes`' `scene`).
|
||||
|
||||
- 新做法 **换场景·人物不变**:你递给 AI 一张图,AMB 自动抠出人物(inspyrenet-rembg),只重画人物以外的地方,AI 在提示词里写新场景;人物边缘留 6 像素一起重画,接缝更自然。实测 RTX 4050 6GB、SDXL 832×1216 约 30 秒。人物身上的光还是原图的光,换到光线差别很大的场景时看得出来(以后可以加重新打光)。AI 递给做法的图也认 `名字.png [output]` 这种写法了。
|
||||
- New way **New scene, same character**: from a picture handed to the AI, the character is cut out (inspyrenet-rembg) and only the rest is drawn anew from the prompt's new scene; 6 px of its edge are redrawn too for the seam. About 30 s for SDXL 832×1216 on an RTX 4050 6 GB. The character keeps the old picture's light, which shows in a very differently lit scene (relighting may come later). A picture handed to a block may also be written `name.png [output]`.
|
||||
|
||||
- AI 有了现成的做法可以照着用(参考做法):从擅长这些活的工作流里提炼出来的几块节点,AI 说一声用哪块,AMB 就在画布上搭好、接进你的工作流里,一次调用,不用 AI 一个个节点地搭。AI 开工时会收到这份清单,每块一行。
|
||||
- **局部重绘·裁切缝合**:把圈出的地方连同周围一圈一起裁出来,放大到 1024 重画,再缝回原图,别处一点不动。AMB 顺手把采样强度调到 0.45(修东西的力度:再高,新画的东西会撑满你圈的范围,手会变大),并提醒 AI 圈得贴着要修的东西、把提示词改成只写这块地方(比如“一只手,五根手指”),不然它会把整张图的内容(比如一整个人)画进这一小块里;手和脸一次出 4 张让你挑。需要节点包 ComfyUI-Inpaint-CropAndStitch。
|
||||
- **局部重绘(官方节点)**:没装上面那个包时用它,只用 ComfyUI 自带的节点。
|
||||
- **部位细化(脸和手)**:出完图以后找出脸和手,各自放大重画再贴回去,细化后的图另存一张(文件名以 detailed 开头)。需要 Impact Pack 和 Impact Subpack,以及 models/ultralytics/bbox 里的检测模型。
|
||||
- **局部重绘·LanPaint**:把采样器整个换成 LanPaint 的(步数、CFG、种子照搬原来的),它每一步都多想几遍,新画的部分和周围融合得最好,任何模型都能用,不用专门的重绘模型;代价是慢好几倍。需要节点包 LanPaint。
|
||||
- **放大**:用放大模型放大到 1.25 倍,再用同样的设置轻轻重画一遍(强度 0.35),细节是真画出来的而不是糊的,另存一张(文件名以 upscaled 开头)。只用 ComfyUI 自带的节点,自动挑你装的放大模型。参考工作流作者也只用 1.25 倍,6GB 显存的卡上 1.5 倍就太吃力了。
|
||||
- **分块放大**:放大 2 倍,按 1024 的小块逐块重画,多大的图都放得下,比上一个慢。需要节点包 ComfyUI_UltimateSDUpscale。
|
||||
- **照着姿势画**、**照着构图画**:你递给 AI 一张图,AMB 读出它的姿势(OpenPose)或深度(Depth Anything V2),用控制网引导出图。控制网模型由 AMB 按画布上底模的类型自动挑(SDXL 的配 SDXL 的,同底模的优先),参考图自动复制一份到输入文件夹(只新建,不覆盖)。需要节点包 comfyui_controlnet_aux。
|
||||
- **迭代放大**:分 3 小步放大到 1.3 倍,每步轻轻重画,比一步到位更稳。用 Impact Pack。
|
||||
- **双模型放大**:和"放大"一样,但重画换一个更稳的模型(底模放大时容易走样就用它);新加的模型加载器先是同一个模型,AI 按你的意思换。只用自带节点。
|
||||
- **背景移除**:把一张图的背景抠掉,存成透明 PNG(文件名以 nobg 开头)。需要节点包 comfyui-inspyrenet-rembg。
|
||||
- **读图出标签**:你递给 AI 一张图,WD14 打标器读出它的 Danbooru 标签,AI 照着写提示词。需要节点包 ComfyUI-WD14-Tagger,第一次用时会下载打标模型(约 1.2GB)。AI 运行后,节点输出的文字(比如这些标签)现在也会带回给它。
|
||||
- 装了 KJNodes 时,几种放大都会在最后把颜色对齐到原图(参考工作流的做法),防止放大后颜色跑偏;没装也照常能用,面板上写着"装了 KJNodes 会更好"。
|
||||
- 照着姿势画改用 DWPose 读姿势:二次元人物也认得准,连脸和手指都标(原来的 OpenPose 认二次元常认错)。人物挤成一团、画风很浓的图可能认不出,读出的骨架图或深度图会作为预览给 AI 看,空的它就知道换照着构图画。第一次用时会下载 DWPose 的模型(约 350MB)。
|
||||
- **平铺放大**:放大到 1.25 倍,重画时用平铺控制网把画面钉住(强度 0.6,到 80% 的步数,参考作者的参数),所以重画强度可以调到 0.5,细节多得多,构图也不跑。SDXL 用 Union 控制网,NoobAI/Illustrious 优先用 NoobAI 的平铺控制网,Anima 用 Anima 的修复 LLLite(较慢)。
|
||||
- **线稿上色**:你递给 AI 一张黑白线稿,AMB 把线条交给线稿控制网(Union 的线稿模式),AI 在提示词里写颜色和画风,按线稿画出彩色图。
|
||||
- Union(万能)控制网用之前,AMB 会告诉它这次是姿势、深度、平铺还是线稿。还没扫描过的新控制网按它所在的文件夹(SDXL_1.0、NoobAI……)认底模。做法说缺某个模型时,AMB 先把 ComfyUI 的文件列表刷新一次再找:刚下载到子文件夹里的模型,ComfyUI 自己不重启是看不见的。
|
||||
- 放大类的块优先用"够用的最小倍数"的放大模型(放大到 1.25 倍用 2 倍模型,比 4 倍模型再缩回来省事)。
|
||||
- 做法会按画布上的底模换搭法:画布上是 Anima 模型时,照着构图画、照着姿势画改用 Anima 专用的 LLLite 控制网,局部重绘·裁切缝合会加上 Anima 的重绘 LLLite。AI 照样只说用哪块。需要节点包 ComfyUI-Anima-LLLite 和 models/controlnet 里的 anima-lllite 模型。Anima 的 LLLite 比 SDXL 的控制网温和,提示词要和参考图说的一致(几个人、在干什么),AI 会被这样提醒。
|
||||
- 画布上有好几个采样器时,AI 要指明用哪一个,AMB 不乱猜。
|
||||
- AI 工作台右边多了**做法**面板:按"要做的事"分组列出所有做法,每块写着能干什么、从哪学来的。缺节点包或缺模型的变灰,写明缺什么,点缺的节点包会打开 ComfyUI-Manager 去搜它。每块有个开关:关掉的,AI 就看不到,硬要用也会被拒绝。AI 开工时只收到开着又能用的做法,其余的只附一行"装了什么还能用什么"。
|
||||
- 做法面板上每块写着怎么测过的:在哪张卡上(目前都是 RTX 4050 笔记本,6GB 显存)、多大的图、整次运行用了多久;参数也是按这张卡定的(比如放大只到 1.25 倍)。AI 用某块时也会收到这句,能提前告诉你大概要等多久。
|
||||
- AMB 维护一份做法用到的模型清单(api/block_models.json):每个模型的下载链接(HuggingFace、Civitai、GitHub)、大小、SHA256、许可证和该放的文件夹。每块做法列出它推荐的模型,面板上已有的打勾(改过名的也认得出),缺的点一下就用 AMB 的下载器下载,下完核对哈希。把做法分享给别人时,对方也能照着这份清单拿到一模一样的文件。
|
||||
- 线稿上色在 Anima 模型上改用 Anima 的线稿 LLLite。
|
||||
- 每块做法现在是一个单独的数据文件(api/blocks/),里面有它的节点、要的节点包和模型、给 AI 的话、面板上的中英文说明。以后收集到新做法,加一个文件就行。
|
||||
- AI has ready-made ways of doing things (reference blocks): a few groups of nodes distilled from workflows that do one job well; the AI names one and Anomalous builds it on the canvas, spliced into your workflow, in one call instead of node by node. The AI gets their list, a line each, when it begins a task.
|
||||
- **Repaint, crop and stitch**: crops the marked area with a margin around it, repaints it at 1024 and stitches it back, the rest untouched. Anomalous sets the sampler's denoise to 0.45 (a fix: higher, the new thing fills the whole mark and a hand comes out too big) and tells the AI to mark tightly, write the prompt for that area only ("a hand, five fingers"), or it paints the whole picture's subject (a whole person) into it, and to make 4 of a hand or face for you to pick. Needs ComfyUI-Inpaint-CropAndStitch.
|
||||
- **Repaint (built-in nodes)**: for when that pack is not installed, ComfyUI's own nodes only.
|
||||
- **Face and hand detailing**: after a picture is made, finds faces and hands, repaints each larger and puts it back, saving the detailed picture beside it (prefix detailed). Needs Impact Pack and Impact Subpack and detector models in models/ultralytics/bbox.
|
||||
- **Repaint with LanPaint**: the sampler becomes LanPaint's (same steps, cfg and seed), which thinks several times a step, so the new part blends in best, with any model and no inpainting model; several times slower. Needs LanPaint.
|
||||
- **Upscale**: an upscale model enlarges the picture to 1.25×, then a light redraw with the same settings (denoise 0.35) makes the new detail real rather than smeared; saved beside it (prefix upscaled). ComfyUI's own nodes, with an upscale model you have. 1.25× is what the source workflow used; 1.5× is too much for a 6 GB card.
|
||||
- **Tiled upscale**: 2×, redrawn in 1024 tiles, so any size fits; slower. Needs ComfyUI_UltimateSDUpscale.
|
||||
- **Follow a pose**, **follow a layout**: from a picture you hand the AI, Anomalous reads the pose (OpenPose) or depth (Depth Anything V2) and steers the picture with a ControlNet it picks to fit the canvas's model (SDXL with SDXL, the very same base first); the picture is copied into the input folder, never over a file. Needs comfyui_controlnet_aux.
|
||||
- **Iterative upscale**: 1.3× in 3 small steps, a light redraw each, steadier than one jump. Uses Impact Pack.
|
||||
- **Upscale with a second model**: like Upscale, but a second, steadier model redraws it (for a base model that goes wrong enlarged); its new loader starts as the same model and the AI changes it as you say. Built-in nodes only.
|
||||
- **Remove the background**: saves a picture with its background cut away as a transparent PNG (prefix nobg). Needs comfyui-inspyrenet-rembg.
|
||||
- **Tags from a picture**: from a picture you hand the AI, the WD14 tagger reads its Danbooru tags for the AI to write the prompt from. Needs ComfyUI-WD14-Tagger; the first time it downloads its model (about 1.2 GB). What a run's nodes show as words (these tags) now reaches the AI with the run.
|
||||
- With KJNodes, the upscales match their colours to the original at the end (as the source workflow does), so they don't drift; without it they work as before and the panel says they're better with KJNodes.
|
||||
- Follow a pose reads the pose with DWPose: anime characters too, face and fingers included (OpenPose often misread them). A crowded or very stylised picture may come out empty; the pose skeleton or depth map is left as a preview for the AI, which then turns to Follow a layout. The first time, DWPose downloads its models (about 350 MB).
|
||||
- **Upscale held by tile**: 1.25×, a tile ControlNet holding the picture while it is redrawn (strength 0.6 to 80% of the steps, the source's), so the redraw can be stronger (0.5): much more detail, the layout kept. SDXL uses the union ControlNet, NoobAI and Illustrious NoobAI's tile one first, Anima its tile-repair LLLite (slower).
|
||||
- **Colour a line drawing**: from a black-on-white drawing you hand the AI, its lines steer a lineart ControlNet (the union one's lineart mode) while the prompt says the colours and style.
|
||||
- A union ControlNet is told what it follows (pose, depth, tile or lineart). A ControlNet not scanned yet goes by the folder it is in (SDXL_1.0, NoobAI…). When a block lacks a model, Anomalous reads ComfyUI's file lists anew once before saying so: ComfyUI does not see a model just downloaded into a subfolder until it restarts.
|
||||
- The upscales take the least enlarging upscale model that reaches their size (a 2× one for 1.25×, less work than a 4× one scaled back down).
|
||||
- A way is built for the canvas's model: on an Anima model, Follow a layout and Follow a pose use Anima's ControlNet-LLLite, and Repaint: crop and stitch adds Anima's repaint LLLite; the AI still only names the way. Needs ComfyUI-Anima-LLLite and anima-lllite models in models/controlnet. Anima's LLLite steers more gently than an SDXL ControlNet, so the prompt has to agree with the picture, and the AI is told so.
|
||||
- With several samplers on the canvas the AI has to say which one; nothing is guessed.
|
||||
- A **Ways** panel on the right of the AI workbench lists every way by job, what it does and where it came from. Ways missing a node pack or a model are greyed with what they lack; a missing pack opens ComfyUI-Manager searched for it. Each has a switch: one turned off is not offered to the AI, and building it is refused. When it begins, the AI gets only the ways that are on and can run here, with one line on what installing which pack would add.
|
||||
- The Ways panel says how each way was tried: the card (so far an RTX 4050 laptop, 6 GB), the size, a whole run's time; its settings are for that card (the upscale stops at 1.25×). The AI hears it too when it uses one, so it can say how long to wait.
|
||||
- Anomalous keeps a list of the models the ways use (api/block_models.json): each one's links (Hugging Face, Civitai, GitHub), size, SHA256, licence and folder. Each way names the models it recommends; the panel ticks the ones here (renamed ones too) and downloads a missing one with Anomalous's downloader in a click, checking its hash. Someone given a way gets the very same files from the list.
|
||||
- Colouring a line drawing uses Anima's lineart LLLite on an Anima model.
|
||||
- Each way is now a data file of its own (api/blocks/): its nodes, the packs and models it needs, what the AI is told and the panel's words in both languages, so a way collected later is one more file.
|
||||
|
||||
## v1.62.4 (Fix: opened at the computer's LAN address, changes were refused) — 2026-11-08
|
||||
|
||||
- ComfyUI 只监听局域网地址(比如 `--listen 192.168.1.5`)时,在运行 ComfyUI 的这台电脑上用这个地址打开,现在也算本机:扫描、改设置、填代理都能用了。以前它被当成别的设备,只能浏览。
|
||||
- 设置页的代理和“测试连接”从别的设备打开时会说清原因:只能在运行 ComfyUI 的电脑上改。以前代理总说“格式写错了”,测试说“连不上 Civitai”,其实格式和网络都没问题。被拒绝的提示也会指向窗口顶上的“仅浏览”:点开有从别的设备也能用的办法(服务器上的 `allow_remote_changes` 文件)。
|
||||
- With ComfyUI listening on its LAN address only (say `--listen 192.168.1.5`), opening it at that address on the computer it runs on now counts as this computer: scans, settings and the proxy work. Before, it was taken for another device and could only look.
|
||||
- Opened from another device, the settings page's proxy and Test connection now say why nothing happened: only the computer ComfyUI runs on can do that. Before, the proxy always said the format was wrong and the test said Civitai could not be reached, though neither was the case. Refusals also point to the View only chip at the top of the window, which says how to use the plugin from other devices too (the `allow_remote_changes` file on the server).
|
||||
|
||||
## v1.62.3 (Fix: ComfyUI-Manager took other packs' nodes for AMB's) — 2026-11-05
|
||||
|
||||
- ComfyUI-Manager 不再把别的插件的节点算到 AMB 头上:AMB 没有节点,是测试文件里的假节点表让它误以为有,于是缺 Power Lora Loader 等节点时会推荐装 AMB。Manager 重新扫描后生效。AMB 本身用起来没有变化。
|
||||
- ComfyUI-Manager no longer takes other packs' nodes for AMB's: AMB has no nodes, but node tables in its test files looked like some, so a missing Power Lora Loader and the like could offer AMB. Takes effect once Manager scans again. AMB itself works as before.
|
||||
|
||||
## v1.62.2 (Fix: workflow cards squeezed into strips) — 2026-11-05
|
||||
|
||||
- 工作流很多时,卡片不再被压成细条,列表正常滚动。
|
||||
|
||||
@@ -2,14 +2,14 @@
|
||||
|
||||
# 🚀 Anomalous Model Browser
|
||||
|
||||
**A model browser and creative workspace for ComfyUI**
|
||||
*模型库 · 出图图库 · 工作流 · 搭配 · 提示词 · 角色配音 · 模型检查 · 操作记录*
|
||||
**A model browser, workflow studio and AI workbench for ComfyUI**
|
||||
*模型库 · 出图图库 · 工作流与预设 · 画布存档 · 搭配 · 提示词工坊 · 模型检查 · AI 工作台 · 角色配音 · 操作记录*
|
||||
|
||||
<br/>
|
||||
|
||||
[](https://github.com/ltdrdata/ComfyUI-Manager)
|
||||
[](LICENSE)
|
||||
[](CHANGELOG.md)
|
||||
[](CHANGELOG.md)
|
||||
[](https://www.bilibili.com/video/BV1boHx61Enc/)
|
||||
[](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
[](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
@@ -18,9 +18,11 @@
|
||||
|
||||
[**English**](#english) | [**中文说明**](#中文)
|
||||
|
||||
<sub>Installed but can't find it? <a href="#where-en">Here is where it is</a> · 装好了找不到入口?<a href="#where-zh">看这里</a></sub>
|
||||
|
||||
<br/>
|
||||
|
||||
<img src="docs/images/en-models.webp" width="900" alt="Models with their Civitai covers, base model and real names">
|
||||
<img src="docs/images/en-models.webp" width="900" alt="Model library with Civitai covers, base model badges and multi-version cards">
|
||||
|
||||
</div>
|
||||
|
||||
@@ -28,101 +30,177 @@
|
||||
|
||||
<h2 id="english">🇬🇧 English</h2>
|
||||
|
||||
> **Anomalous Model Browser** takes care of the chores around ComfyUI in one window: browse your models with their Civitai covers and trigger words, look back through your outputs, keep workflows you have tuned, keep model combos and prompts, repair workflows whose models are missing, and, with the companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS), give your characters a voice. An activity log shows everything the plugin changed.
|
||||
> **Anomalous Model Browser** takes care of the chores around ComfyUI in one window: browse your models by their Civitai covers and trigger words, recover workflows from past outputs, keep tuned workflows with visual presets, snapshot canvas saves to compare fairly and jump back to, reuse model + LoRA combos and tag-based prompts, find missing models again (for sure when the workflow carries this plugin's fingerprints), let an AI app run and refine workflows on a copy tab while you steer each step, and—with the optional companion pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)—give your characters a voice. Everything the plugin changes is recorded in **Activity** with **Undo**.
|
||||
|
||||
### 🧭 The idea
|
||||
<!-- guide:idea -->
|
||||
|
||||
**Not another WebUI, not a replacement for ComfyUI: just ComfyUI made easier to use.**
|
||||
|
||||
- **Fewer needless chores.** Telling cryptic model filenames apart, fixing red missing-model nodes, digging out how an old picture was made, rewiring the same Checkpoint + LoRAs every session: the plugin handles these.
|
||||
- **Still your ComfyUI.** The canvas and nodes are ComfyUI's own. The plugin adds **zero custom nodes**: what it places on the canvas are standard ComfyUI nodes (or packs you already installed), so the PNGs and workflows you save open cleanly even on a computer without this plugin.
|
||||
- **A grounded way to use AI with ComfyUI.** You say what you want in plain words; **Anomalous** opens your workflows and wires tested node blocks on a **copy tab**; **your own ComfyUI and local models** draw the pictures. You see every step, pick which preview continues, and undo any canvas change in one click.
|
||||
|
||||
### ✨ Highlights in v1.63
|
||||
|
||||
- **AI Workbench — staged creation instead of blind node-guessing**: run fast first-pass base previews and pick your favorite before spending time on a second-pass upscale, inspect results with a before/after slider, snap in tested **Ways** & model-aware **Skills**, and keep your open tab untouched via **AI edits a copy**.
|
||||
- **Workflows — structure flow diagram, orange preset diffs & Official examples**: every workflow card now draws its stages, models and LoRAs as a compact structure diagram; pick a preset and what it changes lights up in **orange**, or start immediately on a fresh install with **Official examples**.
|
||||
- **Canvas Saves & locked-seed comparison**: save states of a workflow as `1 · 2 · 3` on a bar under the canvas, batch-run them with **Locked** or **Random** seeds, compare exact node/value diffs beside each save's pictures, and click a save number to restore both the graph and that save's output node previews.
|
||||
- **Grouped multi-version model cards & numbered multi-LoRA insertion**: files from the same Civitai model page fold into one card (slide out versions under an orange bar, pin your default, or download other Civitai versions); **Insert LoRA** chains several LoRAs in numbered `1 · 2 · 3` order in one `Ctrl+Z` step; the docked sidebar edge drags smoothly from `480px` wide.
|
||||
|
||||
### 🎬 Videos and discussion
|
||||
* 📺 **Bilibili** (Chinese): [Video demo](https://www.bilibili.com/video/BV1boHx61Enc/) · [Character voices demo](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
* 💬 **Reddit**: [The post on r/comfyui](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
* 📺 **Bilibili** (Chinese): [Feature demo](https://www.bilibili.com/video/BV1boHx61Enc/) · [Character voices demo](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
* 💬 **Reddit**: [Discussion on r/comfyui](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
|
||||
### 💡 What it helps with
|
||||
---
|
||||
|
||||
**1. "Which LoRA was that again?"** A file called `download (3).safetensors` tells you nothing. Press **Scan** once: every model is looked up on Civitai by its file hash and gets its real name, cover, trigger words and base model. From then on you pick models by their covers.
|
||||
### ✨ AI Workbench: You direct in plain words, ComfyUI draws with your models
|
||||
<!-- guide:ai_workbench -->
|
||||
|
||||
<p align="center"><img src="docs/images/en-scan.webp" width="520" alt="Scan results: each file matched on Civitai"></p>
|
||||
Most "AI + image" tools either hide ComfyUI behind a cloud generator or ask an LLM to guess raw node connections from scratch—often breaking your open workflow. **AI Workbench** takes a practical middle route built around your own ComfyUI library:
|
||||
|
||||
**2. Someone else's workflow opens with red nodes.** Model Check says which models are missing. When the workflow carries model fingerprints (the plugin writes them into the workflows and images of everyone who has it, unless they turn it off), the ones you already have under another name or in another folder go back in with one press, and the rest can be downloaded from Civitai, Hugging Face or ComfyUI-Manager's model list, each download checked against its fingerprint (a Manager entry that only shares the file name is marked so, for you to decide). A workflow without fingerprints can only be matched by file name: a file of the same name elsewhere on your computer is offered for you to confirm.
|
||||
- **Your open workflow stays safe.** By default (**AI edits a copy**), the first canvas action duplicates your workflow into a dedicated task tab. Your own tab is never touched, and every step on the timeline has **Undo** and **Find on canvas**.
|
||||
- **Tested building blocks instead of guesswork.** Rather than inventing fragile graphs, the AI opens **your saved workflows**, **any gallery picture's embedded workflow**, or **Official examples**, and extends them with **Ways** tested here (two-pass Tile upscale, mid-way model switch, another model on only the character or only the background, a blurred background enlarged with only the character redrawn, crop-and-stitch or LanPaint area repaint, hand repair, background swap, pose/depth control…) plus on-demand **Skills** (such as matching pose complexity to your model's parameter scale, or planning multi-stage passes). You can also click **Use it this time** on any Way to lock the AI to your preferred method.
|
||||
- **Steer before committing GPU time.** On multi-pass workflows, the AI can run a few fast first-pass **base previews** first; **Pick a base** opens on the right-hand **Stage**, you click **Take this one**, and the second pass sharpens that exact image without redrawing the base. Finished runs appear with a **before/after comparison slider**, **Turned out well / Not quite** feedback buttons, and a `✎` brush tool to mark regions for local repainting.
|
||||
- **Clear boundaries (`Gates`).** You decide what the AI may **Go ahead** with, must **Ask me first** about, or may **Never** do: changing the canvas, using the GPU, downloading models, scanning models online, moving model files. Two more settings cover nodes it places by itself: any custom node pack or only those of the Ways, and whether an untried route of its own waits for your OK (with an optional per-task `🔒` privacy switch when you want prompt text, pictures and model names kept local as `[P1]` / `[img1]` / `[M1]` tokens instead of sent to the AI). Works locally with Claude, Cursor, Antigravity, Cherry Studio, VS Code and other MCP-capable apps—copy one sentence from **Connect an AI** to set it up, or see [docs/guides/mcp.md](docs/guides/mcp.md).
|
||||
|
||||
<p align="center"><img src="docs/images/en-model-check.webp" width="760" alt="Model Check puts back three missing models with one press"></p>
|
||||
<p align="center"><img src="docs/images/en-ai-workbench.webp" width="840" alt="AI Workbench: pick one of three fast first-pass base previews, then inspect the 2nd-pass upscale with a before/after slider and choose the next step"></p>
|
||||
|
||||
**3. "How did I make this picture?"** Search your outputs by prompt words, model or LoRA, then drag the picture onto the canvas: its workflow comes back.
|
||||
---
|
||||
|
||||
<p align="center"><img src="docs/images/en-gallery.webp" width="760" alt="Gallery search, then drag an image onto the canvas"></p>
|
||||
### 💡 Everyday ComfyUI chores it solves (with or without AI)
|
||||
|
||||
**4. The same model, LoRAs and prompt, again and again.** Pick those nodes on the canvas, right-click → **AMB** → **Save as combo**, and they are kept as a card beside the canvas. Next time press **Put on canvas** (or drag the card) and the wired group comes back where you click; nothing already on the canvas changes. Models and prompts in a combo can be changed later.
|
||||
<!-- guide:names -->
|
||||
**1. "Which LoRA was `download (3).safetensors` again?"**
|
||||
Press **Scan** once: every model is matched on Civitai by its SHA-256 file hash and gets its real title, cover, trigger words and base model badge. Multiple versions of the same Civitai model fold neatly into one card—click its numbered side tab to slide all versions out under an orange bar, pin the version you use most, or check and download other versions from Civitai.
|
||||
|
||||
<p align="center"><img src="docs/images/en-combo.webp" width="760" alt="Picked nodes saved as a combo, then put back on the canvas as wired nodes"></p>
|
||||
<p align="center"><img src="docs/images/en-scan.webp" width="560" alt="Scan results matching cryptic local filenames to their real Civitai titles"></p>
|
||||
|
||||
**5. Long prompts are hard to edit.** Select a prompt node and Prompt Studio shows its text as tags: click to weight, drag to reorder, type to add (Chinese and other languages become English).
|
||||
<!-- guide:model_check -->
|
||||
**2. Someone else's workflow opens with red missing-model nodes.**
|
||||
**Model Check** looks as soon as a workflow opens, without changing it. How far it gets depends on what the workflow carries:
|
||||
- **With fingerprints** (its author has this plugin too, which records each model's SHA-256 in the workflows and pictures it saves, unless turned off): the same file on your computer under another name or in another folder is found for certain and goes back in with one press of **Put in**; a model you really lack is looked up on Civitai by its fingerprint and checked against it after downloading.
|
||||
- **Without fingerprints** (its author does not have this plugin): nothing proves which file it was, so only names are left. A file of the same name in another folder is offered for you to confirm, row by row; VAEs, text encoders and other parts that often share a name are never guessed by name.
|
||||
- **Downloads** also use the links a workflow carries (ComfyUI's own templates have them) and ComfyUI-Manager's model list. A file that only has the same name is marked **Download by name** and comes only when you press it.
|
||||
|
||||
<p align="center"><img src="docs/images/en-prompt-studio.webp" width="760" alt="Prompt Studio editing a prompt node as tags"></p>
|
||||
<p align="center"><a href="docs/guides/model-check-rules.en.md"><img src="https://img.shields.io/badge/📖_How_Model_Check_decides-Full_rules-blueviolet?style=for-the-badge" alt="How Model Check decides: the full rules"></a></p>
|
||||
|
||||
**6. Characters that talk.** With the companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): GPT-SoVITS characters with a reference clip per emotion, and a script page to voice them.
|
||||
<p align="center"><img src="docs/images/en-model-check.webp" width="820" alt="Model Check replacing three renamed/moved missing models in one click"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/en-voices.webp" width="760" alt="Character voices page"></p>
|
||||
<!-- guide:workflows -->
|
||||
**3. Keep tuned workflows, see their stages at a glance, and switch presets.**
|
||||
**Official examples** (text-to-image, two-pass upscale, img2img, inpaint) open ready to run on a model you already own. On any saved workflow card, a compact **structure flow diagram** maps out models, LoRAs and control bars across each sampling stage (`① Base picture` → `② Enlarge and redraw`). Click a **preset tab** and every stage or LoRA it changes turns **orange**; click any box in the flow to see `default → preset` parameter diffs right underneath, apply it to the canvas, or export a `.zip` recipe package / short `⇅` share code.
|
||||
|
||||
**Changed your mind?** Everything the plugin changes is listed in **Activity**, with **Undo** and **Find on canvas**.
|
||||
<p align="center"><img src="docs/images/en-workflows.webp" width="840" alt="Workflows page: Official examples, structure flow diagram, orange preset highlights and node diff"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/en-activity.webp" width="620" alt="Activity log with an undo button"></p>
|
||||
<!-- guide:saves -->
|
||||
**4. Try another idea without losing this one — Canvas Saves.**
|
||||
Right-click the canvas → **Save this state** snapshots the whole workflow onto a compact bar under the canvas; tweak anything and press `+` to save again. Run every save in batch with **Random** or **Locked** seeds for a fair side-by-side test. **Saves** (at the top of Workflows) lists what each save changed beside the pictures it made; click `1`, `2` or `3` on the bottom bar to jump back on the same tab—bringing that save's output node previews back too (`Ctrl+Z` returns; **Keep as a workflow** turns a favorite into a workflow card).
|
||||
|
||||
**Light on your computer**: the plugin itself loads no models, so it takes no graphics memory from image generation; the browser shows small thumbnails instead of full images, and its pictures are released a minute and a half after you close it. The voice models of Anomalous_TTS give their graphics memory back whenever ComfyUI needs it for image generation, and all of it on **Unload models**.
|
||||
<p align="center"><img src="docs/images/en-saves.webp" width="840" alt="Canvas Saves: side-by-side diffs with locked-seed renders, and switching saves 1, 2, 3 on the bottom bar"></p>
|
||||
|
||||
<!-- guide:job_sheet -->
|
||||
Many prompts on one canvas instead? Right-click the canvas → **AMB** → **Job sheet (many prompts)**: paste a prompt a line, set **Runs of each** and **Seconds between pictures**, and press **Start**. It runs copies of the canvas as it was at the start, one after another (your canvas stays as it is), with **Pause**, **Go on** and **Stop**; with no prompt it runs the canvas as it is, a rest between pictures. An AI can hand over a sheet too ("these prompts, 4 each"; "cfg 4, 5 and 6, 3 each").
|
||||
|
||||
<!-- guide:picture_workflow -->
|
||||
**5. "How did I make this picture?"**
|
||||
Search your `output` folder by prompt words, checkpoint, LoRA, seed or model hash (even hundred-megapixel upscales stay smooth via background thumbnails), then drag the picture straight onto the canvas to bring its full workflow back.
|
||||
|
||||
<p align="center"><img src="docs/images/en-gallery.webp" width="820" alt="Gallery search by tag chip, then dragging an output image onto the canvas to restore its workflow"></p>
|
||||
|
||||
<!-- guide:combos -->
|
||||
**6. Stop rewiring the same Checkpoint, LoRAs and prompt.**
|
||||
Select those nodes on the canvas, right-click → **AMB** → **Save as combo**, and keep them as a card beside the canvas. Press **Put on canvas** (or drag the card) to lay out the wired node group wherever you click without altering existing nodes. Need several LoRAs on an existing loader? **Current node → Insert LoRA** lets you click multiple LoRA cards in `1 · 2 · 3` order and wires the whole chain (or an rgthree / Easy-Use LoRA stack) in one `Ctrl+Z` step.
|
||||
|
||||
<p align="center"><img src="docs/images/en-combo.webp" width="820" alt="Placing a saved Checkpoint + LoRA + Prompt combo onto the canvas as a wired group"></p>
|
||||
|
||||
<!-- guide:prompts -->
|
||||
**7. Edit long prompts as clickable tags — Prompt Studio.**
|
||||
Dock **Prompt Studio** beside the canvas, select a prompt node, and edit its positive and negative text live as tag pills: click `+` / `−` to adjust weights, double-click to edit, drag to reorder, type in Chinese, Japanese or other languages to insert English tags automatically, and drag saved prompt cards straight onto canvas prompt boxes.
|
||||
|
||||
<p align="center"><img src="docs/images/en-prompt-studio.webp" width="820" alt="Prompt Studio docked beside the canvas, weighting tags live on a selected CLIP Text Encode node"></p>
|
||||
|
||||
<!-- guide:voices -->
|
||||
**8. Characters that talk.**
|
||||
With the optional companion node pack [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): manage GPT-SoVITS characters with a reference clip per emotion, fix pronunciations, and generate dialogue lines from a script page.
|
||||
|
||||
<p align="center"><img src="docs/images/en-voices.webp" width="760" alt="Character voices page with emotion reference clips"></p>
|
||||
|
||||
<!-- guide:undo -->
|
||||
**Changed your mind?**
|
||||
Every change made by Anomalous or an AI task is logged in **Activity** with before → after values, **Find on canvas** and one-click **Undo**.
|
||||
|
||||
<p align="center"><img src="docs/images/en-activity.webp" width="760" alt="Activity log showing expanded node changes with Undo and Find on canvas buttons"></p>
|
||||
|
||||
<!-- guide:light -->
|
||||
**Light on your computer**: the plugin itself loads no AI models and takes **zero VRAM** away from image generation; grids use cached thumbnails rather than full originals, and browser image memory is released 90 seconds after closing the window. Voice models in `Anomalous_TTS` yield VRAM automatically whenever ComfyUI generates images, or completely on **Unload models**.
|
||||
|
||||
### 📦 Install
|
||||
<!-- guide:install -->
|
||||
|
||||
1. In **ComfyUI Manager**, search `Anomalous Model Browser`, install it and restart ComfyUI. Or clone it into `custom_nodes`:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. Press **Shift + B** to open it. On **Home**, **Scan model folders** fills in covers and trigger words, and **Tour the interface** points at each part of the window.
|
||||
3. Optional: for character voices, also install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) (search `Anomalous TTS` in ComfyUI Manager).
|
||||
2. <a id="where-en"></a>**Where to find it**: after the restart a small cube button sits at the top left of the canvas (ringed below). Click it, or press **Shift + B**. On **Home**, **Scan model folders** fetches covers and trigger words, and **Tour the interface** walks through each area of the window.
|
||||
|
||||
Scanning needs to reach Civitai. Without it, scan offline: base models are read from the files, and a later online scan fills in the rest (see **Network and backups** below).
|
||||
<p align="center"><img src="docs/images/en-entry.webp" width="640" alt="The cube button at the canvas's top left, and ComfyUI's menu C, Extensions, Anomalous Model Browser, Open"></p>
|
||||
|
||||
Can't see the cube? It may have been dragged away, or another entry chosen: ComfyUI's **C** menu → **Extensions → Anomalous Model Browser** has **Open Anomalous Model Browser** and **Reset floating entry position**. To make it easier to spot, go to **ComfyUI Settings → Anomalous Model Browser → Interface**: **Floating entry style** → **Labeled pill** adds the words "Model Browser", **Floating entry size** → **Large**, or **Browser entry mode** → **Top action bar** puts it beside the Run button.
|
||||
3. Optional: for character voices, install [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) (`Anomalous TTS` in ComfyUI Manager). Nothing else depends on it.
|
||||
|
||||
Scanning reaches Civitai by file hash (no extra Python dependencies). Without internet access, choose **Offline** scan: base models are read directly from file headers, and a later online scan fills in covers and trigger words (see **Network and backups** below).
|
||||
|
||||
<details>
|
||||
<summary><b>📖 Every page and tool, in detail</b></summary>
|
||||
<!-- guide:pages -->
|
||||
|
||||
Every page has an icon on the rail at the left of the window.
|
||||
Every page has an icon on the left rail; when docked beside the canvas, drag the window's right edge (`480px` minimum, double-click to reset to `660px`) to resize.
|
||||
|
||||
| Page | What you do there |
|
||||
| :--- | :--- |
|
||||
| **🏠 Home** | Pick what you want to do: a card per task, first steps (scan your model folders, a guided tour of the interface) and your latest activity. |
|
||||
| **🕘 Activity** | Everything the plugin changed, by day: on the canvas (which node settings changed from what to what, nodes added or removed, workflows opened) and in files (models, covers, workflows, materials, notes, images, audio). **Find on canvas** jumps to the node; a canvas change can be undone with **Undo** until something changes it again, the rest is recorded only. Your own edits are not listed. |
|
||||
| **📦 Models** | Your models with covers, trigger words and base model. Chips at the top switch between types (Checkpoint, LoRA, VAE…) and list a whole type, subfolders included; the folder list narrows it to one folder. **+** adds a loader node to the canvas; edit a model's name, notes and cover, or scan just that model. |
|
||||
| **🖼️ Gallery** | Your ComfyUI `output` folder. Search by prompt, model, LoRA, seed, file name or model hash; open an image to see the parameters it was made with, or drag it onto the canvas to get its workflow back. **☆** keeps a good one as a whole workflow, a combo (model, LoRAs, prompt) or just its prompts. |
|
||||
| **🪡 Workflows** | Saved workflows: save a whole workflow or a part of one together with its models, cover, notes and parameters. Each card shows whether the models are on this computer; drag it onto the canvas to load it. Versions can be compared and restored. A card's export button makes a **recipe package** (.zip) to give to someone; dropped into their ⇅, it becomes a workflow card. |
|
||||
| **🧩 Combos** | A main model, a few LoRAs and a prompt you like, kept together. **Put on canvas** (or dragging the card onto the canvas) lays them out as a wired group of nodes; nothing already on the canvas changes. |
|
||||
| **✍️ Prompts** | Prompt Studio, a page of the browser; **Beside the canvas** moves it next to the canvas to see both. Select a prompt node and its prompt boxes appear as tags, edited in place: click a tag to make it stronger or weaker, double-click to edit, drag to reorder, type to add (Chinese, Japanese and other languages are translated to English), and show each tag's meaning in Chinese, Japanese, Korean and other languages. Click a card (a few common ones, plus your saved prompts) to add it, or drag it straight onto a prompt box on the canvas (outlined green for positive, red for negative) or onto empty canvas for a new prompt node. With no prompt node selected you write a draft. |
|
||||
| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) installed: import GPT-SoVITS characters, pick a reference clip per emotion, fix pronunciations, write a script under Voice-over on the same page and generate it right there; generated audio is listed in the audio gallery. Without it, the page explains how to install it and nothing else depends on it. |
|
||||
| **🏠 Home** | Task cards for common jobs, first-run steps (**Scan model folders**, **Tour the interface**), recent AI tasks and latest activity. |
|
||||
| **🕘 Activity** | Everything Anomalous changed, grouped by day: canvas edits (before → after node values, added/removed nodes, opened workflows, links to AI tasks) and file operations (models, covers, workflows, combos, prompts, images, audio). **Find on canvas** jumps straight to the node; canvas changes offer **Undo** while still current. Your manual canvas edits are not listed. |
|
||||
| **📦 Models** | Browse models with Civitai covers, trigger words and base model chips. Switch types at the top (Checkpoint, LoRA, VAE…) or narrow to a subfolder on the left; **+** drops a loader node onto the canvas. Files sharing a Civitai model ID fold into one card with a numbered side arrow (slide out under an orange bar, pin a default version, or use **Open all versions**); model details also fetch **All versions on Civitai** for SHA-256-checked downloads. Includes **Tidy** (spot misplaced or duplicate files) and **Sources**. |
|
||||
| **🖼️ Gallery** | Your ComfyUI `output` folder (including background-rendered previews for ultra-large upscales). Search by prompt, model, LoRA, seed, filename or model hash; inspect full generation parameters, compare two images, drag onto the canvas to restore the workflow, or click **☆** to keep as a workflow, combo or saved prompt. |
|
||||
| **✨ AI workbench** | Task-by-task workspace for MCP AI apps: live step timeline with **Undo**, copy-tab controls (**AI edits a copy** / **Show the copy**), **Stage** panels (first-pass base picks, before/after comparison slider, choices, `✎` brush repaint markup, **Turned out well / Not quite**), **Node map**, **Decisions**, **Materials**, **Results**, **Ways** (with **Use it this time**) & **Skills**, and **Gates** (including optional per-task `🔒` privacy mode). Follow-up tasks indent cleanly under their parent task. |
|
||||
| **🪡 Workflows** | **Official examples** and **Saves** at the top, followed by saved workflow cards. Details show a compact **structure flow diagram**, sticky **preset tabs** (highlighting changed stages in orange, with inline node diffs and **+ New preset**), **Hand to AI**, version comparison/restore, `.zip` **recipe packages**, and `⇅` share codes / drag-and-drop import. |
|
||||
| **🧩 Combos** | Keep a main model, several LoRAs (with exact strengths) and prompts together as one card. **Put on canvas** (or dragging the card) places them as a newly wired node group without touching existing canvas nodes. |
|
||||
| **✍️ Prompts** | Prompt Studio—full page or docked **Beside the canvas**. Edits selected prompt nodes live as tags: click to weight, double-click to edit, drag to reorder, type in Chinese/Japanese/etc. to translate into English tags, display per-tag translations underneath, or drag prompt cards onto positive (green) / negative (red) boxes. Works as a scratchpad when no prompt node is selected. |
|
||||
| **🎙️ Voices / 🎧 Audio** | With [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS): import GPT-SoVITS characters, assign reference clips per emotion, tune pronunciations, write multi-line scripts under Voice-over, and browse generated clips in the audio gallery. |
|
||||
|
||||
Tools on the rail:
|
||||
Tools at the bottom of the rail (ordered by frequency of use):
|
||||
|
||||
* **Scan** reads your model folders and fetches covers, trigger words and base models from Civitai (by file hash, no extra Python packages).
|
||||
* **Model Check** (formerly Model Doctor) checks each workflow you open: when models are missing, a bar over the canvas says so and puts back, with one press, the ones you have under another name or folder, recognised by the fingerprints (hash and file size) the plugin records in workflows and images (**Settings → Record model fingerprints in workflows**, on by default); without them, a file of the same name is offered for you to confirm. Its page lists the rest with what you can do.
|
||||
* **Current node** (formerly Node Assistant) shows the model of the node you select, lets you swap it from the covers or insert a LoRA, and lists every set of values saved for that kind of node with exactly what applying it would change; saved values can be deleted there.
|
||||
* **Each tool on its page**: share and import on Workflows (⇅: a short share code of the canvas workflow, in Chinese characters or letters; paste a code, or drop a workflow file, an image or a backup .zip), Model Sources (where each model can be downloaded) on Models and in Model Check, translation on the prompt boxes in Current node.
|
||||
* **Settings** (at the bottom): language, font size, thumbnails, video covers, which model folders to show, and feedback. Home and Settings have **Report a problem**, which opens a GitHub issue with your versions and GPU already filled in.
|
||||
* **Current node** (formerly Node Assistant): inspects the selected canvas node's model, swaps models from visual cards, inserts one or **multiple numbered LoRAs** at once (as standard chained nodes or into an rgthree Power Lora Loader / Easy-Use stack), and compares/applies saved values from workflows, combos, gallery pictures or node presets (`⇄ Compare` checks that node alone).
|
||||
* **Model Check** (formerly Model Doctor): checks opened workflows read-only; offers a one-click banner when renamed or moved local files match the workflow's SHA-256 fingerprints (written by this plugin: **Settings → Record model fingerprints in workflows**, on by default); without fingerprints, offers a file of the same name for you to confirm. Downloads come from Civitai by fingerprint, the workflow's own links or ComfyUI-Manager's model list. [The full rules](docs/guides/model-check-rules.en.md).
|
||||
* **Scan**: hashes model files locally, reads headers (including offline base-model & architecture detection checked against 300+ model layouts), and fetches Civitai covers, names and trigger words.
|
||||
* **Settings**: language (switches live across all panels, Stage and Saves bar), interface scale, theme, thumbnails, video covers, model download mirrors, backups, and **Report a problem** (pre-filled with version & GPU info).
|
||||
|
||||
**AI apps (MCP)**: Claude, Cursor, Cherry Studio and other apps that support MCP can use your library through `http://127.0.0.1:8188/anomalous/mcp`: answer questions about your models, outputs and saved prompts, and change the open workflow for you (prompts, models, LoRAs, combos, missing models), each change one Ctrl+Z step. Local only; setup in [docs/guides/mcp.md](docs/guides/mcp.md).
|
||||
<!-- guide:connect_ai -->
|
||||
**Connecting AI apps (MCP)**: Claude, Cursor, Antigravity, Cherry Studio, VS Code and other MCP clients connect locally via `http://127.0.0.1:8188/anomalous/mcp` (localhost only). Full setup and tool reference in [docs/guides/mcp.md](docs/guides/mcp.md).
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>⚙️ Opening, shortcuts and updating</b></summary>
|
||||
<!-- guide:open -->
|
||||
|
||||
Open the browser with **Shift + B** (again to close), the floating button on the canvas, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. **ComfyUI Settings → Anomalous Model Browser → Interface** changes the shortcut, the language and how the browser is opened (floating button, a button next to Run, or the Extensions menu only).
|
||||
Open the browser with **Shift + B** (press again to close), the cube button at the canvas's top left, or **Extensions → Anomalous Model Browser** in ComfyUI's menu. Customize the shortcut, language and launcher style under **ComfyUI Settings → Anomalous Model Browser → Interface**.
|
||||
|
||||
**Updating**: click the **!** button at the top right of the browser; it shows the current version, and **Check for updates / switch version** opens the version panel. Update to the newest release, go back to an earlier one or return to the latest, then restart ComfyUI. The plugin never checks for updates on its own.
|
||||
**Updating**: click the **!** button at the top right of the browser, then **Check for updates / switch version** to update to the newest release or roll back to an earlier tag, and restart ComfyUI. The plugin never phones home or checks for updates in the background.
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🌐 Network and backups</b></summary>
|
||||
<!-- guide:network -->
|
||||
|
||||
**On a cloud server (AutoDL, RunPod…):** fully supported on overseas servers; on servers in mainland China only in part (Civitai is mostly out of reach there: lookups are unreliable, covers rarely download). Not fully tested yet (one AutoDL server so far). The guide has a section for AI assistants to walk you through it. Opened from another computer, the plugin is view only by default. Open ComfyUI through an SSH tunnel, or see [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
|
||||
**On a cloud server (AutoDL, RunPod…):** overseas servers work out of the box; servers in mainland China only in part (Civitai is mostly out of reach there; see the `civitai.red` fallback and `hf-mirror.com` below). Tested on one AutoDL server so far. Opened from another machine, the plugin defaults to read-only for safety; connect via an SSH tunnel so ComfyUI sees `127.0.0.1`, or read [docs/guides/cloud-server.md](docs/guides/cloud-server.md).
|
||||
|
||||
**Network:** Scan looks models up on Civitai and prompt translation uses Google Translate, so both need to reach those sites (from mainland China usually through a proxy; when civitai.com cannot be reached the plugin tries Civitai's other address, civitai.red, which some networks reach, a cloud server in mainland China among them: names and trigger words come, covers usually not). Without them, pick **Offline** on the scan page (an online scan does the same by itself when it cannot connect): base models are read from the files, without covers or trigger words, and a later online scan fills in the rest. Downloading missing models needs the network too; for Hugging Face, **Settings → Model downloads** can use the hf-mirror.com mirror (Civitai has none). Browsing models, outputs, workflows and combos needs no network.
|
||||
**Network:** Civitai scanning and Google prompt translation require network access (when `civitai.com` is unreachable, the plugin tries `civitai.red` automatically; **Offline** scan works without internet and reads base models from file headers). For Hugging Face downloads in mainland China, enable **Settings → Model downloads → Hugging Face mirror (`hf-mirror.com`)**. Daily browsing of models, gallery, workflows, saves and combos requires **no network**.
|
||||
|
||||
**Keep a copy of your own data:** **Settings → Backup → Export** packs workflows, combos, saved prompts and node values, parameter presets, and the names, notes and covers you gave your models into one .zip; keep it on a cloud drive or a USB stick, and **Import** puts it back, also on another computer. (They live in `workflows/anomalous_recipes`, `workflows/anomalous_notebooks`, `workflows/anomalous_materials` and `workflows/anomalous_parameters` in your ComfyUI user folder.)
|
||||
**Backups:** **Settings → Backup → Export** packs your saved workflows, presets, canvas saves, combos, prompts, node values, and custom model names/notes/covers into a single `.zip` for cloud drives or a move to another PC. **Import** never deletes anything: a file that differs from the backup's stays unless you choose to take the backup's (yours then goes to the Recycle Bin). A backup also lists your models with their fingerprints, so on a new computer the missing ones can be looked up and downloaded again. Data lives under `workflows/anomalous_*` inside your ComfyUI `user` folder.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -130,114 +208,191 @@ Open the browser with **Shift + B** (again to close), the floating button on the
|
||||
|
||||
<h2 id="中文">🇨🇳 中文说明</h2>
|
||||
|
||||
> **Anomalous Model Browser** 在一个窗口里帮你打理 ComfyUI 周边的杂事:
|
||||
> - 带 C 站封面和触发词浏览模型;
|
||||
> - 翻看以前的出图;
|
||||
> - 保存调好的工作流,存下常用的模型搭配和提示词;
|
||||
> - 别人的工作流缺模型时自动找回;
|
||||
> - 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 给角色配音。
|
||||
> **Anomalous Model Browser** 在一个窗口里帮你打理 ComfyUI 周边的琐事:
|
||||
> - 带 C 站封面、触发词和多版本折叠卡浏览模型;
|
||||
> - 翻看历史出图,一拖还原当时的完整工作流;
|
||||
> - 用结构流程图和预设管理调好的工作流,随时给画布存几个存档锁种对比、一键回退;
|
||||
> - 存下常用的模型 + LoRA 搭配,用词块直观编辑长提示词;
|
||||
> - 打开别人的工作流缺模型时帮你找回或下载(工作流带着本插件记的指纹时最准);
|
||||
> - 在 **AI 工作台** 里动嘴提要求,让 AI 在副本标签页里调用你的工作流和实测“做法”跑图、先挑底图再放大精修;
|
||||
> - 配合可选节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 给角色配音。
|
||||
>
|
||||
> 插件改动过什么,都能在“操作记录”里查到。
|
||||
> 插件和 AI 改动过的每一处画布设置,都在“记录”里写得明明白白,随时定位或撤销。
|
||||
|
||||
<p align="center"><img src="docs/images/models.webp" width="880" alt="中文模型库界面:Civitai 封面、底模标签与多版本同卡展开"></p>
|
||||
|
||||
### 🧭 理念
|
||||
<!-- guide:idea -->
|
||||
|
||||
**不再造一个套壳 WebUI,也不取代 ComfyUI,只是把 ComfyUI 身边最磨人的杂活接过去。**
|
||||
|
||||
- **少做没必要的体力活。** 认乱码文件名、修一片红框的缺模型节点、翻旧图找参数、每次重连同一串大模型和 LoRA——这些交给插件做。
|
||||
- **用的还是你自己的 ComfyUI。** 画布和节点全是 ComfyUI 原生的。插件本身**一个自定义节点都不往工作流里塞**,放到画布上的都是官方节点或你本来就装了的节点包;出的图、另存的工作流,发给没装本插件的朋友照样直接打开。
|
||||
- **接 AI 也不走“黑盒盲猜”路线。** 你只管用人话提要求;**AMB** 负责在**副本标签页**里打开你的工作流、接好实测过的节点块;真正出图的还是**你本机的 ComfyUI 和你挑的模型**。每一步改了什么都在眼前,先挑构图再跑慢速放大,不满意随时一步撤销。
|
||||
|
||||
### ✨ 1.63 重点更新
|
||||
|
||||
- **AI 工作台走通“分阶段导演式出图”**:多段流程支持**先快速跑几张一阶段底图让你挑**(点“就选这张”锁种进入二段放大,不重画底图),右侧展台提供**前后拉帘对比**、局部涂抹重绘与“效果好 / 不太行”一键反馈;内置按用途整理的实测**做法**与按需 **Skill**,默认在**副本标签页**干活,不碰你正在改的画布。
|
||||
- **工作流详情升级“结构流程图 + 橙色预设差异 + 官方示例”**:点开工作流就能看到按出图阶段排布的结构图(底模、LoRA、控制模型横跨在它们作用的阶段上方);切换**预设**时改动过的阶段直接标成**橙色**,点格子即看 `默认 → 当前预设` 参数对比;顶部新增**官方示例**,新装环境打开就能跑。
|
||||
- **画布存档与锁种公平对比**:右键画布存下当前状态,底部存档条随时切回 `1 · 2 · 3`(连输出节点上的预览图也跟着一起切回对应存档!),支持**锁种**或**随机**批量跑多组存档,并在存档页并排对比每版改了什么和各自出的图。
|
||||
- **同模型多版本合卡 & 一次按顺序插入多个 LoRA**:来自同一个 C 站模型页的多个版本自动收成一张卡(点右侧数字展开橙边抽屉、图钉设默认版本、按 SHA-256 下载该模型其他版本);**插入 LoRA** 支持点选多个按 `1 · 2 · 3` 编号自动串好线(或放进 rgthree / Easy-Use 堆叠节点),一次 `Ctrl+Z` 全退回;侧边停靠支持拖拽右边缘自由调宽(最窄 `480px`)。
|
||||
|
||||
### 🎬 视频与讨论
|
||||
* 📺 **哔哩哔哩 (Bilibili)**:[功能演示](https://www.bilibili.com/video/BV1boHx61Enc/) · [角色配音演示](https://www.bilibili.com/video/BV1bkpZ6uEZQ/)
|
||||
* 💬 **Reddit**:[r/comfyui 上的介绍帖](https://www.reddit.com/r/comfyui/s/n38ihf3DLf)
|
||||
|
||||
### 💡 它能帮你解决什么
|
||||
---
|
||||
|
||||
**1. 模型太多,认不出谁是谁。** 文件叫 `下载 (3).safetensors`,根本看不出是什么。点一次 **扫描**,每个模型都按文件哈希去 C 站认一遍,显示出它真正的名字、封面、触发词和底模。以后看封面挑模型就行。
|
||||
### ✨ AI 工作台:你用人话当导演,AMB 搭线,ComfyUI 用你的模型出图
|
||||
<!-- guide:ai_workbench -->
|
||||
|
||||
<p align="center"><img src="docs/images/scan.webp" width="520" alt="扫描结果:每个文件都在 C 站认出来了"></p>
|
||||
很多人一听“在 ComfyUI 里接 AI”,第一反应往往是两个顾虑:要么怕它像云端生图软件一样绕开自己的本地模型库,要么怕大模型瞎猜节点连线,把自己辛辛苦苦调好的画布搅得一团糟。**AI 工作台**走的是另一条路——**让 AI 当你的操作助手,规矩由 AMB 守住**:
|
||||
|
||||
**2. 打开别人的工作流,一片红框。** 模型检查会告诉你缺哪些模型。如果工作流里带着模型指纹(装了本插件的人出图、保存工作流时会自动记下,除非他在设置里关掉),你电脑里其实有、只是改了名或换了文件夹的,点一下就换上;真没有的可以从 C 站、Hugging Face 或 ComfyUI-Manager 的模型清单下载,下载完按指纹核对过才用(Manager 清单里只是同名、没法核对的,会标出来让你决定)。没有指纹的工作流只能按文件名找:别处有同名文件,会列出来让你确认。
|
||||
- **默认只改“副本”,绝不弄乱你开着的画布。** 任务默认开启 **AI 改副本**:AI 第一次动手前,AMB 自动把工作流复制到专属的任务标签页里操作,你原来的标签页一笔不动;时间线上的每一步都能点 **撤销** 或 **在画布上找到**。
|
||||
- **不靠瞎猜连线,优先用你的工作流和实测“做法”。** AI 会直接调用**你存好的工作流及预设**、**图库某张图自带的工作流**,或是开箱即用的 **官方示例**;需要加功能时,由 AMB 按你已装的节点包搭上实测过的 **做法**(Tile 锁图二段放大、半途换模型接着画、换模型只改人物或只改场景、背景虚化的图放大时只重画人物、裁切缝合 / LanPaint 局部重绘、修手先放大再修、换背景、姿势/深度控制……),并配合按需 **Skill**(按主模型参数量给动作建议、分阶段出图思路)。你还能在做法面板点 **这次用它**,直接指定本次任务必须走哪套做法。
|
||||
- **先挑快底图,再跑慢精修,展台拉帘对比。** 跑两段放大这类耗时流程时,AI 可以先快速跑几张第一段底图预览(不污染正式输出目录);右侧**展台**弹出挑选面板,你点 **就选这张**,第二段直接接着这张图做细节重绘,省掉重跑底图的时间。出图后展台提供**前后拉帘对比**、**效果好 / 不太行** 按钮,哪里不满意还能点图片右上角 `✎` 用画笔涂出区域让它只重画那一块。
|
||||
- **权限全由“关卡”把关。** 改画布、用显卡出图、下载模型、联网扫描模型、挪动模型文件,都能在 **关卡** 里设为“直接做 / 先问我 / 不许做”;AI 自己往画布上加节点时,能不能用任意第三方节点包、没验证过的新路线要不要先问你,也在这里设(任务标题栏还带可选的 `🔒` 隐私开关,开启后提示词与图片名会换成 `[P1]`、`[img1]` 代号,不把原图和原文发给 AI)。支持连接 Antigravity、Claude、Cursor、Cherry Studio、VS Code 等支持 MCP 的本机软件——点工作台里的 **连接 AI** 复制一句话发给它就能配好,详见 [docs/guides/mcp.md](docs/guides/mcp.md)。
|
||||
|
||||
<p align="center"><img src="docs/images/model-check.webp" width="760" alt="模型检查一键换上 3 个缺失的模型"></p>
|
||||
<p align="center"><img src="docs/images/ai-workbench.webp" width="840" alt="AI 工作台:先在右侧展台三选一挑中快速底图,再锁种完成二段精修并用拉帘滑块对比前后细节"></p>
|
||||
|
||||
**3. 这张图当时是怎么出的?** 在图库里按提示词、模型或 LoRA 搜,再把图拖到画布上,当时的工作流就回来了。
|
||||
---
|
||||
|
||||
<p align="center"><img src="docs/images/gallery.webp" width="760" alt="图库搜索后把图拖到画布"></p>
|
||||
### 💡 日常玩 ComfyUI,它帮你省掉哪些麻烦(不接 AI 也一样主力)
|
||||
|
||||
**4. 每次都要重新配一遍模型、LoRA 和提示词。** 在画布上选中这几个节点,右键 → **AMB** → **存为搭配**,它们就成了画布旁的一张卡片。下次点 **放到画布**(或者直接拖卡片),点哪里,那组连好线的节点就出现在哪里,画布上原有的东西不动。搭配里的模型和提示词以后还能换。
|
||||
<!-- guide:names -->
|
||||
**1. 模型塞满硬盘,文件名全是 `download (3).safetensors`。**
|
||||
点一次 **扫描**,每个模型按文件 SHA-256 哈希去 C 站认领真名、高清封面、触发词和底模角标。同一个 C 站模型的不同版本还会自动收进一张卡片,点右侧数字滑出全部版本(顶部橙边相连),点图钉设默认版本,详情里还能直接核对下载该模型在 C 站上的其他版本。
|
||||
|
||||
<p align="center"><img src="docs/images/combo.webp" width="760" alt="选中的节点存为搭配,再放回画布变成连好线的节点"></p>
|
||||
<p align="center"><img src="docs/images/scan.webp" width="560" alt="扫描明细:把乱七八糟的本地文件名精准匹配回 C 站模型名"></p>
|
||||
|
||||
**5. 提示词太长,改起来麻烦。** 选中提示词节点,提示词工坊把它拆成词块:点一下调权重,拖动换位置,输入就加(中文会自动译成英文,比如输入“蓝宝石”加进去的是 sapphire)。
|
||||
<!-- guide:model_check -->
|
||||
**2. 打开别人分享的工作流,满屏红框缺模型。**
|
||||
**模型检查** 在打开工作流时自动只读体检,能做到哪一步,看工作流里带了什么:
|
||||
- **带指纹的**(作者也装了本插件,出图、存工作流时默认记下每个模型的 SHA-256):你电脑里其实有、只是改了名或挪了文件夹的同一个文件,能确定认出来,横幅上点一下 **换上** 就全部就位;真缺的按指纹去 C 站找,下载完核对一致才换上。
|
||||
- **没带指纹的**(作者没装本插件):没法证明是哪一个文件,只能按名字找。别的文件夹里恰好有一个同名文件,会列出来让你逐个确认;VAE、文本编码器这类经常同名的组件不按名字猜。
|
||||
- **下载**时还会查工作流自带的下载链接(ComfyUI 官方模板就带着)和 ComfyUI-Manager 的模型清单,Hugging Face 的文件可以走国内镜像。只是名字对上的会标成 **按文件名下载**,你点了才下。
|
||||
|
||||
<p align="center"><img src="docs/images/prompt-studio.webp" width="760" alt="提示词工坊按词块编辑提示词节点"></p>
|
||||
<p align="center"><a href="docs/guides/model-check-rules.md"><img src="https://img.shields.io/badge/📖_模型检查怎么判断-完整规则-blueviolet?style=for-the-badge" alt="模型检查怎么判断:完整规则"></a></p>
|
||||
|
||||
**6. 想让角色开口说话。** 配合配套节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS):导入 GPT-SoVITS 角色,每种情绪一段参考音频,在配音页写台词直接生成。
|
||||
<p align="center"><img src="docs/images/model-check.webp" width="820" alt="模型检查:一键把 3 个改名或挪过文件夹的红框缺失节点换回正确模型"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面"></p>
|
||||
<!-- guide:workflows -->
|
||||
**3. 存下调好的工作流,结构流程图和橙色预设差异一眼看清。**
|
||||
顶部自带 **官方示例**(文生图、两段放大、图生图、局部重绘,自动配好你本机已有的模型),新手装上就能出图。存下的每张工作流卡点开就是清爽的 **结构流程图**:底模、LoRA、控制模型横跨在它们管辖的采样阶段(`① 出底图` → `② 放大重画`)上方。切换下方的 **预设标签**,和默认参数不同的阶段直接亮起 **橙色**,点任意方格就能在下方展开 `默认 → 当前预设` 的逐项对比;还能打成 `.zip` **配方包** 或生成短短一截 `⇅` 汉字/字母分享码发给别人。
|
||||
|
||||
**改错了也不怕。** 插件做的每一处改动都列在“操作记录”里,带 **撤销** 和 **在画布上找到**。
|
||||
<p align="center"><img src="docs/images/workflows.webp" width="840" alt="工作流页:官方示例、结构流程图、切换预设后橙色高亮改动阶段及节点参数对比"></p>
|
||||
|
||||
<p align="center"><img src="docs/images/activity.webp" width="560" alt="操作记录和撤销按钮"></p>
|
||||
<!-- guide:saves -->
|
||||
**4. 想试几种新参数或加个 LoRA,又怕改坏现在这版 —— 画布存档。**
|
||||
在画布上右键 → **存档当前工作流**,当前状态就存进画布底部的存档条;接着随便改参数、加节点,点 `+` 再存几版。存档条支持选 **锁种**(同一组种子公平对比)或 **随机**,一键把几个存档按顺序排进队列。“工作流”页顶部的 **存档** 把每个存档改了什么和它跑出的图并排摆在一起;点底部存档条的数字 `1`、`2`、`3` 就能在当前标签页秒回那一版——**连输出节点上的预览图也会跟着切回那个存档跑出来的图**(`Ctrl+Z` 可撤回,满意的随时点 **收藏为工作流**)。
|
||||
|
||||
**不占你的资源**:
|
||||
- 插件本身不加载任何模型,不和生图抢显存。
|
||||
- 浏览时显示的是小缩略图,不加载原图;关闭窗口一分半后,图片也会释放掉。
|
||||
- Anomalous_TTS 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 时全部释放。
|
||||
<p align="center"><img src="docs/images/saves.webp" width="840" alt="画布存档:并排查看三个存档的参数节点改动与锁种出图,并在底部存档条一键切换回退"></p>
|
||||
|
||||
<!-- guide:job_sheet -->
|
||||
想用同一张画布跑一大串提示词?画布右键 → **AMB** → **任务单(批量换提示词)**:一行贴一条提示词,填 **每条跑几次**、**每张之间歇几秒**,点 **开始**。跑的是开始时那张画布的副本,一张接一张,你的画布不动;随时 **暂停**、**继续**、**停止**。一条也不写,就是照画布原样隔一段时间跑一张。也可以让 AI 排任务单(“这几条各跑 4 张”“cfg 4、5、6 各跑 3 张”)。
|
||||
|
||||
<!-- guide:picture_workflow -->
|
||||
**5. 翻到一张老图:“当时到底怎么调出来的?”**
|
||||
在 **图库** 里按提示词、大模型、LoRA、种子或模型哈希秒搜(哪怕几亿像素的超大图也在后台独立进程生成缩略图,绝不卡死页面),把图往画布上一拖,当时的完整工作流直接回来。
|
||||
|
||||
<p align="center"><img src="docs/images/gallery.webp" width="820" alt="图库按标签词块搜索图片,拖入右侧画布立即还原完整工作流"></p>
|
||||
|
||||
<!-- guide:combos -->
|
||||
**6. 每次开新图都要重连一遍大模型、LoRA 和起手提示词。**
|
||||
框选画布上的这几个节点,右键 → **AMB** → **存为搭配**,就存成一张搭配卡。下次点 **放到画布**(或直接拖卡片),点哪里就在哪里放出一组连好线的节点,画布原有的东西分毫不动。想在已有节点后面加几个 LoRA?到 **当前节点 → 插入 LoRA** 里连点几张卡片,卡片上的数字 `1 · 2 · 3` 就是串联顺序(装了 rgthree 或 Easy-Use 还能一键塞进同一个 LoRA 堆叠节点),一次 `Ctrl+Z` 整串退回。
|
||||
|
||||
<p align="center"><img src="docs/images/combo.webp" width="820" alt="把存好的模型 + LoRA + 提示词搭配一键放到画布上自动连好线"></p>
|
||||
|
||||
<!-- guide:prompts -->
|
||||
**7. 提示词一长串逗号,改权重、调顺序眼都花了 —— 提示词工坊。**
|
||||
点 **停到画布旁** 把工坊挂在画布左侧,选中任意提示词节点,正负面文本立刻拆成可视化词块:点 `+` / `−` 调权重,双击改字,拖拽换顺序,直接敲中文回车自动译成英文词块加进去,常用词卡还能直接拖进画布的正向(绿框)/ 反向(红框)输入框里。
|
||||
|
||||
<p align="center"><img src="docs/images/prompt-studio.webp" width="820" alt="提示词工坊停靠在画布旁,按词块实时调整选中节点的提示词权重"></p>
|
||||
|
||||
<!-- guide:voices -->
|
||||
**8. 想让画出来的角色开口说话。**
|
||||
配合可选节点包 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS):导入 GPT-SoVITS 角色,按情绪绑定参考音频、修正多音字读音,在配音页写好台词直接生成语音。
|
||||
|
||||
<p align="center"><img src="docs/images/voices.webp" width="760" alt="角色语音页面:按情绪管理参考音频与台词配音"></p>
|
||||
|
||||
<!-- guide:undo -->
|
||||
**改错了也不怕。**
|
||||
插件和 AI 在画布上改过的每一个参数、连过的每一根线,都按天记在 **记录** 里,展开就能看修改前后的数值,点 **在画布上找到** 直接跳过去,点 **撤销** 一键退回。
|
||||
|
||||
<p align="center"><img src="docs/images/activity.webp" width="760" alt="操作记录页面:展开查看节点修改前后数值,一键在画布上找到或撤销"></p>
|
||||
|
||||
<!-- guide:light -->
|
||||
**不抢你的显存和内存**:
|
||||
- 插件自身不加载任何生图模型,**零显存占用**,绝不和 ComfyUI 出图抢资源。
|
||||
- 浏览网格全程走小缩略图缓存,不把原图塞进浏览器;关闭窗口一分半后自动释放图片内存。
|
||||
- `Anomalous_TTS` 的语音模型在 ComfyUI 生图需要显存时会自动让出,点 **卸载模型** 则彻底清空。
|
||||
|
||||
### 📦 安装
|
||||
<!-- guide:install -->
|
||||
|
||||
1. 在 **ComfyUI Manager** 里搜索 `Anomalous Model Browser`,安装后重启 ComfyUI。也可以在 `custom_nodes` 目录下手动安装:
|
||||
1. 在 **ComfyUI Manager** 里搜索 `Anomalous Model Browser`,安装后重启 ComfyUI;或在 `custom_nodes` 目录下手动克隆:
|
||||
```bash
|
||||
cd custom_nodes
|
||||
git clone https://github.com/DemonGatanjieu/Anomalous_Model_Browser.git
|
||||
```
|
||||
2. 按 **Shift + B** 打开。在 **主页** 点 **扫描模型文件夹** 补全封面和触发词;点 **界面导览** 会逐一指给你看窗口的各个部分。
|
||||
3. 想用角色配音的话,再装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)(在 ComfyUI Manager 搜索 `Anomalous TTS`)。不装也不影响其他功能。
|
||||
2. <a id="where-zh"></a>**装好后去哪找**:重启后,画布左上角会多一个小立方体按钮(下图圈出来的那个),点它或按 **Shift + B** 打开窗口。第一次用可以在 **主页** 点 **扫描模型文件夹** 补齐封面和触发词,点 **界面导览** 跟着认识一圈各个区域。
|
||||
|
||||
扫描要连 C 站。连不上也能用:选离线扫描,只从模型文件推断底模,以后能连上时再扫一次会自动补上(见下面的“网络和备份”)。
|
||||
<p align="center"><img src="docs/images/entry.webp" width="640" alt="画布左上角的立方体按钮,以及 ComfyUI 菜单 C → Extensions → Anomalous Model Browser → 打开"></p>
|
||||
|
||||
看不到这个立方体?可能被拖走了,或者换成了别的入口:点左上角 ComfyUI 的 **C** 菜单 → **Extensions → Anomalous Model Browser**,里面有 **打开 Anomalous 模型浏览器** 和 **重置悬浮入口位置**。想让它更显眼,去 **ComfyUI 设置 → Anomalous Model Browser → 界面**:**悬浮入口样式** 选 **胶囊文字**(会带上“模型浏览器”几个字),**悬浮入口大小** 选 **大**;或者 **浏览器入口模式** 选 **顶部动作栏**,放到运行按钮旁边。
|
||||
3. 需要角色配音的话,再安装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS)(ComfyUI Manager 搜索 `Anomalous TTS`);不装完全不影响其他功能。
|
||||
|
||||
扫描模型默认联网查 C 站(纯内置实现,无需额外装 Python 包)。没开代理或断网时选 **离线扫描** 也能直接从模型文件头识别底模与架构,等以后连上网再扫一次会自动补齐封面和触发词(详见下方“网络和备份”)。
|
||||
|
||||
<details>
|
||||
<summary><b>📖 每个页面和工具的详细说明</b></summary>
|
||||
<!-- guide:pages -->
|
||||
|
||||
窗口左侧的图标栏,每个页面一个图标。
|
||||
窗口左侧是页面图标栏;切换为“侧边停靠”时,按住窗口右边缘即可左右拖宽窄(最窄 `480px`,双击边缘恢复默认 `660px`)。
|
||||
|
||||
| 页面 | 在这里做什么 |
|
||||
| :--- | :--- |
|
||||
| **🏠 主页** | 想做什么就点哪张卡片。还有上手第一步(扫描模型文件夹、界面导览)和最近的操作记录。 |
|
||||
| **🕘 记录** | 按天列出插件做过的每一处改动。<br>画布上:哪个节点的设置从什么改成了什么、加了或删了哪些节点、打开了哪个工作流。<br>文件上:模型、封面、工作流、素材、笔记、图片、音频。<br>**在画布上找到** 可以直接跳到那个节点;画布上的改动在没被再改过之前可以点 **撤销**,其余只记录。你自己在画布上的修改不会记进来。 |
|
||||
| **📦 模型** | 模型带封面、触发词和底模信息。<br>顶部标签切换类型(Checkpoint、LoRA、VAE…),连同子文件夹一起列出;左侧文件夹列表可以只看某个文件夹。<br>**+** 一键在画布上创建加载节点;也可以改名、写备注、换封面,或只扫描这一个模型。 |
|
||||
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹。<br>可以按提示词、模型、LoRA、seed、文件名或模型哈希搜索。<br>点开图片能看到生成参数;拖到画布上可以还原当时的工作流。<br>觉得好就点 **☆**:存成整个工作流、搭配(模型 + LoRA + 提示词),或者只存提示词。 |
|
||||
| **🪡 工作流** | 存下的工作流:把完整工作流或其中一段,连同模型、封面、备注和参数一起存下来。<br>卡片会显示这台电脑上模型是否齐全;拖到画布上即可载入。<br>可以比较、恢复历史版本。<br>卡片上的导出按钮能打成**配方包**(压缩包)发给别人,对方拖进 ⇅ 就多一张工作流卡片。 |
|
||||
| **🧩 搭配** | 把常用的主模型、几个 LoRA 和一段提示词存成一个搭配。<br>按 **放到画布**(或把卡片拖到画布上),就放出一组连好线的节点;画布上原有的东西不动。 |
|
||||
| **✍️ 提示词** | 提示词工坊,浏览器里的一个页面;点“停到画布旁”可以移到画布旁边,边看画布边改。选中一个提示词节点,它的提示词框就以词块的样子出现在工坊里,直接改:点一下调权重,双击改字,拖动换位置,输入就加(中文、日文等会自动译成英文),还能在每个词下面显示它的中文、日文、韩文等语言的意思。<br>点词卡(几张常用的,加上你存的)加进去,或者直接拖到画布的提示词框里(正面绿框、负面红框),拖到空白处就新建一个提示词节点。没选中提示词节点时写的是草稿。 |
|
||||
| **🎙️ 角色语音 / 🎧 音频库** | 装了 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后可用:<br>导入 GPT-SoVITS 角色,给每种情绪选参考音频,修正读音;切到“配音”写台词并直接生成。<br>生成的音频都在音频库里。没装时,页面会说明怎么安装,其他功能不受影响。 |
|
||||
| **🏠 主页** | 按你想做的事分好入口卡片,包含新手第一步(扫描模型文件夹、界面导览)、AI 最近做的任务和最新操作记录。 |
|
||||
| **🕘 记录** | 按天列出插件和 AI 做过的全部改动:画布改动(具体哪个节点的什么参数从多少改成多少、增删了哪些节点、关联哪个 AI 任务)与文件操作(模型、封面、工作流、搭配、提示词、图片、音频)。支持 **在画布上找到** 定位节点,没被后续覆盖的画布改动可随时 **撤销**。 |
|
||||
| **📦 模型** | 带封面、触发词和底模分类条浏览模型,左侧可按子文件夹筛选,点 **+** 一键往画布放加载器。来自同一 C 站模型页的多个版本自动收成一张卡(点右侧数字展开橙边版本排、图钉固定默认显示版本、右上可 **展开全部版本**);详情页点 **Civitai 上的全部版本** 可按需联网查看并按 SHA-256 核对下载其他版本。另含 **整理**(排查放错目录或重复文件)与 **模型来源**。 |
|
||||
| **🖼️ 图库** | 读取 ComfyUI 的 `output` 文件夹(几亿像素超大图由后台子进程做缩略图,浏览不卡)。支持按提示词、模型、LoRA、种子、文件名或模型哈希组合搜索;看大图与完整参数、勾选两张对比工作流差异、拖进画布还原工作流,或点 **☆** 存为工作流、搭配、提示词。 |
|
||||
| **✨ AI 工作台** | 按任务记录并操控接入的 AI:左侧任务树(后续任务自动缩进挂在主任务下),中间实时步骤时间线(每步可撤销,顶部带 **AI 改副本** 与 **切到副本**),右侧可切换 **展台**(一阶段底图挑选、前后拉帘滑块、选择题、`✎` 涂抹局部重绘、**效果好 / 不太行**)、**节点结构**、**决策**、**素材**、**成果**、**做法**(支持点“这次用它”)与 **Skill**,以及权限 **关卡**(含可选单任务 `🔒` 隐私开关)。 |
|
||||
| **🪡 工作流** | 顶部是 **官方示例** 与 **存档**,下方是保存的工作流卡。详情页从上到下依次为 **结构流程图**、吸顶 **预设标签栏**(橙色标出与默认不同的阶段,点方格看逐项差异,支持 **+ 新预设**)、**交给 AI**、历史版本对比恢复,支持导出 `.zip` **配方包** 或通过 `⇅` 收发短分享码、拖入图片/压缩包导入。 |
|
||||
| **🧩 搭配** | 把顺手的主模型、几个 LoRA(连同各自权重)和正反提示词存成一张搭配卡。点 **放到画布** 或拖入画布即生成连好线的独立节点组,绝不改动画布上原有的节点。 |
|
||||
| **✍️ 提示词** | 提示词工坊,可全窗口用,也可点 **停到画布旁** 边看节点边改。选中提示词节点后自动拆成词块:单击调权重、双击改字、拖拽排顺序、输入中文/日文回车自动译成英文词块,还能在词块下显示多语种释义;左侧词卡支持一键加入或拖进画布正/反向输入框,未选节点时可当草稿本写。 |
|
||||
| **🎙️ 角色语音 / 🎧 音频库** | 安装 [Anomalous_TTS](https://github.com/DemonGatanjieu/Anomalous_TTS) 后启用:导入 GPT-SoVITS 角色、按情绪选参考音频、修读音,在“配音”页写台词直接生成,并在音频库回放管理。 |
|
||||
|
||||
图标栏上的工具:
|
||||
左侧栏底部的常用工具(按日常使用频率从上到下排列):
|
||||
|
||||
* **扫描**:读取模型文件夹,按文件哈希从 C 站获取封面、触发词和底模。不需要额外安装 Python 包。
|
||||
* **模型检查**(原“模型医生”):打开工作流时自动检查,缺模型就在画布上方提示;本地改过名、换过文件夹的同一个文件一键换上,靠的是插件记在工作流和图片里的模型指纹(哈希和文件大小;**设置 → 在工作流里记下模型指纹**,默认打开);没有指纹时,同名文件会列出来让你确认。其余的在检查页里自己挑或去 Civitai 找。
|
||||
* **当前节点**(原“节点助手”):选中画布上的节点,查看它用的模型。可以看图换模型、插入 LoRA;存过的同类节点参数列在一起,每条写明会改哪几项,一键套用;存下的参数也在这里删除。
|
||||
* **工具回到各自的页面**:分享与导入在“工作流”页(⇅:把画布上的工作流变成一段短短的分享码,有汉字码和字母码;也能粘贴分享码,或拖入工作流文件、图片、备份压缩包),模型来源(每个模型去哪下载)在“模型”页和模型检查里,翻译在“当前节点”的提示词框上。
|
||||
* **设置**(最下面):语言、字号、缩略图、视频封面、显示哪些模型文件夹,以及提交反馈。主页和设置里的 **报告问题** 会打开一个已经填好版本和显卡信息的 GitHub 页面。
|
||||
* **当前节点**(原“节点助手”):跟随画布当前选中的节点,看封面换模型,或点 **插入 LoRA** 一次勾选多个 LoRA 按 `1 · 2 · 3` 顺序自动串接(装了 rgthree / Easy-Use 可选塞入单个堆叠节点);下方按来源小标签(工作流、搭配、图片、本节点存的)列出同类节点参数,点 `⇄ 对比` 只看当前节点差异并一键套用。
|
||||
* **模型检查**(原“模型医生”):打开工作流自动只读检查;本机改过名或换过目录的同一文件凭 SHA-256 指纹在画布顶部横幅一键换上(指纹由本插件写进工作流:**设置 → 在工作流里记下模型指纹**,默认开启);没有指纹时列出同名文件让你确认。缺的模型从 C 站(按指纹)、工作流自带的链接或 Manager 清单下载。[完整规则](docs/guides/model-check-rules.md)。
|
||||
* **模型扫描**:按文件哈希向 C 站匹配模型信息与封面;离线时也已针对 Manager 清单 300+ 真实模型文件头校准过底模与类型识别,不乱下误判结论。
|
||||
* **设置**(最下方):中英文实时切换(工作台展台、留言框与底部存档条立即生效,无需刷新页面)、界面缩放、深浅主题、缩略图与视频封面、Hugging Face 国内镜像、数据备份导出/导入,以及自动附带版本与显卡信息的 **报告问题**。
|
||||
|
||||
**接入 AI 软件(MCP)**:Claude、Cursor、Cherry Studio 等支持 MCP 的 AI 软件,可以通过 `http://127.0.0.1:8188/anomalous/mcp` 查你的模型、出图和存下的提示词,回答相关问题;也能帮你改当前工作流(提示词、模型、LoRA、搭配、缺失的模型),每次改动都能用 Ctrl+Z 撤销。只限本机。设置方法见 [docs/guides/mcp.md](docs/guides/mcp.md)。
|
||||
<!-- guide:connect_ai -->
|
||||
**接入 AI 软件(MCP)**:Antigravity、Claude、Cursor、Cherry Studio、VS Code 等支持 MCP 的客户端可通过本机地址 `http://127.0.0.1:8188/anomalous/mcp` 连接(仅接受本机请求)。完整配置与工具清单见 [docs/guides/mcp.md](docs/guides/mcp.md)。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>⚙️ 打开方式、快捷键和更新</b></summary>
|
||||
<!-- guide:open -->
|
||||
|
||||
打开方式有三种:按 **Shift + B**(再按一次关闭)、点击画布上的悬浮按钮,或者从 ComfyUI 菜单 **扩展 → Anomalous Model Browser** 打开。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 里,可以修改快捷键、插件语言和打开方式(悬浮按钮、运行按钮旁的按钮,或只用扩展菜单)。
|
||||
打开方式有三种:按 **Shift + B**(再按一次关闭)、点画布左上角的立方体按钮,或从 ComfyUI 菜单 **Extensions → Anomalous Model Browser** 进入(中文界面下这一项也叫 Extensions)。在 **ComfyUI 设置 → Anomalous Model Browser → 界面** 中可自定义快捷键、语言及入口按钮样式。
|
||||
|
||||
**更新插件**:
|
||||
- 点窗口右上角的 **!**,可以看到当前版本;
|
||||
- 点 **检查更新 / 切换版本** 打开版本面板,可以更新到最新版、回退到以前的版本,或回到最新版;
|
||||
- 换完版本后重启 ComfyUI。插件不会自己联网检查更新。
|
||||
**更新与版本切换**:点击窗口右上角的 **!** → **检查更新 / 切换版本**,可一键升级到最新正式版或回退到历史版本,完成后重启 ComfyUI 生效。插件绝不会在后台悄悄联网检查更新。
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><b>🌐 网络和备份</b></summary>
|
||||
<!-- guide:network -->
|
||||
|
||||
**在云服务器上(AutoDL 等):** 海外的云服务器完整支持;国内的云服务器只能部分支持(多半连不上 Civitai,查模型不稳定,封面基本下不来)。目前还没有完整测过(只实测过一台 AutoDL 服务器),用的时候请留意。说明里有一节写给 AI 看的步骤,把链接丢给 AI 就能让它带你做。从别的电脑打开时插件默认只能浏览。用 SSH 隧道打开就能照常用,或者见 [docs/guides/cloud-server.md](docs/guides/cloud-server.md)。
|
||||
**云服务器(AutoDL、RunPod 等):** 海外云服务器完整支持;国内云服务器只能部分支持:多半连不上 Civitai,有的能连上 `civitai.red` 认出模型名与触发词(封面较难下载)。目前只实测过一台 AutoDL 服务器。从非本机浏览器直接访问时插件默认只读保护,建议通过 SSH 隧道映射到本地打开,详见 [docs/guides/cloud-server.md](docs/guides/cloud-server.md)(内附可直接丢给 AI 助手照着配的步骤)。
|
||||
|
||||
**关于网络:** 扫描模型要连 Civitai(C 站)查封面和触发词,提示词翻译默认用谷歌翻译,这两样在国内一般要开网络代理才能用。不开代理也行:扫描页选 **离线**(联网扫描连不上时也会自动改成这样),只从模型文件推断底模,没有封面和触发词,以后能连上时再扫一次会自动补上。下载缺失的模型也要联网:Hugging Face 的模型可以在 **设置 → 模型下载** 里打开“Hugging Face 用国内镜像”,不用代理;C 站的没有镜像。(civitai.com 连不上时插件会自动试 Civitai 的另一个地址 civitai.red,有的网络能连上,比如国内的云服务器,这时能认出模型名字和触发词,封面多半还是下不来。)浏览模型、图库、工作流和搭配都不用联网。
|
||||
**关于网络:** 扫描封面与谷歌提示词翻译需要联网(连不上 `civitai.com` 时会自动尝试 `civitai.red`;无代理时选 **离线** 扫描同样能识别底模)。下载 Hugging Face 模型可在 **设置 → 模型下载** 开启 `hf-mirror.com` 国内镜像。日常浏览模型、图库、工作流、存档和搭配**完全不需要联网**。
|
||||
|
||||
**备份自己的数据:** **设置 → 备份 → 导出**,把工作流、搭配、存下的提示词和节点参数、参数预设,以及你给模型改的名字、备注和封面打成一个压缩包,放到云端硬盘或 U 盘里;换电脑或重装后用 **导入** 恢复。(它们存在 ComfyUI 用户目录下的 `workflows/anomalous_recipes`、`workflows/anomalous_notebooks`、`workflows/anomalous_materials` 与 `workflows/anomalous_parameters`。)
|
||||
**备份自己的数据:** **设置 → 备份 → 导出** 会把工作流、预设、画布存档、搭配、提示词库、节点参数以及你自定义的模型名/备注/封面打包成一个 `.zip`,换电脑或重装后点 **导入** 即可恢复。导入从不删东西:和备份里不一样的文件默认留着你电脑上的,选了“换成备份里的”才替换,原来的进回收站。备份里还记着你有哪些模型和它们的指纹,换了新电脑,缺的模型可以照着清单查来源补下载。(数据保存在 ComfyUI 用户目录下的 `workflows/anomalous_*` 文件夹中。)
|
||||
|
||||
</details>
|
||||
|
||||
@@ -245,8 +400,8 @@ Open the browser with **Shift + B** (again to close), the floating button on the
|
||||
|
||||
### 🧪 Tests (测试)
|
||||
|
||||
The plugin's tests are in `tests/`; run them all with `node tools/run_tests.mjs` from the plugin folder. See [docs/guides/testing.md](docs/guides/testing.md).
|
||||
插件的测试在 `tests/` 里,在插件文件夹运行 `node tools/run_tests.mjs` 一次跑完。改代码或提 PR 之前跑一遍,说明见 [docs/guides/testing.md](docs/guides/testing.md)。
|
||||
The plugin's tests live in `tests/`; run the full suite with `node tools/run_tests.mjs` from the plugin folder. See [docs/guides/testing.md](docs/guides/testing.md).
|
||||
插件测试位于 `tests/` 目录,在插件根目录运行 `node tools/run_tests.mjs` 即可一次跑完。提 PR 或改代码前请先跑通,详见 [docs/guides/testing.md](docs/guides/testing.md)。
|
||||
|
||||
### 📝 License & Branding (开源与品牌声明)
|
||||
|
||||
|
||||
+28
-1
@@ -2,7 +2,8 @@ from . import (
|
||||
activity_log, audio_catalog, folder_types, gallery_routes, materials, media_routes, model_catalog,
|
||||
model_folder, model_media, model_metadata, model_resolution, model_type_listing, kept_images, quick_index, node_material, recipe_packages, recipes, scan_choice, scan_report, scan_summary,
|
||||
local_guard, lora_info, mcp_server, model_download, model_import, model_placement, translation_routes, version_manager, tts_version, backup,
|
||||
network_check, model_heads, model_usage, usage_ledger, sidecar_layout, ai_tasks_routes,
|
||||
network_check, model_heads, model_usage, usage_ledger, sidecar_layout, ai_tasks_routes, snapshot_routes, workflow_saves, example_workflows,
|
||||
model_guide, job_sheets, prompt_language, lllite_fit, description_translation, voice_covers,
|
||||
)
|
||||
from .scanner import *
|
||||
from .config import *
|
||||
@@ -38,6 +39,28 @@ def setup_routes(app):
|
||||
if hasattr(app, 'middlewares') and local_guard.local_only_middleware not in app.middlewares:
|
||||
app.middlewares.append(local_guard.local_only_middleware)
|
||||
app.router.add_get('/anomalous/access', local_guard.api_access)
|
||||
app.router.add_post('/anomalous/snapshot_check', snapshot_routes.api_snapshot_check)
|
||||
app.router.add_get('/anomalous/saves', workflow_saves.api_list)
|
||||
app.router.add_get('/anomalous/saves/group', workflow_saves.api_group)
|
||||
app.router.add_get('/anomalous/saves/workflow', workflow_saves.api_workflow)
|
||||
app.router.add_post('/anomalous/saves/add', workflow_saves.api_add)
|
||||
app.router.add_post('/anomalous/saves/update', workflow_saves.api_update)
|
||||
app.router.add_post('/anomalous/saves/ran', workflow_saves.api_ran)
|
||||
app.router.add_post('/anomalous/saves/remove', workflow_saves.api_remove)
|
||||
app.router.add_post('/anomalous/saves/remove_group', workflow_saves.api_remove_group)
|
||||
app.router.add_get('/anomalous/job_sheet', job_sheets.api_get)
|
||||
app.router.add_get('/anomalous/job_sheet/workflow', job_sheets.api_workflow)
|
||||
app.router.add_post('/anomalous/job_sheet/start', job_sheets.api_start)
|
||||
app.router.add_post('/anomalous/job_sheet/report', job_sheets.api_report)
|
||||
app.router.add_post('/anomalous/job_sheet/ask', job_sheets.api_ask)
|
||||
app.router.add_post('/anomalous/job_sheet/clear', job_sheets.api_clear)
|
||||
app.router.add_post('/anomalous/prompt_language', prompt_language.api_check)
|
||||
app.router.add_post('/anomalous/lllite_fit', lllite_fit.api_check)
|
||||
app.router.add_post('/anomalous/translate_description', description_translation.api_translate_description)
|
||||
app.router.add_get('/anomalous/voice_covers', voice_covers.api_list)
|
||||
app.router.add_get('/anomalous/voice_cover', voice_covers.api_get)
|
||||
app.router.add_post('/anomalous/voice_cover', voice_covers.api_set)
|
||||
app.router.add_post('/anomalous/voice_cover/remove', voice_covers.api_remove)
|
||||
try:
|
||||
folder_types.upgrade_folder_defaults()
|
||||
except Exception as error:
|
||||
@@ -58,7 +81,9 @@ def setup_routes(app):
|
||||
app.router.add_post('/anomalous/delete_model', model_metadata.api_delete_model)
|
||||
app.router.add_post('/anomalous/open_model_folder', model_folder.api_open_model_folder)
|
||||
app.router.add_get('/anomalous/compatible_models', model_catalog.api_compatible_models)
|
||||
app.router.add_get('/anomalous/model_guide', model_guide.api_model_guide)
|
||||
app.router.add_get('/anomalous/notebooks', api_get_notebooks)
|
||||
app.router.add_get('/anomalous/notebooks/by_node_type', api_get_notebooks_by_node_type)
|
||||
app.router.add_post('/anomalous/save_notebook', api_save_notebook)
|
||||
app.router.add_post('/anomalous/delete_notebook', api_delete_notebook)
|
||||
|
||||
@@ -74,6 +99,7 @@ def setup_routes(app):
|
||||
|
||||
# Recipe Routes
|
||||
app.router.add_get('/anomalous/recipes', recipes.api_get_recipes)
|
||||
app.router.add_get('/anomalous/example_workflows', example_workflows.api_examples)
|
||||
app.router.add_get('/anomalous/recipe_full', recipes.api_get_recipe_full)
|
||||
app.router.add_get('/anomalous/recipe_asset', recipes.api_get_recipe_asset)
|
||||
app.router.add_get('/anomalous/recipe_gallery', recipes.api_get_recipe_gallery)
|
||||
@@ -110,6 +136,7 @@ def setup_routes(app):
|
||||
app.router.add_get('/anomalous/gallery_images', gallery_routes.api_get_gallery_images)
|
||||
app.router.add_post('/anomalous/delete_gallery_image', gallery_routes.api_delete_gallery_image)
|
||||
app.router.add_get('/anomalous/output_thumbnail', gallery_routes.api_output_thumbnail)
|
||||
app.router.add_get('/anomalous/output_preview', gallery_routes.api_output_preview)
|
||||
app.router.add_get('/anomalous/kept_images', kept_images.api_kept_images)
|
||||
app.router.add_get('/anomalous/quick_index', quick_index.api_quick_index)
|
||||
app.router.add_get('/anomalous/resolve_hash', model_resolution.api_resolve_hash)
|
||||
|
||||
@@ -19,7 +19,7 @@ STAGES = ("model", "combo", "prompt", "canvas", "run", "picture", "voice", "libr
|
||||
GATES = ("canvas", "gpu", "download", "network", "files")
|
||||
|
||||
# Arguments with which a call also changes the canvas: it then asks as the stricter of its own gate and "canvas".
|
||||
ALSO_CANVAS = {"run_workflow": ("workflow", "prompt", "negative", "seed", "width", "height", "image", "image_node", "values")}
|
||||
ALSO_CANVAS = {"run_workflow": ("workflow", "prompt", "prompts", "negative", "seed", "width", "height", "image", "image_node", "values")}
|
||||
|
||||
TOOLS = {
|
||||
# reading
|
||||
@@ -38,7 +38,6 @@ TOOLS = {
|
||||
"describe_canvas": Entry("look", "canvas", None),
|
||||
"get_situation": Entry("look", None, None), # the task, the canvas in brief and the next step (mcp_situation.py)
|
||||
"check_workflow_models": Entry("look", "model", None),
|
||||
"download_status": Entry("look", "model", None),
|
||||
"list_voices": Entry("look", "voice", None),
|
||||
# acting
|
||||
"set_prompt": Entry("canvas", "prompt", "canvas"),
|
||||
@@ -76,6 +75,7 @@ TOOLS = {
|
||||
"run_status": Entry("look", "run", None),
|
||||
"stop_run": Entry(None, "run", None), # ends a run step of its own; the step says who stopped it
|
||||
"get_toolbox": Entry(None, None, None),
|
||||
"read_guide": Entry("look", None, None), # Anomalous's own manual (mcp_guide.py)
|
||||
# Anomalous's card in the conversation (mcp_apps.py): its own calls, not the AI's steps
|
||||
"app_task_view": Entry(None, None, None),
|
||||
"app_answer": Entry(None, None, None),
|
||||
|
||||
@@ -6,7 +6,7 @@ user set in the Gates window (ai_gates.answer_wait, from when it asked), and whi
|
||||
typing an answer (the page posts POST /anomalous/ai_tasks/{id}/typing as they type) until a while
|
||||
after their last key. Then the AI ends its turn and the user brings it back with a line to paste
|
||||
(web/modules/ai_call_lines.js); or, when the user set so (ai_gates.no_answer "recommended"), each
|
||||
open choice the AI marked a recommended option for takes it, marked as such for the user and the
|
||||
open choice or picks block the AI marked a recommended option for takes it, marked as such for the user and the
|
||||
AI (recommended_on_timeout), and the AI goes on; not once the user began writing (typed since the
|
||||
question came): they stepped away mid-answer, and it waits for them. Typing is kept in memory only.
|
||||
"""
|
||||
@@ -25,11 +25,11 @@ _typed = {} # task id -> time of the user's last key
|
||||
|
||||
|
||||
def take_recommended(task_id, step):
|
||||
"""Answers each open choice of panel step `step` that has a recommended option with it. Returns how many."""
|
||||
"""Answers each open choice or picks block of panel step `step` that has a recommended option with it. Returns how many."""
|
||||
taken = 0
|
||||
for block in step["panel"].get("blocks") or []:
|
||||
picked = [option["id"] for option in block.get("options") or [] if option.get("recommended")]
|
||||
if block.get("type") != "choices" or not picked or block.get("id") in (step.get("answers") or {}):
|
||||
picked = [option["id"] for option in block.get("options") or block.get("items") or [] if option.get("recommended")]
|
||||
if block.get("type") not in ("choices", "picks") or not picked or block.get("id") in (step.get("answers") or {}):
|
||||
continue
|
||||
try:
|
||||
ai_panels.answer(task_id, step["id"], block["id"], choice=picked if block.get("multiple") else picked[:1],
|
||||
|
||||
+210
@@ -0,0 +1,210 @@
|
||||
"""The bases the user picks from (run_workflow base_only; the cut: web/modules/base_only.js). When
|
||||
such a run ends its previews, in ComfyUI's temp folder (emptied whenever ComfyUI starts or stops),
|
||||
become a picks panel. The one the user picks is copied into the output folder, beside where the
|
||||
canvas saves its base, and its seed is held: the task's next run_workflow puts it on that sampler,
|
||||
"fixed", kept under every try (web/modules/mcp_bridge.js). Where the rest of the workflow takes the
|
||||
base's picture, it takes the kept picture and the base is not drawn again: drawn again, even from the
|
||||
same seed and settings, it came out grainy once on a 6 GB card (2026-11-04). A base handed on as a
|
||||
latent (the mid-way switch) is drawn again from its seed; when that run saves its base, the picture is
|
||||
compared with the kept one, so the user and the AI know whether the second pass grew from it. Until newer
|
||||
bases come or the task moves to another canvas, the user may take another of those previews instead
|
||||
(change): it is kept and held the same way, and the AI is told; the earlier copy stays in the output folder."""
|
||||
|
||||
import os
|
||||
import shutil
|
||||
|
||||
from PIL import Image, ImageChops, ImageStat
|
||||
|
||||
from . import ai_outside, ai_panels, ai_pictures, ai_tasks
|
||||
|
||||
SAME_BELOW = 1.0 # mean difference per channel (0-255); the same seed and settings drew the same picture
|
||||
PREFIX = "amb_base/base" # where a picked base goes when the canvas saves none of its own
|
||||
TITLE = "挑一张底图 / Pick a base"
|
||||
QUESTION = ("挑中的那张会存进输出文件夹,下一步用它的种子接着画 / "
|
||||
"The one you pick is kept in the output folder; the next pass grows from its seed")
|
||||
|
||||
|
||||
def _step(task, step_id):
|
||||
return next((item for item in (task or {}).get("steps") or [] if item.get("id") == step_id), None)
|
||||
|
||||
|
||||
def hold_of(task_id):
|
||||
"""The base the user picked in task `task_id`, for run_workflow to hold, or None."""
|
||||
task = ai_tasks.get_task(task_id) if task_id else None
|
||||
return dict((task or {}).get("base_hold") or {}) or None
|
||||
|
||||
|
||||
def forget(task_id):
|
||||
"""New bases are coming: the earlier pick no longer holds."""
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id) if task_id else None
|
||||
if task and task.pop("base_hold", None) is not None:
|
||||
ai_tasks._save(task)
|
||||
|
||||
|
||||
def _seed(prompt, base):
|
||||
inputs = (prompt.get(str(base["node"])) or {}).get("inputs") or {}
|
||||
return next((inputs[name] for name in base.get("names") or [] if isinstance(inputs.get(name), int)), None)
|
||||
|
||||
|
||||
def offer(task_id, step_id, prompts, history):
|
||||
"""A finished base_only run: its previews on a picks panel, each with the seed that drew it."""
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = _step(task, step_id)
|
||||
info = (step or {}).get("bases")
|
||||
if not info or step.get("state") != "done" or step.get("offered"):
|
||||
return None
|
||||
step["offered"] = True
|
||||
client = task.get("client") or "AI"
|
||||
ai_tasks._save(task)
|
||||
items, picks = [], {}
|
||||
for prompt_id in prompts:
|
||||
record = history.get(prompt_id) or {}
|
||||
prompt = (record.get("prompt") or [None, None, {}])[2] or {}
|
||||
base = next((base for base in info["bases"] if _seed(prompt, base) is not None), info["bases"][0])
|
||||
for show in info["shows"]:
|
||||
for image in ((record.get("outputs") or {}).get(str(show)) or {}).get("images") or []:
|
||||
if image.get("type") != "temp" or len(items) >= ai_panels.MAX_ITEMS:
|
||||
continue
|
||||
rel = "/".join(part for part in (image.get("subfolder"), image.get("filename")) if part)
|
||||
seed, item_id = _seed(prompt, base), f"b{len(items) + 1}"
|
||||
items.append({"id": item_id, "image": rel, "folder": "temp", "label": f"{len(items) + 1} · seed {seed}"})
|
||||
picks[item_id] = {"image": rel, "seed": seed, "node": str(base["node"]), "type": base["type"],
|
||||
"names": base.get("names") or [], "show": str(show) if show in info.get("saved", []) else None}
|
||||
if not items:
|
||||
return None
|
||||
block = {"type": "picks", "id": "base", "question": QUESTION, "question_key": "aiBaseQuestion", "items": items}
|
||||
return ai_panels.show(client, TITLE, [block], task_id=task_id, tool="pick_base", title_key="aiBaseTitle",
|
||||
extra={"bases": picks, "prefix": info.get("prefix") or PREFIX})
|
||||
|
||||
|
||||
def prepare(task_id, step_id, choice, skip=False):
|
||||
"""The user's pick on a bases panel, made before it is recorded: the picture copied into the output
|
||||
folder; {} for other panels."""
|
||||
step = _step(ai_tasks.get_task(task_id), step_id)
|
||||
bases = (step or {}).get("bases")
|
||||
if not bases or skip:
|
||||
return {}
|
||||
picked = bases.get(str((choice if isinstance(choice, list) else [choice])[0] if choice else ""))
|
||||
if not picked:
|
||||
return {}
|
||||
import folder_paths
|
||||
|
||||
source = os.path.join(folder_paths.get_temp_directory(), *picked["image"].split("/"))
|
||||
if not os.path.isfile(source):
|
||||
raise ValueError(f"这张预览已经没了:ComfyUI 重启时会清空临时文件夹。让 AI 用种子 {picked['seed']} 再画一次 / "
|
||||
f"This preview is gone: ComfyUI empties its temp folder when it restarts. Ask the AI to draw it "
|
||||
f"again with seed {picked['seed']}")
|
||||
with Image.open(source) as picture:
|
||||
width, height = picture.size
|
||||
folder, name, counter, subfolder, _prefix = folder_paths.get_save_image_path(
|
||||
step.get("prefix") or PREFIX, folder_paths.get_output_directory(), width, height)
|
||||
target = f"{name}_{counter:05}_.png"
|
||||
ai_outside.own(target)
|
||||
shutil.copyfile(source, os.path.join(folder, target)) # its prompt and workflow come with it
|
||||
kept = "/".join(part for part in (subfolder, target) if part)
|
||||
picked = {**picked, "kept": kept, "kept_input": ai_pictures.to_input(kept), "picked_on": step_id} # kept_input: what LoadImage reads
|
||||
return {"base": picked, "kept": kept, "seed": picked["seed"],
|
||||
"note": f"Anomalous keeps this base as {kept} and holds seed {picked['seed']} on node {picked['node']} "
|
||||
"for your next run_workflow: run the whole workflow now; do not change that seed."}
|
||||
|
||||
|
||||
def discard(prepared):
|
||||
"""The copy `prepare` made, when the pick was refused after all (answered already)."""
|
||||
if (prepared or {}).get("base"):
|
||||
import folder_paths
|
||||
|
||||
try:
|
||||
os.remove(os.path.join(folder_paths.get_output_directory(), *prepared["kept"].split("/")))
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def hold(task_id, extra):
|
||||
"""Holds the base the user just picked (prepare's answer) for the task's next runs."""
|
||||
base = (extra or {}).get("base")
|
||||
if not base:
|
||||
return
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if task:
|
||||
task["base_hold"] = {key: base[key] for key in ("node", "type", "names", "seed", "kept", "kept_input", "show", "picked_on")}
|
||||
ai_tasks._save(task)
|
||||
|
||||
|
||||
def change(task_id, step_id, choice):
|
||||
"""Another base taken instead on bases panel `step_id` while the task still holds that panel's pick: kept
|
||||
and held as the first one was, and the AI told. Returns the step's answers."""
|
||||
task = ai_tasks.get_task(task_id)
|
||||
if ((task or {}).get("base_hold") or {}).get("picked_on") != step_id:
|
||||
raise ValueError("这次挑的底图已经换不了了:之后又出了新底图,或任务换了画布 / "
|
||||
"This pick can no longer change: newer bases came since, or the task moved to another canvas")
|
||||
answers = dict(_step(task, step_id).get("answers") or {})
|
||||
if answers.get("base", {}).get("choice") == (choice if isinstance(choice, list) else [choice]):
|
||||
return answers # that one already (a second click)
|
||||
extra = prepare(task_id, step_id, choice)
|
||||
if not extra:
|
||||
raise ValueError("Pick one of the bases")
|
||||
extra["note"] = (f"The user took another base instead: Anomalous now keeps {extra['kept']} and holds seed {extra['seed']} "
|
||||
f"on node {extra['base']['node']} for your next run_workflow. A run started before this still draws from "
|
||||
"the earlier base: run the whole workflow again; do not change that seed.")
|
||||
try:
|
||||
answers = ai_panels.answer(task_id, step_id, "base", choice, extra=extra, again=True)
|
||||
except ai_panels.PanelError:
|
||||
discard(extra)
|
||||
raise
|
||||
hold(task_id, extra)
|
||||
return answers
|
||||
|
||||
|
||||
def same(first, second):
|
||||
"""The mean difference of two pictures per channel (0-255), or None when they differ in size."""
|
||||
with Image.open(first) as a, Image.open(second) as b:
|
||||
if a.size != b.size:
|
||||
return None
|
||||
return sum(ImageStat.Stat(ImageChops.difference(a.convert("RGB"), b.convert("RGB"))).mean) / 3
|
||||
|
||||
|
||||
def check(task_id, step_id, prompts, history):
|
||||
"""A run that held a picked base and saved its base again: is it the picked picture?"""
|
||||
import folder_paths
|
||||
|
||||
with ai_tasks._lock:
|
||||
step = _step(ai_tasks._load().get(task_id), step_id)
|
||||
asked = dict((step or {}).get("base_check") or {})
|
||||
if not asked or "same" in asked:
|
||||
return
|
||||
output = folder_paths.get_output_directory()
|
||||
record = next((history.get(prompt_id) for prompt_id in prompts if history.get(prompt_id)), None) or {}
|
||||
image = next((item for item in ((record.get("outputs") or {}).get(asked["show"]) or {}).get("images") or []
|
||||
if item.get("type") == "output"), None)
|
||||
if not image:
|
||||
return
|
||||
try:
|
||||
diff = same(os.path.join(output, *asked["kept"].split("/")),
|
||||
os.path.join(output, image.get("subfolder") or "", image["filename"]))
|
||||
except OSError:
|
||||
return
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = _step(task, step_id)
|
||||
if step is None:
|
||||
return
|
||||
step["base_check"] = {**asked, "same": diff is not None and diff < SAME_BELOW, "diff": None if diff is None else round(diff, 2)}
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
|
||||
|
||||
def words(run):
|
||||
"""What the AI is told of a run's base: the bases offered, or whether the picked one held."""
|
||||
if run.get("bases") and run.get("state") == "done":
|
||||
return ("Anomalous showed the user these bases to pick from (a panel); get_answers waits for their pick, "
|
||||
"then run the whole workflow: the picked base is kept and its seed held.")
|
||||
check_ = run.get("base_check") or {}
|
||||
if check_.get("same") is True:
|
||||
return f"Its base is the one the user picked ({check_['kept']})."
|
||||
if check_.get("same") is False:
|
||||
return (f"Its base is NOT the one the user picked ({check_['kept']}): something before the second pass changed "
|
||||
"(prompt, model, LoRA, size or a setting). Tell the user (report) before going on.")
|
||||
return ""
|
||||
@@ -11,6 +11,18 @@ _CLIENT_NAMES = (
|
||||
)
|
||||
|
||||
|
||||
# AI apps that read files and run commands by themselves, by a word of what they call themselves.
|
||||
_READS_DISK = ("claude code", "claude-code", "cursor", "windsurf", "codex", "gemini", "antigravity", "vs code", "vscode",
|
||||
"cline", "roo", "kilo", "copilot", "trae", "zed", "opencode", "goose", "aider")
|
||||
|
||||
|
||||
def reads_disk(client):
|
||||
"""Whether AI app `client` (a task's) is known to open files and run commands by itself: privacy mode filters
|
||||
what Anomalous sends it, not what it opens on its own (the output folder, the task records)."""
|
||||
lowered = str(client or "").lower()
|
||||
return any(word in lowered for word in _READS_DISK)
|
||||
|
||||
|
||||
def client_of(headers, params):
|
||||
"""A short name for the AI app making a call: the client info it sends, else its User-Agent."""
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""The darkroom helper, an extension: a model running on this computer reads what a private task
|
||||
keeps from the AI app (ai_privacy.py), so the task still gets a sealed prompt rewritten and its
|
||||
pictures looked at. The user points Anomalous at a server that speaks the OpenAI chat API on a
|
||||
loopback address (Ollama, LM Studio, llama.cpp's server; user/anomalous/ai_darkroom.json {url,
|
||||
model, vision}). Any other address is refused, no proxy is used and no redirect is followed, so
|
||||
what the helper is sent stays on this computer.
|
||||
|
||||
write(): the AI app says how a prompt should change (set_prompt's rewrite); the helper gets the
|
||||
prompt's text and answers the new one, which leaves as a token like any prompt (ai_sealed.py).
|
||||
check_picture(): the helper is shown the picture a private task withholds (zoom_image) and
|
||||
answers a fixed list of checks, each one of a few allowed words (CHECKS); whatever else it says
|
||||
is dropped, so the answer cannot carry what the picture shows."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
|
||||
from . import ai_tasks
|
||||
from .mcp_tools import ToolError, retext
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
HOSTS = ("127.0.0.1", "localhost", "::1")
|
||||
PORTS = (11434, 1234, 8080) # Ollama, LM Studio, llama.cpp's server
|
||||
TIMEOUT = 50 # an AI app waits about a minute for a tool
|
||||
MAX_TEXT = 4000
|
||||
MAX_ANSWER = 2_000_000
|
||||
CHECKS = {"sharpness": ("sharp", "soft", "blurry"), "exposure": ("dark", "ok", "bright"),
|
||||
"hands": ("ok", "wrong", "not_shown"), "face": ("ok", "wrong", "not_shown"),
|
||||
"body": ("ok", "wrong", "not_shown"), "framing": ("ok", "cut_off", "off_centre")}
|
||||
WRITE = ("You rewrite prompts for an image model. Change the prompt as asked and nothing else; keep its form (tags stay "
|
||||
"tags, sentences stay sentences) and its language. Answer with the new prompt only.")
|
||||
LOOK = ("Check this generated picture. Answer with JSON only, for each key one of its words:\n"
|
||||
+ "\n".join(f'"{name}": {" | ".join(words)}' for name, words in CHECKS.items()))
|
||||
THINKING = re.compile(r"<think>.*?</think>", re.DOTALL) # a reasoning model's notes before its answer
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
_opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), _NoRedirect)
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_darkroom.json")
|
||||
|
||||
|
||||
def _local(url):
|
||||
"""`url` without its last slash when it is a server on this computer; ValueError for any other."""
|
||||
parts = urllib.parse.urlsplit(str(url or "").strip())
|
||||
if parts.scheme not in ("http", "https") or parts.hostname not in HOSTS or parts.username or parts.query or parts.fragment:
|
||||
raise ValueError("The helper is a server on this computer, like http://127.0.0.1:11434/v1")
|
||||
return urllib.parse.urlunsplit((parts.scheme, parts.netloc, parts.path.rstrip("/"), "", ""))
|
||||
|
||||
|
||||
def settings():
|
||||
"""{url, model, vision} as the user set them, or None."""
|
||||
try:
|
||||
with open(_path(), encoding="utf-8") as source:
|
||||
kept = json.load(source)
|
||||
return {"url": _local(kept["url"]), "model": str(kept["model"]), "vision": kept.get("vision") is True}
|
||||
except (OSError, ValueError, KeyError, TypeError, AttributeError):
|
||||
return None
|
||||
|
||||
|
||||
def save(url, model, vision=False):
|
||||
if not isinstance(model, str) or not model.strip() or len(model) > 200:
|
||||
raise ValueError("Pick a model")
|
||||
kept = {"url": _local(url), "model": model.strip(), "vision": vision is True}
|
||||
atomic_write_json(_path(), kept)
|
||||
return kept
|
||||
|
||||
|
||||
def turn_off():
|
||||
try:
|
||||
os.remove(_path())
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _ask(url, body=None, timeout=TIMEOUT):
|
||||
request = urllib.request.Request(url, json.dumps(body).encode("utf-8") if body is not None else None, {"Content-Type": "application/json"})
|
||||
with _opener.open(request, timeout=timeout) as response:
|
||||
return json.loads(response.read(MAX_ANSWER).decode("utf-8"))
|
||||
|
||||
|
||||
def models(url):
|
||||
"""The models the server at `url` lists."""
|
||||
listed = _ask(_local(url) + "/models", timeout=3).get("data") or []
|
||||
return [str(item["id"]) for item in listed if isinstance(item, dict) and item.get("id")][:100]
|
||||
|
||||
|
||||
def found():
|
||||
"""The servers answering on this computer's usual ports: [{url, models}]."""
|
||||
servers = []
|
||||
for port in PORTS:
|
||||
url = f"http://127.0.0.1:{port}/v1"
|
||||
try:
|
||||
listed = models(url)
|
||||
except (OSError, ValueError, AttributeError):
|
||||
continue
|
||||
if listed:
|
||||
servers.append({"url": url, "models": listed})
|
||||
return servers
|
||||
|
||||
|
||||
def note():
|
||||
"""What the AI of a private task is told the helper does for it, or None when there is none."""
|
||||
kept = settings()
|
||||
if not kept:
|
||||
return None
|
||||
return ("The user runs a local model that reads what you may not. set_prompt(prompt=\"[P1]\", rewrite=\"what to change\") has it "
|
||||
"rewrite a sealed prompt; the new text is on the canvas and comes back as a token."
|
||||
+ (" zoom_image on a picture answers with its checks (local_check: " + ", ".join(CHECKS) + "), each one of a few fixed words."
|
||||
if kept["vision"] else ""))
|
||||
|
||||
|
||||
def _chat(messages, limit):
|
||||
kept = settings()
|
||||
if not kept:
|
||||
raise ToolError("The user has set up no local helper (the Gates window, Darkroom helper); go on without it.")
|
||||
try:
|
||||
answer = _ask(kept["url"] + "/chat/completions", {"model": kept["model"], "messages": messages, "temperature": 0.2,
|
||||
"max_tokens": limit, "stream": False})
|
||||
text = answer["choices"][0]["message"]["content"]
|
||||
except (OSError, ValueError, KeyError, IndexError, TypeError) as error:
|
||||
raise ToolError(f"The user's local helper did not answer ({type(error).__name__}); go on without it, or tell the user.")
|
||||
return THINKING.sub("", str(text or "")).strip()
|
||||
|
||||
|
||||
def write(prompt, ask):
|
||||
"""`prompt` changed the way `ask` says, by the helper."""
|
||||
text = _chat([{"role": "system", "content": WRITE},
|
||||
{"role": "user", "content": f"Prompt:\n{prompt}\n\nChange it like this: {ask}"}], 1200).strip("`\"' \n")
|
||||
if not text:
|
||||
raise ToolError("The user's local helper gave no prompt; go on without it.")
|
||||
return text[:MAX_TEXT]
|
||||
|
||||
|
||||
def look(image):
|
||||
"""The helper's checks of `image` (an MCP image item): {check: one of its words}, only what CHECKS allows."""
|
||||
text = _chat([{"role": "user", "content": [
|
||||
{"type": "text", "text": LOOK},
|
||||
{"type": "image_url", "image_url": {"url": f"data:{image.get('mimeType') or 'image/webp'};base64,{image.get('data') or ''}"}}]}], 300)
|
||||
said = re.search(r"\{.*\}", text, re.DOTALL)
|
||||
try:
|
||||
said = json.loads(said.group(0)) if said else {}
|
||||
except ValueError:
|
||||
said = {}
|
||||
return {name: said[name] for name, words in CHECKS.items() if isinstance(said, dict) and said.get(name) in words}
|
||||
|
||||
|
||||
def check_picture(result):
|
||||
"""zoom_image's `result` in a private task, before its picture is taken out: the helper's checks with it
|
||||
(local_check), when the user's helper sees pictures."""
|
||||
kept = settings()
|
||||
image = next((item for item in result.get("content") or [] if isinstance(item, dict) and item.get("type") == "image"), None)
|
||||
data = result.get("structuredContent")
|
||||
if not kept or not kept["vision"] or not image or result.get("isError") or not isinstance(data, dict):
|
||||
return result
|
||||
try:
|
||||
checks = look(image) or "the local helper gave no usable answer"
|
||||
except ToolError as error:
|
||||
checks = str(error)
|
||||
retext(result, {**data, "local_check": checks})
|
||||
return result
|
||||
@@ -24,6 +24,8 @@ def log(client, title, decision, reason="", rejected=None, by="user", related=No
|
||||
raise ValueError("title (what was decided about) and decision (what was decided) are needed")
|
||||
if by not in BY:
|
||||
raise ValueError(f"by is one of {', '.join(BY)}")
|
||||
if isinstance(rejected, str): # one option written as a sentence, not a list: its letters are no options
|
||||
rejected = [rejected]
|
||||
rejected = [ai_tasks._short(str(item).strip(), 200) for item in (rejected or []) if str(item).strip()][:MAX_REJECTED]
|
||||
now = time.time() if now is None else now
|
||||
record = {"title": ai_tasks._short(title, 200), "decision": ai_tasks._short(decision, MAX_TEXT),
|
||||
@@ -65,6 +67,8 @@ def decisions(task):
|
||||
out.append({"type": "toolbox", "step": step["id"], "at": step["at"], "mode": box.get("mode", "prefer"), "count": count})
|
||||
elif tool == "user_canvas":
|
||||
out.append({"type": "canvas", "step": step["id"], "at": step["at"], "mode": step.get("canvas")})
|
||||
elif tool == "user_privacy":
|
||||
out.append({"type": "privacy", "step": step["id"], "at": step["at"], "on": bool(step.get("privacy"))})
|
||||
elif tool == "user_stop":
|
||||
out.append({"type": "stop", "step": step["id"], "at": step["at"]})
|
||||
return sorted(out, key=lambda item: item["at"])
|
||||
@@ -96,6 +100,8 @@ def lines(task, limit=12):
|
||||
words.append(f"The user handed over {item['count']} materials ({item['mode']})")
|
||||
elif item["type"] == "canvas":
|
||||
words.append("The user set the canvas mode to " + ("the task's own tab" if item["mode"] == "new" else "their open canvas"))
|
||||
elif item["type"] == "privacy":
|
||||
words.append("The user turned privacy mode " + ("on: no pictures reach you" if item["on"] else "off"))
|
||||
else:
|
||||
words.append("The user stopped a run")
|
||||
return words
|
||||
|
||||
+119
-4
@@ -13,22 +13,56 @@ The same window sets (anomalous/ai_tools.json) how an AI waits for a run (run_wa
|
||||
workbench gives them a line to paste), or "wait", it waits with run_status; how long in all an
|
||||
AI waits for the user's answer to its question (answer_wait, ai_answer_wait.py), and what happens
|
||||
when that time runs out (no_answer): "stop", the AI ends its turn and waits for the user, or
|
||||
"recommended", a choice the AI marked a recommended option for takes it (never a gate's question).
|
||||
"recommended", a choice the AI marked a recommended option for takes it (never a gate's question);
|
||||
which custom nodes the AI may add itself (node_rule): any, or only those of the ready-made ways;
|
||||
a new route (route_rule): an AI placing nodes by itself rather than through a way, card or route
|
||||
builds something nobody tried. "allow" goes on with one reminder in the task that a stronger model
|
||||
suits that better; "ask" waits for the user's OK, once a task. Three safety levels (LEVELS:
|
||||
"careful", "balanced", "free") set those gates, rules, plan_auto and no_answer together.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import action_catalog, ai_tasks
|
||||
from . import action_catalog, ai_plans, ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
POLICIES = ("auto", "ask", "never")
|
||||
RUN_WAITS = ("call", "wait")
|
||||
NODE_RULES = ("any", "ways")
|
||||
ROUTE_RULES = ("allow", "ask")
|
||||
NEW_ROUTE_NOTE = ("AI 在自己搭一条新路线:现成的卡、做法里没有,还没验证过。跑完看看效果,好的话存成卡,以后日常用;也可以让它用最接近的现成路线。 / "
|
||||
"The AI is building a new route of its own: no card or way has it, so it is untried. See how the run turns out; "
|
||||
"if you like it, save it as a card for daily use. Or have it use the nearest route there is.")
|
||||
ANSWER_WAITS = (60, 180, 600) # seconds; the first call waits 30-55 s, get_answers the rest
|
||||
NO_ANSWERS = ("stop", "recommended")
|
||||
DEFAULTS = {"canvas": "auto", "gpu": "auto", "download": "ask", "network": "auto", "files": "ask"}
|
||||
LEVELS = {
|
||||
"careful": {
|
||||
"policies": {"canvas": "ask", "gpu": "ask", "download": "ask", "network": "ask", "files": "ask"},
|
||||
"nodes": "ways",
|
||||
"routes": "ask",
|
||||
"plan_auto": False,
|
||||
"no_answer": "stop",
|
||||
},
|
||||
"balanced": {
|
||||
"policies": dict(DEFAULTS),
|
||||
"nodes": "any",
|
||||
"routes": "allow",
|
||||
"plan_auto": False,
|
||||
"no_answer": "stop",
|
||||
},
|
||||
"free": {
|
||||
"policies": {"canvas": "auto", "gpu": "auto", "download": "auto", "network": "auto", "files": "ask"},
|
||||
"nodes": "any",
|
||||
"routes": "allow",
|
||||
"plan_auto": True,
|
||||
"no_answer": "recommended",
|
||||
},
|
||||
}
|
||||
WAIT_SECONDS = 50 # below the minute many AI apps give a tool call
|
||||
ALLOWED_SECONDS = 30 * 60 # an Allow the AI did not wait for holds this long for the same call
|
||||
MAX_ARGS = 8
|
||||
@@ -91,6 +125,52 @@ def set_run_wait(mode):
|
||||
return _set_tool_setting("runs", mode)
|
||||
|
||||
|
||||
def node_rule():
|
||||
"""Which custom nodes an AI adds itself: "any", or "ways": only those the ready-made ways build
|
||||
(reference_blocks.node_types) and those the user picked for the task (mcp_canvas_edit._allowed)."""
|
||||
chosen = _tool_settings().get("nodes")
|
||||
return chosen if chosen in NODE_RULES else "any"
|
||||
|
||||
|
||||
def set_node_rule(mode):
|
||||
if mode not in NODE_RULES:
|
||||
raise ValueError("nodes is any or ways")
|
||||
return _set_tool_setting("nodes", mode)
|
||||
|
||||
|
||||
def route_rule():
|
||||
"""An AI placing nodes by itself: "allow" (with a reminder) or "ask" (the user's OK, once a task)."""
|
||||
chosen = _tool_settings().get("routes")
|
||||
return chosen if chosen in ROUTE_RULES else "allow"
|
||||
|
||||
|
||||
def set_route_rule(mode):
|
||||
if mode not in ROUTE_RULES:
|
||||
raise ValueError("routes is allow or ask")
|
||||
return _set_tool_setting("routes", mode)
|
||||
|
||||
|
||||
def _new_route(tool, arguments):
|
||||
"""Whether this call places nodes the AI chose itself: not a way, a card or a route."""
|
||||
return tool == "add_node" or (tool in ("add_nodes", "new_workflow") and bool((arguments or {}).get("nodes")))
|
||||
|
||||
|
||||
def _route_gate(tool, arguments, step_ref, now):
|
||||
"""True when this call must wait for the user's OK on a new route; else the task's one reminder is left there."""
|
||||
if not _new_route(tool, arguments):
|
||||
return False
|
||||
with _decided:
|
||||
task, _step_now = _step(step_ref)
|
||||
steps = (task or {}).get("steps") or []
|
||||
if route_rule() == "ask":
|
||||
return not any((step.get("gate") or {}).get("gate") == "route" and (step.get("gate") or {}).get("decision") == "allow" for step in steps)
|
||||
if task and not any(step.get("tool") == "new_route" for step in steps):
|
||||
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "new_route", "kind": "note", "ok": True,
|
||||
"state": "done", "summary": "new_route", "text": NEW_ROUTE_NOTE, "results": []})
|
||||
ai_tasks._save(task)
|
||||
return False
|
||||
|
||||
|
||||
def answer_wait():
|
||||
"""How long in all an AI waits for the user's answer, in seconds (one of ANSWER_WAITS)."""
|
||||
chosen = _tool_settings().get("answer_wait")
|
||||
@@ -115,6 +195,39 @@ def set_no_answer(mode):
|
||||
return _set_tool_setting("no_answer", mode)
|
||||
|
||||
|
||||
def level():
|
||||
"""Which safety level the current settings match (careful / balanced / free), or "custom"."""
|
||||
with _decided:
|
||||
current = {
|
||||
"policies": policies(),
|
||||
"nodes": node_rule(),
|
||||
"routes": route_rule(),
|
||||
"plan_auto": ai_plans.auto(),
|
||||
"no_answer": no_answer(),
|
||||
}
|
||||
for name, spec in LEVELS.items():
|
||||
if current == spec:
|
||||
return name
|
||||
return "custom"
|
||||
|
||||
|
||||
def set_level(name):
|
||||
"""Apply all settings of safety level `name`, leaving unrelated settings alone."""
|
||||
if not isinstance(name, str) or name not in LEVELS:
|
||||
raise ValueError("level is careful, balanced or free")
|
||||
spec = LEVELS[name]
|
||||
with _decided:
|
||||
atomic_write_json(_path(), dict(spec["policies"]))
|
||||
atomic_write_json(_tools_path(), {
|
||||
**_tool_settings(),
|
||||
"nodes": spec["nodes"],
|
||||
"routes": spec["routes"],
|
||||
"no_answer": spec["no_answer"],
|
||||
})
|
||||
ai_plans.set_auto(spec["plan_auto"])
|
||||
return name
|
||||
|
||||
|
||||
def describe(tool, arguments):
|
||||
"""What a call would do, as [{label, value}] for the card the user answers."""
|
||||
out = []
|
||||
@@ -148,12 +261,14 @@ def check(tool, arguments, step_ref, now=None):
|
||||
strict = {"auto": 0, "ask": 1, "never": 2}
|
||||
gate = max((gate, "canvas"), key=lambda name: strict.get(policies().get(name, "auto"), 0))
|
||||
policy = policies().get(gate, "auto") if gate else "auto"
|
||||
now = time.time() if now is None else now
|
||||
if policy == "auto" and _route_gate(tool, arguments, step_ref, now):
|
||||
policy, gate = "ask", "route"
|
||||
if policy == "auto":
|
||||
return None
|
||||
if policy == "never":
|
||||
return _blocked(f"The user does not let AI apps do this ({gate}) through Anomalous; tell them what you "
|
||||
"wanted to do instead, or ask them to change it in the AI workbench (Gates).")
|
||||
now = time.time() if now is None else now
|
||||
signature = _signature(tool, arguments)
|
||||
with _decided:
|
||||
task, step = _step(step_ref)
|
||||
@@ -216,7 +331,7 @@ def decide(task_id, step_id, decision, text="", remember=False, now=None):
|
||||
ai_tasks._save(task)
|
||||
_decided.notify_all()
|
||||
if remember and decision == "allow":
|
||||
set_policy(asked["gate"], "auto")
|
||||
set_route_rule("allow") if asked["gate"] == "route" else set_policy(asked["gate"], "auto")
|
||||
ai_tasks._notify(task_id)
|
||||
from . import ai_panels
|
||||
ai_panels.wake()
|
||||
|
||||
+8
-2
@@ -19,7 +19,7 @@ import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from collections import OrderedDict
|
||||
from collections import OrderedDict, deque
|
||||
|
||||
import folder_paths
|
||||
|
||||
@@ -36,6 +36,12 @@ MAX_TEXT, MAX_FILES, MAX_FILE = 300, 20, 200
|
||||
|
||||
_lock = threading.Lock()
|
||||
_watched = OrderedDict() # task id -> {"since": time of the last full check, "told": {(folder, name, changed)}}
|
||||
_own = deque(maxlen=200) # pictures Anomalous itself put at the top of the output folder
|
||||
|
||||
|
||||
def own(name):
|
||||
"""Picture `name` was put there by Anomalous (a base the user picked, ai_bases.py): no news."""
|
||||
_own.append(name)
|
||||
|
||||
|
||||
def _queue():
|
||||
@@ -120,7 +126,7 @@ def check(task_id, now=None):
|
||||
for name, change, when in _changes(folder, root, watch["since"]):
|
||||
if busy and change != "rewritten":
|
||||
continue # maybe the running prompt's: judged once it ends
|
||||
if change != "rewritten" and name in saved:
|
||||
if change != "rewritten" and (name in saved or name in _own):
|
||||
continue
|
||||
if (folder, name, when) not in watch["told"]:
|
||||
watch["told"].add((folder, name, when))
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""The parts of the blocks the AI sends to a panel (ai_panels.py), each checked to one plain shape:
|
||||
text and lists of bounded size, fractions of a picture, marks (circles or boxes with a label),
|
||||
the part of the pictures shown enlarged (zoom), pictures only from ComfyUI's output folder,
|
||||
diagram boxes and arrows, and model cards only from the user's folders."""
|
||||
|
||||
import os
|
||||
|
||||
from . import ai_tasks
|
||||
|
||||
MAX_ITEMS = 12
|
||||
COLORS = ("red", "green", "blue", "yellow", "purple")
|
||||
MAX_MARKS = 8
|
||||
MAX_NODES = 24
|
||||
MAX_EDGES = 40
|
||||
|
||||
|
||||
class PanelError(ValueError):
|
||||
"""What is wrong with a panel, for the AI to fix."""
|
||||
|
||||
|
||||
def _text(value, limit, where, required=False):
|
||||
if value is None and not required:
|
||||
return ""
|
||||
if not isinstance(value, (str, int, float)) or isinstance(value, bool):
|
||||
raise PanelError(f"{where} must be text")
|
||||
text = str(value).strip()
|
||||
if required and not text:
|
||||
raise PanelError(f"{where} is empty")
|
||||
return text[:limit]
|
||||
|
||||
|
||||
def _list(value, limit, where):
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise PanelError(f"{where} must be a list")
|
||||
if len(value) > limit:
|
||||
raise PanelError(f"{where} has more than {limit} items")
|
||||
return value
|
||||
|
||||
|
||||
def _fraction(value, where):
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool) or not 0 <= value <= 1:
|
||||
raise PanelError(f"{where} must be a number from 0 to 1 (a fraction of the picture)")
|
||||
return round(float(value), 4)
|
||||
|
||||
|
||||
def _marks(value, where):
|
||||
marks = []
|
||||
for i, mark in enumerate(_list(value, MAX_MARKS, where)):
|
||||
here = f"{where}[{i}]"
|
||||
if not isinstance(mark, dict):
|
||||
raise PanelError(f"{here} must be {{x, y, w, h | r, label, color}}")
|
||||
out = {"x": _fraction(mark.get("x"), f"{here}.x"), "y": _fraction(mark.get("y"), f"{here}.y"),
|
||||
"label": _text(mark.get("label"), 60, f"{here}.label"),
|
||||
"color": mark.get("color") if mark.get("color") in COLORS else "red"}
|
||||
if mark.get("w") is not None or mark.get("h") is not None:
|
||||
out.update(w=_fraction(mark.get("w"), f"{here}.w"), h=_fraction(mark.get("h"), f"{here}.h"))
|
||||
else:
|
||||
out["r"] = _fraction(mark.get("r", 0.06), f"{here}.r")
|
||||
marks.append(out)
|
||||
return marks
|
||||
|
||||
|
||||
def _image(item, where):
|
||||
image = item.get("image") if isinstance(item, dict) else item
|
||||
temp = isinstance(item, dict) and item.get("folder") == "temp" # a run's preview (run_status previews)
|
||||
found = ai_tasks._output_media(str(image or ""), "temp" if temp else "output")
|
||||
if not found:
|
||||
raise PanelError(f"{where}: {image!r} is not a picture in ComfyUI's {'temp' if temp else 'output'} folder "
|
||||
"(subfolder/name.png, as search_images or run_status gives it)")
|
||||
out = {**found, "caption": _text(item.get("caption") if isinstance(item, dict) else "", 120, f"{where}.caption")}
|
||||
if isinstance(item, dict) and item.get("marks"):
|
||||
out["marks"] = _marks(item["marks"], f"{where}.marks")
|
||||
return out
|
||||
|
||||
|
||||
def _zoom(value, where):
|
||||
"""The part of every picture of a slider or picks block shown enlarged: {x, y, w, h} as fractions,
|
||||
x/y its centre (as a mark's box), kept inside the picture: {left, top, w, h}."""
|
||||
try:
|
||||
x, y, w, h = (float(value[key]) for key in ("x", "y", "w", "h"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
raise PanelError(f"{where} must be {{x, y, w, h}}: fractions 0-1 of the picture, x/y the centre")
|
||||
if not (0 <= x <= 1 and 0 <= y <= 1 and 0.02 <= w <= 1 and 0.02 <= h <= 1):
|
||||
raise PanelError(f"{where}: x and y 0-1, w and h 0.02-1")
|
||||
return {"left": round(min(max(x - w / 2, 0), 1 - w), 4), "top": round(min(max(y - h / 2, 0), 1 - h), 4),
|
||||
"w": round(w, 4), "h": round(h, 4)}
|
||||
|
||||
|
||||
def _diagram(block, where):
|
||||
nodes, ids = [], set()
|
||||
for i, node in enumerate(_list(block.get("nodes"), MAX_NODES, f"{where}.nodes")):
|
||||
here = f"{where}.nodes[{i}]"
|
||||
if not isinstance(node, dict):
|
||||
raise PanelError(f"{here} must be {{id, label, detail, node_id}}")
|
||||
node_id = _text(node.get("id") or f"n{i + 1}", 40, f"{here}.id")
|
||||
if node_id in ids:
|
||||
raise PanelError(f"{here}.id {node_id!r} twice")
|
||||
ids.add(node_id)
|
||||
out = {"id": node_id, "label": _text(node.get("label"), 40, f"{here}.label", True),
|
||||
"detail": _text(node.get("detail"), 80, f"{here}.detail"),
|
||||
"color": node.get("color") if node.get("color") in COLORS else ""}
|
||||
if node.get("node_id") not in (None, ""):
|
||||
out["node_id"] = _text(node.get("node_id"), 20, f"{here}.node_id")
|
||||
nodes.append(out)
|
||||
if not nodes:
|
||||
raise PanelError(f"{where}.nodes is empty")
|
||||
edges = []
|
||||
for i, edge in enumerate(_list(block.get("edges"), MAX_EDGES, f"{where}.edges")):
|
||||
here = f"{where}.edges[{i}]"
|
||||
if not isinstance(edge, dict) or edge.get("from") not in ids or edge.get("to") not in ids:
|
||||
raise PanelError(f"{here} must be {{from, to, label}} naming two node ids")
|
||||
edges.append({"from": edge["from"], "to": edge["to"], "label": _text(edge.get("label"), 30, f"{here}.label")})
|
||||
return {"nodes": nodes, "edges": edges, "direction": "down" if block.get("direction") == "down" else "right"}
|
||||
|
||||
|
||||
def _model(item, where):
|
||||
from .mcp_tools import ToolError, _resolve_model
|
||||
from .model_media import _preview_url_for_model
|
||||
|
||||
model_id = item.get("model_id") if isinstance(item, dict) else item
|
||||
try:
|
||||
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
|
||||
except ToolError as error:
|
||||
raise PanelError(f"{where}: {error}")
|
||||
try:
|
||||
cover = _preview_url_for_model(folder_type, path_idx, base_dir, file_path) or ""
|
||||
except Exception: # noqa: BLE001 - a card without its cover is still a card
|
||||
cover = ""
|
||||
return {"model_id": str(model_id), "folder": folder_type, "path_idx": path_idx,
|
||||
"rel": os.path.relpath(file_path, base_dir).replace(os.sep, "/"), "name": os.path.basename(file_path),
|
||||
"cover": cover, "note": _text(item.get("note") if isinstance(item, dict) else "", 160, f"{where}.note")}
|
||||
+80
-151
@@ -7,10 +7,11 @@ at places given as fractions of the picture), slider (two pictures over each oth
|
||||
drag between them), picks (pictures with a reason and "take this one"), models (the user's, by
|
||||
model_id), diagram (boxes and arrows, laid out by the page; a box can name a canvas node),
|
||||
table, list, params, choices (buttons; one or several picks) and input (a line of text),
|
||||
progress, divider; and area (the user's repaint area beside the AI's, ai_repaint.py). Nothing the AI sends is run or rendered as markup, and no drawing of its own
|
||||
prompts (the user's prompt box: texts and reference pictures the AI gets as tokens, ai_sealed.py);
|
||||
and area (the user's repaint area beside the AI's, ai_repaint.py). Nothing the AI sends is run or rendered as markup, and no drawing of its own
|
||||
(SVG) is taken: every block is checked here to be one of these shapes, with plain strings and
|
||||
numbers of bounded size, pictures only from the output folder and models only from the user's
|
||||
folders. The page draws them (ui_ai_panel.js).
|
||||
folders (the parts: ai_panel_parts.py). The page draws them (ui_ai_panel.js).
|
||||
|
||||
The user's answers come back through POST /anomalous/ai_tasks/{task}/answer and wait in the
|
||||
step until the AI asks for them (get_answers, which may wait a while for one) or makes its next
|
||||
@@ -19,150 +20,40 @@ successful MCP tool call (attach_pending). The user can also write to the AI in
|
||||
the same way.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import ai_marks, ai_tasks
|
||||
from .ai_panel_parts import ( # noqa: F401 - PanelError and the limits are read as ai_panels.*
|
||||
MAX_ITEMS, MAX_NODES, PanelError, _diagram, _image, _list, _model, _text, _zoom,
|
||||
)
|
||||
from .mcp_tools import tool_text
|
||||
|
||||
MAX_BLOCKS = 30
|
||||
MAX_ITEMS = 12
|
||||
MAX_ROWS = 30
|
||||
MAX_COLUMNS = 8
|
||||
MAX_WAIT = 55
|
||||
TONES = ("normal", "muted", "tip", "warning")
|
||||
LAYOUTS = ("row", "grid", "compare")
|
||||
INTERACTIVE = ("choices", "input", "picks")
|
||||
INTERACTIVE = ("choices", "input", "picks", "prompts", "plan")
|
||||
MAX_PROMPT_BOXES = 8
|
||||
INPAINT_NOTE = (
|
||||
"inpaint_image is the picture with the area to repaint: LoadImage(image=inpaint_image) gives the picture and, as MASK, that area. "
|
||||
"inpaint_image is already in ComfyUI's input folder, ready: do not check it with a command; "
|
||||
"get_image_info(image=inpaint_image, folder='input', include_image=true) shows it and the area, zoom_image a part "
|
||||
"of it larger. Repaint with a workflow; do not paint the picture with code of your own. "
|
||||
"Use the picture's own model and prompt (get_image_info on image) and build it with add_node and its wiring "
|
||||
"(in the picture's workflow, LoadImage -> VAEEncode(pixels, vae) -> SetLatentNoiseMask(samples, mask) in place "
|
||||
"of the empty latent), e.g. new_workflow: CheckpointLoaderSimple, "
|
||||
"LoadImage, CLIPTextEncode x2, VAEEncode(pixels, vae) -> SetLatentNoiseMask(samples, mask) -> KSampler "
|
||||
"(denoise 0.5-0.8 keeps the look; 1.0 with VAEEncodeForInpaint(grow_mask_by 8) replaces it) -> VAEDecode -> "
|
||||
"SaveImage; GrowMask softens the edge. Say in report what you set and why, ask_user the strength if unsure, "
|
||||
"and show the old and new picture with a slider panel."
|
||||
"inpaint_image is the picture with the area to repaint as its alpha, already in ComfyUI's input folder: do not "
|
||||
"check it with a command (get_image_info(image=inpaint_image, folder='input', include_image=true) shows it). "
|
||||
"Repaint with a workflow, never by painting the file with code. On a workflow that makes this picture (its model "
|
||||
"and prompt: get_image_info on the picture says them), add_nodes(use=\"repaint\", image=inpaint_image) "
|
||||
"builds the repaint in one call, wired into its sampler (the best repaint this ComfyUI can run); set_prompt "
|
||||
"the sampler's prompt to what belongs in the area (\"a hand, five fingers\"), not the whole picture. Keep the block's low denoise (it keeps the thing's size; higher paints a new one filling the whole "
|
||||
"area). run_workflow with batch 4 (each try gets its own seed) and let the user pick in a picks panel zoomed on the "
|
||||
"area (zoom {x, y, w, h} around it), then show the old and new with a slider panel, zoomed the same. Say in "
|
||||
"report what you set and why."
|
||||
)
|
||||
COLORS = ("red", "green", "blue", "yellow", "purple")
|
||||
MAX_MARKS = 8
|
||||
MAX_NODES = 24
|
||||
MAX_EDGES = 40
|
||||
|
||||
_answered = threading.Condition(ai_tasks._lock)
|
||||
|
||||
|
||||
class PanelError(ValueError):
|
||||
"""What is wrong with a panel, for the AI to fix."""
|
||||
|
||||
|
||||
def _text(value, limit, where, required=False):
|
||||
if value is None and not required:
|
||||
return ""
|
||||
if not isinstance(value, (str, int, float)) or isinstance(value, bool):
|
||||
raise PanelError(f"{where} must be text")
|
||||
text = str(value).strip()
|
||||
if required and not text:
|
||||
raise PanelError(f"{where} is empty")
|
||||
return text[:limit]
|
||||
|
||||
|
||||
def _list(value, limit, where):
|
||||
if value is None:
|
||||
return []
|
||||
if not isinstance(value, list):
|
||||
raise PanelError(f"{where} must be a list")
|
||||
if len(value) > limit:
|
||||
raise PanelError(f"{where} has more than {limit} items")
|
||||
return value
|
||||
|
||||
|
||||
def _fraction(value, where):
|
||||
if not isinstance(value, (int, float)) or isinstance(value, bool) or not 0 <= value <= 1:
|
||||
raise PanelError(f"{where} must be a number from 0 to 1 (a fraction of the picture)")
|
||||
return round(float(value), 4)
|
||||
|
||||
|
||||
def _marks(value, where):
|
||||
marks = []
|
||||
for i, mark in enumerate(_list(value, MAX_MARKS, where)):
|
||||
here = f"{where}[{i}]"
|
||||
if not isinstance(mark, dict):
|
||||
raise PanelError(f"{here} must be {{x, y, w, h | r, label, color}}")
|
||||
out = {"x": _fraction(mark.get("x"), f"{here}.x"), "y": _fraction(mark.get("y"), f"{here}.y"),
|
||||
"label": _text(mark.get("label"), 60, f"{here}.label"),
|
||||
"color": mark.get("color") if mark.get("color") in COLORS else "red"}
|
||||
if mark.get("w") is not None or mark.get("h") is not None:
|
||||
out.update(w=_fraction(mark.get("w"), f"{here}.w"), h=_fraction(mark.get("h"), f"{here}.h"))
|
||||
else:
|
||||
out["r"] = _fraction(mark.get("r", 0.06), f"{here}.r")
|
||||
marks.append(out)
|
||||
return marks
|
||||
|
||||
|
||||
def _image(item, where):
|
||||
image = item.get("image") if isinstance(item, dict) else item
|
||||
found = ai_tasks._output_media(str(image or ""))
|
||||
if not found:
|
||||
raise PanelError(f"{where}: {image!r} is not a picture in ComfyUI's output folder (subfolder/name.png, as search_images gives it)")
|
||||
out = {**found, "caption": _text(item.get("caption") if isinstance(item, dict) else "", 120, f"{where}.caption")}
|
||||
if isinstance(item, dict) and item.get("marks"):
|
||||
out["marks"] = _marks(item["marks"], f"{where}.marks")
|
||||
return out
|
||||
|
||||
|
||||
def _diagram(block, where):
|
||||
nodes, ids = [], set()
|
||||
for i, node in enumerate(_list(block.get("nodes"), MAX_NODES, f"{where}.nodes")):
|
||||
here = f"{where}.nodes[{i}]"
|
||||
if not isinstance(node, dict):
|
||||
raise PanelError(f"{here} must be {{id, label, detail, node_id}}")
|
||||
node_id = _text(node.get("id") or f"n{i + 1}", 40, f"{here}.id")
|
||||
if node_id in ids:
|
||||
raise PanelError(f"{here}.id {node_id!r} twice")
|
||||
ids.add(node_id)
|
||||
out = {"id": node_id, "label": _text(node.get("label"), 40, f"{here}.label", True),
|
||||
"detail": _text(node.get("detail"), 80, f"{here}.detail"),
|
||||
"color": node.get("color") if node.get("color") in COLORS else ""}
|
||||
if node.get("node_id") not in (None, ""):
|
||||
out["node_id"] = _text(node.get("node_id"), 20, f"{here}.node_id")
|
||||
nodes.append(out)
|
||||
if not nodes:
|
||||
raise PanelError(f"{where}.nodes is empty")
|
||||
edges = []
|
||||
for i, edge in enumerate(_list(block.get("edges"), MAX_EDGES, f"{where}.edges")):
|
||||
here = f"{where}.edges[{i}]"
|
||||
if not isinstance(edge, dict) or edge.get("from") not in ids or edge.get("to") not in ids:
|
||||
raise PanelError(f"{here} must be {{from, to, label}} naming two node ids")
|
||||
edges.append({"from": edge["from"], "to": edge["to"], "label": _text(edge.get("label"), 30, f"{here}.label")})
|
||||
return {"nodes": nodes, "edges": edges, "direction": "down" if block.get("direction") == "down" else "right"}
|
||||
|
||||
|
||||
def _model(item, where):
|
||||
from .mcp_tools import ToolError, _resolve_model
|
||||
from .model_media import _preview_url_for_model
|
||||
|
||||
model_id = item.get("model_id") if isinstance(item, dict) else item
|
||||
try:
|
||||
folder_type, path_idx, base_dir, file_path = _resolve_model(model_id)
|
||||
except ToolError as error:
|
||||
raise PanelError(f"{where}: {error}")
|
||||
try:
|
||||
cover = _preview_url_for_model(folder_type, path_idx, base_dir, file_path) or ""
|
||||
except Exception: # noqa: BLE001 - a card without its cover is still a card
|
||||
cover = ""
|
||||
return {"model_id": str(model_id), "folder": folder_type, "path_idx": path_idx,
|
||||
"rel": os.path.relpath(file_path, base_dir).replace(os.sep, "/"), "name": os.path.basename(file_path),
|
||||
"cover": cover, "note": _text(item.get("note") if isinstance(item, dict) else "", 160, f"{where}.note")}
|
||||
|
||||
|
||||
def clean_block(block, at, taken):
|
||||
"""One block as the page may draw it, or PanelError."""
|
||||
where = f"blocks[{at}]"
|
||||
@@ -185,11 +76,13 @@ def clean_block(block, at, taken):
|
||||
out.update(before=_image(block.get("before"), f"{where}.before"), after=_image(block.get("after"), f"{where}.after"),
|
||||
before_label=_text(block.get("before_label"), 40, f"{where}.before_label"),
|
||||
after_label=_text(block.get("after_label"), 40, f"{where}.after_label"))
|
||||
if block.get("zoom"):
|
||||
out["zoom"] = _zoom(block["zoom"], f"{where}.zoom")
|
||||
elif kind == "picks":
|
||||
items, ids = [], set()
|
||||
raw = _list(block.get("items"), MAX_ITEMS, f"{where}.items")
|
||||
if len(raw) < 2:
|
||||
raise PanelError(f"{where}.items needs 2 to {MAX_ITEMS} pictures")
|
||||
if not raw: # one is "take this one" (a single base, ai_bases.py)
|
||||
raise PanelError(f"{where}.items needs 1 to {MAX_ITEMS} pictures")
|
||||
for i, item in enumerate(raw):
|
||||
here = f"{where}.items[{i}]"
|
||||
if not isinstance(item, dict):
|
||||
@@ -203,6 +96,10 @@ def clean_block(block, at, taken):
|
||||
"reason": _text(item.get("reason"), 200, f"{here}.reason"), "recommended": item.get("recommended") is True})
|
||||
out.update(question=_text(block.get("question"), 200, f"{where}.question"), items=items,
|
||||
multiple=block.get("multiple") is True)
|
||||
if _page_word(block.get("question_key")):
|
||||
out["question_key"] = block["question_key"]
|
||||
if block.get("zoom"):
|
||||
out["zoom"] = _zoom(block["zoom"], f"{where}.zoom")
|
||||
elif kind == "run_map": # a run's real shape (ai_run_map.py), drawn by the page from the step; only show_run_map makes it
|
||||
out["step"] = _text(block.get("step"), 20, f"{where}.step", True)
|
||||
elif kind == "area": # the user's repaint area and the AI's, pictures in the task's marks folder; only ai_repaint makes it
|
||||
@@ -255,23 +152,27 @@ def clean_block(block, at, taken):
|
||||
ids.add(option_id)
|
||||
options.append({"id": option_id, "label": label, "detail": _text(option.get("detail"), 200, f"{where}.options[{i}].detail"),
|
||||
**({"recommended": True} if option.get("recommended") is True else {}),
|
||||
**({"label_key": option["label_key"]} if re.fullmatch(r"ai[A-Z]\w{1,40}", str(option.get("label_key"))) else {})})
|
||||
**({key: option[key] for key in ("label_key", "detail_key") if _page_word(option.get(key))}),
|
||||
**({"values": {str(key): _text(value, 120, f"{where}.options[{i}].values") for key, value in option["values"].items()}}
|
||||
if isinstance(option.get("values"), dict) and _page_word(option.get("label_key")) else {}),
|
||||
**({"sample": option["sample"]} if re.fullmatch(r"[a-z_]{1,40}", str(option.get("sample"))) else {})})
|
||||
# other: a line for an answer none of the options is
|
||||
out.update(question=_text(block.get("question"), 200, f"{where}.question"), options=options,
|
||||
multiple=block.get("multiple") is True, other=block.get("other") is True)
|
||||
elif kind == "input":
|
||||
out.update(question=_text(block.get("question"), 200, f"{where}.question", True),
|
||||
placeholder=_text(block.get("placeholder"), 80, f"{where}.placeholder"))
|
||||
elif kind == "progress":
|
||||
value, top = block.get("value"), block.get("max", 100)
|
||||
if not all(isinstance(n, (int, float)) and not isinstance(n, bool) for n in (value, top)) or top <= 0:
|
||||
raise PanelError(f"{where}: value and max must be numbers, max above 0")
|
||||
out.update(label=_text(block.get("label"), 120, f"{where}.label"), value=max(0, min(value, top)), max=top)
|
||||
elif kind == "divider":
|
||||
pass
|
||||
elif kind == "prompts": # filled in Anomalous only: the texts are sealed and the AI gets tokens
|
||||
count = block.get("count", 1)
|
||||
if not isinstance(count, int) or isinstance(count, bool) or not 1 <= count <= MAX_PROMPT_BOXES:
|
||||
raise PanelError(f"{where}.count: 1 to {MAX_PROMPT_BOXES} prompts")
|
||||
out.update(question=_text(block.get("question"), 200, f"{where}.question"), count=count,
|
||||
negative=block.get("negative") is True, pictures=block.get("pictures") is True)
|
||||
elif kind == "plan": # the steps of an AI's plan, ready-made blocks; only mcp_plans makes it
|
||||
out["steps"] = [_plan_step(step, f"{where}.steps[{at}]") for at, step in enumerate(_list(block.get("steps"), 6, f"{where}.steps"))]
|
||||
else:
|
||||
raise PanelError(f"{where}.type {kind!r} is not one of heading, text, images, slider, picks, models, diagram, "
|
||||
"table, list, params, choices, input, progress, divider")
|
||||
"table, list, params, choices, input, prompts")
|
||||
if kind in INTERACTIVE or block.get("id") is not None:
|
||||
block_id = _text(block.get("id") or f"q{at + 1}", 40, f"{where}.id")
|
||||
if block_id in taken:
|
||||
@@ -281,6 +182,19 @@ def clean_block(block, at, taken):
|
||||
return out
|
||||
|
||||
|
||||
def _plan_step(step, where):
|
||||
from .reference_blocks import BLOCKS
|
||||
|
||||
if not isinstance(step, dict) or step.get("block") not in BLOCKS:
|
||||
raise PanelError(f"{where}: a plan's step is one of Anomalous's ready-made blocks")
|
||||
out = {"block": step["block"], "title": BLOCKS[step["block"]]["title"], "model": step.get("model") and str(step["model"])[:300]}
|
||||
if step.get("model"):
|
||||
out.update(model_name=_text(step.get("model_name") or step["model"], 200, f"{where}.model_name"),
|
||||
choices=[{"model_id": str(item["model_id"])[:300], "name": _text(item.get("name") or item["model_id"], 200, f"{where}.choices")}
|
||||
for item in (step.get("choices") or [])[:12] if isinstance(item, dict) and item.get("model_id")])
|
||||
return out
|
||||
|
||||
|
||||
def clean_blocks(blocks):
|
||||
blocks = _list(blocks, MAX_BLOCKS, "blocks")
|
||||
if not blocks:
|
||||
@@ -319,11 +233,6 @@ def _merge_blocks(existing, incoming):
|
||||
# A client may send its entire previous layout again. That cannot reorder it.
|
||||
at = next((i for i, old in enumerate(existing) if i not in used
|
||||
and {key: value for key, value in old.items() if key != "id"} == block), None)
|
||||
if at is None and block["type"] == "progress":
|
||||
matches = [i for i, old in enumerate(existing) if old["type"] == "progress"
|
||||
and old.get("label") == block.get("label")]
|
||||
if len(matches) == 1:
|
||||
at = matches[0]
|
||||
if at is None:
|
||||
merged.append(block)
|
||||
else:
|
||||
@@ -345,9 +254,15 @@ def _find(panel_id):
|
||||
return None, None
|
||||
|
||||
|
||||
def show(client, title, blocks, now=None, session="", task_id=None, tool="show_panel", extra=None):
|
||||
def _page_word(key):
|
||||
"""A word of the page (locales.js) Anomalous's own panels name, shown in the user's language."""
|
||||
return bool(re.fullmatch(r"ai[A-Z]\w{1,40}", str(key)))
|
||||
|
||||
|
||||
def show(client, title, blocks, now=None, session="", task_id=None, tool="show_panel", extra=None, title_key=None):
|
||||
title = _text(title, 120, "title", True)
|
||||
panel = {"id": uuid.uuid4().hex[:10], "title": title, "blocks": _block_ids(clean_blocks(blocks)), "closed": False}
|
||||
panel = {"id": uuid.uuid4().hex[:10], "title": title, "blocks": _block_ids(clean_blocks(blocks)), "closed": False,
|
||||
**({"title_key": title_key} if _page_word(title_key) else {})}
|
||||
now = time.time() if now is None else now
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": tool, "kind": "panel", "ok": True,
|
||||
"summary": f"{tool}: {title}", "panel": panel, "answers": {}, "results": [], **(extra or {})}
|
||||
@@ -370,7 +285,6 @@ def update(panel_id, title=None, blocks=None, closed=None):
|
||||
panel["title"] = _text(title, 120, "title", True)
|
||||
if blocks is not None:
|
||||
panel["blocks"] = _merge_blocks(panel["blocks"], blocks)
|
||||
panel["changed"] = time.time() # when its numbers were written: the page greys a progress bar the task went past
|
||||
if closed is not None:
|
||||
panel["closed"] = bool(closed)
|
||||
step["panel"] = panel
|
||||
@@ -380,9 +294,10 @@ def update(panel_id, title=None, blocks=None, closed=None):
|
||||
return panel
|
||||
|
||||
|
||||
def answer(task_id, step_id, block_id, choice=None, text=None, now=None, skip=False, extra=None):
|
||||
def answer(task_id, step_id, block_id, choice=None, text=None, now=None, skip=False, extra=None, again=False):
|
||||
"""The user's answer to one block of a panel (from the page), or `skip` it, with `extra` for the AI
|
||||
(ai_repaint.prepare). Returns the step's answers."""
|
||||
(ai_repaint.prepare); `again`: in place of the answer given (another base taken instead, ai_bases.change).
|
||||
Returns the step's answers."""
|
||||
with _answered:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((item for item in (task or {}).get("steps") or [] if item.get("id") == step_id), None)
|
||||
@@ -393,13 +308,21 @@ def answer(task_id, step_id, block_id, choice=None, text=None, now=None, skip=Fa
|
||||
block = next((b for b in step["panel"]["blocks"] if b.get("id") == block_id), None)
|
||||
if not block or block["type"] not in INTERACTIVE:
|
||||
raise PanelError("No such question")
|
||||
if block_id in step.setdefault("answers", {}):
|
||||
if block_id in step.setdefault("answers", {}) and not again:
|
||||
raise PanelError("Already answered")
|
||||
entry = {"at": time.time() if now is None else now, "delivered": False, **(extra or {})}
|
||||
if skip:
|
||||
entry["skipped"] = True
|
||||
elif block["type"] == "choices" and block.get("other") and not choice and isinstance(text, str) and text.strip():
|
||||
entry.update(choice=["other"], labels=["other"], text=_text(text, 2000, "text", True))
|
||||
elif block["type"] == "prompts":
|
||||
if not ((extra or {}).get("prompts") or (extra or {}).get("gist")):
|
||||
raise PanelError("Prompts are written in Anomalous's prompt box")
|
||||
elif block["type"] == "plan": # build (ai_plans.prepare made the steps kept) or what to change
|
||||
if (extra or {}).get("plan"):
|
||||
entry.update(choice=["build"], labels=["build"])
|
||||
else:
|
||||
entry["text"] = _text(text, 2000, "text", True)
|
||||
elif block["type"] in ("choices", "picks"):
|
||||
picked = choice if isinstance(choice, list) else [choice]
|
||||
options = block["options"] if block["type"] == "choices" else block["items"]
|
||||
@@ -408,6 +331,8 @@ def answer(task_id, step_id, block_id, choice=None, text=None, now=None, skip=Fa
|
||||
if not picked or (len(picked) > 1 and not block.get("multiple")):
|
||||
raise PanelError("Pick one of the options")
|
||||
entry.update(choice=picked, labels=[known[item] for item in picked])
|
||||
if (step.get("routes") or {}).get(picked[0]): # what to call for a route (mcp_routes.py)
|
||||
entry["note"] = step["routes"][picked[0]]
|
||||
else:
|
||||
value = _text(text, 2000, "text", True)
|
||||
entry["text"] = value
|
||||
@@ -427,13 +352,17 @@ def leave_message(task_id, text, refs=None, now=None):
|
||||
"""A message from the user to the AI working on task `task_id`, with what its "@" point at and
|
||||
the pictures attached (ai_message_refs.py); words may be left out when something is attached.
|
||||
Returns the step."""
|
||||
from . import ai_message_refs
|
||||
from . import ai_message_refs, ai_sealed
|
||||
|
||||
try:
|
||||
refs = ai_message_refs.clean(refs)
|
||||
except ai_message_refs.RefsError as error:
|
||||
raise PanelError(str(error))
|
||||
text = _text(text, MAX_MESSAGE, "text", not refs)
|
||||
if ai_sealed.MARKED.search(text): # words the user marked to hide reach the AI as tokens
|
||||
seals = ai_sealed.Seals(task_id)
|
||||
text = ai_sealed.seal_marked(seals, text)
|
||||
seals.save()
|
||||
now = time.time() if now is None else now
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "user_message", "kind": "user", "ok": True,
|
||||
"summary": "user_message", "text": text, "delivered": False, "results": [], **({"refs": refs} if refs else {})}
|
||||
@@ -518,7 +447,7 @@ def _collect(client, panel_id, task_id=None):
|
||||
given.update(delivered=True, delivered_at=time.time())
|
||||
changed.add(task["id"])
|
||||
item = {"panel": panel["id"], "question": block["id"], "asked": block.get("question", "")}
|
||||
item.update({key: given[key] for key in ("choice", "labels", "text", "skipped", "inpaint_image", "note", "recommended_on_timeout") if key in given})
|
||||
item.update({key: given[key] for key in ("choice", "labels", "text", "skipped", "inpaint_image", "note", "recommended_on_timeout", "prompts", "negative", "pictures", "gist", "plan") if key in given})
|
||||
out.append(item)
|
||||
for tid in changed:
|
||||
ai_tasks._save(tasks[tid])
|
||||
@@ -583,7 +512,7 @@ def attach_pending(result, client, task_id=None):
|
||||
"kind=marks messages are places the user marked on a picture to change (the marked picture is attached): "
|
||||
"say what you see and how you would fix it (show_panel can show the picture with your own marks), "
|
||||
"ask_user which fix to do, and log_decision the fix chosen (related: the marks' id); "
|
||||
"kind=toolbox messages are the nodes, combos, workflows, prompts and models the user picked for you to use; "
|
||||
"kind=toolbox messages are the nodes, combos, workflows, prompts, models and ways the user picked for you to use; "
|
||||
"kind=stop: the user stopped your run, so do not run it again until they say what to change; "
|
||||
"kind=pictures: pictures the user handed you (attached), with made_with read from each picture's own workflow."
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ message in the output folder's ai_inbox, so the gallery has it and the message c
|
||||
any other: read as a picture first, size-capped, never over a file.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
@@ -141,6 +142,37 @@ def hand(task_id, images, text="", now=None):
|
||||
return step
|
||||
|
||||
|
||||
def to_input(image):
|
||||
"""Picture `image` as a file in ComfyUI's input folder, where LoadImage reads (a control block's
|
||||
reference, run_workflow's image): its name there. One already there stays itself; an output picture
|
||||
("subfolder/name.png") is copied, its transparency kept (LoadImage's MASK), named by its content so
|
||||
the same picture is one file, never over another. Not "name.png [output]": ComfyUI's page flags that
|
||||
as a missing file once its run history is gone, though LoadImage reads it."""
|
||||
from .mcp_tools import _resolve_folder_image
|
||||
|
||||
rel = str(image or "").replace("\\", "/").strip("/")
|
||||
if rel.endswith(" [output]"): # ComfyUI's mark for an output picture, as Load Image shows it
|
||||
rel = rel[: -len(" [output]")]
|
||||
try:
|
||||
return _resolve_folder_image(rel, "input")[0]
|
||||
except ToolError:
|
||||
pass
|
||||
with open(_resolve_image(rel)[1], "rb") as source:
|
||||
data = source.read()
|
||||
name = f"anomalous_reference_{hashlib.sha1(data).hexdigest()[:12]}.png"
|
||||
target = os.path.join(ai_tasks.folder_paths.get_input_directory(), name)
|
||||
if not os.path.isfile(target):
|
||||
try:
|
||||
with Image.open(io.BytesIO(data)) as picture, open(target, "xb") as out:
|
||||
clear = "A" in picture.getbands() or "transparency" in picture.info
|
||||
picture.convert("RGBA" if clear else "RGB").save(out, "PNG")
|
||||
except FileExistsError:
|
||||
pass # the same picture, copied a moment ago
|
||||
except OSError:
|
||||
raise ToolError(f"{image} cannot be read as a picture.")
|
||||
return name
|
||||
|
||||
|
||||
def message_of(step):
|
||||
"""The AI's message for a pictures step: the pictures' paths and what made each; their files go
|
||||
in "_pictures" for ai_marks.image_items to attach."""
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
"""The plan card (蓝图) in the AI workbench: the steps an AI laid out for a picture (add_nodes(plan=…),
|
||||
mcp_plans.py), each a ready-made block (reference_blocks.py) with its model, shown to the user before
|
||||
anything is built. The card is a panel block only Anomalous makes ("plan", id "answer"): the user drops
|
||||
steps but the first, swaps a step's model for one of the same family listed with it, and says build, or
|
||||
writes what to change. prepare() turns a build into the steps kept, which the AI's call then builds. The
|
||||
user's "build without asking" is kept in ai_plans.json beside the tasks. A plan of several steps once built,
|
||||
its first run shows three bases to pick from (the user's rule, kept on the task as "plan_previews" until a
|
||||
first pass alone has run), and the whole waits for the user's pick ("plan_pick": the first run without one is
|
||||
refused once, so an AI that hears "go on without" can go on): mcp_actions.run_workflow."""
|
||||
|
||||
PREVIEWS, PICK = "plan_previews", "plan_pick"
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from . import ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_plans.json")
|
||||
|
||||
|
||||
def auto():
|
||||
"""Whether the user said to build plans without asking."""
|
||||
try:
|
||||
with open(_path(), encoding="utf-8") as source:
|
||||
return json.load(source).get("auto") is True
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def set_auto(on):
|
||||
atomic_write_json(_path(), {"auto": bool(on)})
|
||||
|
||||
|
||||
def mark(task_id, key, on):
|
||||
"""Sets or clears a plan's rule `key` (PREVIEWS, PICK) on task `task_id`."""
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if not task or bool(task.get(key)) == bool(on):
|
||||
return
|
||||
if on:
|
||||
task[key] = True
|
||||
else:
|
||||
task.pop(key)
|
||||
ai_tasks._save(task)
|
||||
|
||||
|
||||
def due(task_id, key):
|
||||
return bool(task_id and (ai_tasks.get_task(task_id) or {}).get(key))
|
||||
|
||||
|
||||
def card_of(step):
|
||||
"""The plan block of a panel step, or None."""
|
||||
return next((block for block in ((step or {}).get("panel") or {}).get("blocks") or [] if block.get("type") == "plan"), None)
|
||||
|
||||
|
||||
def kept(steps, edits):
|
||||
"""[{block, model}] the user kept of a card's `steps`, with the models they swapped in. `edits`:
|
||||
{drop: [step index], models: {step index: model_id}}, indices from 0; ValueError for an edit not theirs."""
|
||||
edits = edits if isinstance(edits, dict) else {}
|
||||
try:
|
||||
drop = {int(at) for at in edits.get("drop") or []}
|
||||
models = {int(at): str(model) for at, model in (edits.get("models") or {}).items()}
|
||||
except (TypeError, ValueError, AttributeError):
|
||||
raise ValueError("Edits are {drop: [step index], models: {step index: model_id}}.")
|
||||
if 0 in drop:
|
||||
raise ValueError("The first step makes the picture: it stays.")
|
||||
out = []
|
||||
for at, step in enumerate(steps):
|
||||
if at in drop:
|
||||
continue
|
||||
model = step.get("model")
|
||||
if at in models:
|
||||
if models[at] not in {choice["model_id"] for choice in step.get("choices") or []} | {model}:
|
||||
raise ValueError(f"Step {at + 1}: pick one of the models listed with it.")
|
||||
model = models[at]
|
||||
out.append({"block": step["block"], "model": model})
|
||||
return out
|
||||
|
||||
|
||||
def prepare(task_id, step_id, choice, edits, auto_on):
|
||||
"""{"plan": the steps kept} when the user says build on a plan card, else None; their "build without
|
||||
asking" is saved with it."""
|
||||
step = next((item for item in (ai_tasks.get_task(task_id) or {}).get("steps") or [] if item.get("id") == step_id), None)
|
||||
card = card_of(step)
|
||||
if not card or choice != ["build"]:
|
||||
return None
|
||||
plan = kept(card["steps"], edits)
|
||||
if auto_on is True:
|
||||
set_auto(True)
|
||||
return {"plan": plan, "note": f"add_nodes(route={step['panel']['id']!r}) builds it as the user left it, if your call stopped waiting."}
|
||||
@@ -0,0 +1,121 @@
|
||||
"""Privacy mode (隐私模式): a task the user keeps private sends the AI none of its pictures and no prompt
|
||||
text. The AI still builds and runs workflows and knows the models; it hears how many pictures came out
|
||||
and whether the run went well, and the user looks at them in Anomalous. Prompts and picture names
|
||||
reach it as tokens it passes on (ai_sealed.py); the user writes prompts in a prompt box. Each task has its switch (task["privacy"],
|
||||
in the task's header); new tasks follow the default the user sets in the Gates window
|
||||
(user/anomalous/ai_privacy.json, {default, models}). Turning it on or off is a step the AI is told of, and one of the
|
||||
task's decisions (ai_decisions.py). A private task keeps its models' names too, as tokens the AI picks by type,
|
||||
base model and the user's note (ai_sealed_models.py), unless the user lets the names through in the Gates window.
|
||||
|
||||
All of it happens at one place: mcp_server.py puts the texts back into a call's arguments
|
||||
(ai_sealed.expand) and hands every tool result to guard() before it leaves, whatever tool or later
|
||||
step (run pictures, the user's messages) put a picture or a prompt in it. A call that
|
||||
cannot be tied to a task is private while the default is on or any private task is unfinished:
|
||||
when in doubt, nothing is shown. The card in the AI app (mcp_apps.py) shows no pictures either.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import ai_sealed, ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
NOTE = ("Privacy mode: no pictures or prompt text reach you, only tokens like [P1], [img1] and [M1] (a model) to pass "
|
||||
"on as they are. get_toolbox(skill=\"privacy\") first.")
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_privacy.json")
|
||||
|
||||
|
||||
def _kept():
|
||||
try:
|
||||
with open(_path(), encoding="utf-8") as source:
|
||||
data = json.load(source)
|
||||
except (OSError, ValueError):
|
||||
return {}
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def default_on():
|
||||
"""Whether new tasks are private (the Gates window); off unless the user turned it on."""
|
||||
return _kept().get("default") is True
|
||||
|
||||
|
||||
def hides_models():
|
||||
"""Whether a private task keeps its models' names too (the Gates window); it does unless the user lets them through."""
|
||||
return _kept().get("models") is not False
|
||||
|
||||
|
||||
def set_hides_models(on):
|
||||
if not isinstance(on, bool):
|
||||
raise ValueError("privacy_models is true or false")
|
||||
with ai_tasks._lock:
|
||||
atomic_write_json(_path(), {**_kept(), "models": on})
|
||||
return on
|
||||
|
||||
|
||||
def set_default(on):
|
||||
"""The default for new tasks. Turned off, the tasks that were private by it stay private."""
|
||||
if not isinstance(on, bool):
|
||||
raise ValueError("privacy is true or false")
|
||||
with ai_tasks._lock:
|
||||
if not on and default_on():
|
||||
for task in ai_tasks._load().values():
|
||||
if "privacy" not in task:
|
||||
task["privacy"] = True
|
||||
ai_tasks._save(task)
|
||||
atomic_write_json(_path(), {**_kept(), "default": on})
|
||||
return on
|
||||
|
||||
|
||||
def is_private(task):
|
||||
"""The task's own choice, else the default."""
|
||||
value = (task or {}).get("privacy")
|
||||
return value if isinstance(value, bool) else default_on()
|
||||
|
||||
|
||||
def set_private(task_id, on):
|
||||
"""The user's choice for one task, told to the AI as a step of theirs. Returns it."""
|
||||
if not isinstance(on, bool):
|
||||
raise ValueError("privacy is true or false")
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
if not task:
|
||||
raise ValueError("No such task")
|
||||
if is_private(task) != on:
|
||||
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": time.time(), "ms": 0, "tool": "user_privacy", "kind": "user",
|
||||
"ok": True, "state": "done", "summary": "user_privacy", "privacy": on, "delivered": False, "results": [],
|
||||
"text": "The user turned privacy mode on for this task. " + NOTE if on else
|
||||
"The user turned privacy mode off for this task: you get the pictures again."})
|
||||
task["privacy"] = on
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return on
|
||||
|
||||
|
||||
def applies(task_id):
|
||||
"""Whether a call of task `task_id` (None: none known) is private."""
|
||||
task = ai_tasks.get_task(task_id) if task_id else None
|
||||
if task:
|
||||
return is_private(task)
|
||||
if default_on():
|
||||
return True
|
||||
with ai_tasks._lock:
|
||||
return any(item.get("privacy") is True and not item.get("ended") for item in ai_tasks._load().values())
|
||||
|
||||
|
||||
def guard(result, private, task_id=None, info=None, tool=""):
|
||||
"""`result` as it may leave: a private call's pictures taken out (with a word on why), its prompt
|
||||
text, picture names and (hides_models) models as tokens (ai_sealed.mask; `info`: ComfyUI's node list)."""
|
||||
if not private or not isinstance(result, dict):
|
||||
return result
|
||||
content = [item for item in result.get("content") or [] if not (isinstance(item, dict) and item.get("type") == "image")]
|
||||
if len(content) != len(result.get("content") or []):
|
||||
content.append({"type": "text", "text": NOTE})
|
||||
result["content"] = content
|
||||
if isinstance(result.get("structuredContent"), dict):
|
||||
result["structuredContent"] = {**result["structuredContent"], "privacy": NOTE}
|
||||
return ai_sealed.mask(result, task_id, info, tool, hides_models())
|
||||
+61
-3
@@ -1,7 +1,9 @@
|
||||
"""Stopping a run an AI started (the workbench's stop button, the AI's stop_run): its prompts still
|
||||
waiting leave ComfyUI's queue and the one running is interrupted, only while it is still that one
|
||||
(ComfyUI's own cancel; never anyone else's prompt). The run's watcher (ai_task_runs.py) then ends
|
||||
the step as "cancelled" with who stopped it; when the user did, the AI is told with its next answer."""
|
||||
the step as "cancelled" with who stopped it; when the user did, the AI is told with its next answer.
|
||||
And the user's word on a finished run (set_verdict, the buttons under its pictures): it turned out
|
||||
well, or not, kept on the run's step and told to the AI as their message."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
@@ -9,6 +11,14 @@ import uuid
|
||||
from . import ai_task_runs, ai_tasks
|
||||
|
||||
|
||||
VERDICTS = ("good", "bad")
|
||||
# What is off with a run's pictures, a click each under "not quite right" (ui_ai_verdict.js), in the words the AI gets:
|
||||
# the eyes of an AI that is not sent the pictures (ai_privacy.py), and quicker than writing for any.
|
||||
ISSUES = {"hands": "the hands are wrong", "face": "the face is off", "body": "the body or the pose is wrong",
|
||||
"composition": "the framing is off", "dark": "too dark", "bright": "too bright", "blurry": "blurry or short of detail",
|
||||
"style": "not the style they want", "subject": "not the subject they asked for", "extra": "something is extra or missing"}
|
||||
|
||||
|
||||
class RunControlError(ValueError):
|
||||
pass
|
||||
|
||||
@@ -35,9 +45,10 @@ def stop(step_id, by="user", task_id=None, now=None):
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
if not step or step.get("tool") != "run_workflow":
|
||||
raise RunControlError("No such run in this task")
|
||||
if step.get("state") != "running" or not step.get("prompt_ids"):
|
||||
sheet = step.get("sheet") # a job sheet: its own stop takes its runs out (job_sheets.py)
|
||||
if step.get("state") != "running" or not (step.get("prompt_ids") or sheet):
|
||||
raise RunControlError("This run is not going any more")
|
||||
prompts = list(step["prompt_ids"])
|
||||
prompts = list(step.get("prompt_ids") or [])
|
||||
step["stop"] = by
|
||||
if by == "user":
|
||||
task["steps"].append({
|
||||
@@ -48,8 +59,55 @@ def stop(step_id, by="user", task_id=None, now=None):
|
||||
del task["steps"][:-ai_tasks.MAX_STEPS]
|
||||
task["updated"] = max(task.get("updated", 0), now)
|
||||
ai_tasks._save(task)
|
||||
if sheet:
|
||||
from . import job_sheets
|
||||
try:
|
||||
job_sheets.ask(sheet, "stop")
|
||||
except job_sheets.SheetError:
|
||||
pass # gone or ended already: nothing left to stop
|
||||
ai_tasks._notify(task["id"])
|
||||
return {"task": task["id"], "removed": 0, "interrupted": 0}
|
||||
queue = ai_task_runs._queue()
|
||||
removed = sum(1 for prompt_id in prompts if queue.delete_queue_item(lambda item, pid=prompt_id: item[1] == pid))
|
||||
interrupted = sum(1 for prompt_id in prompts if _interrupt(queue, prompt_id))
|
||||
ai_tasks._notify(task["id"])
|
||||
return {"task": task["id"], "removed": removed, "interrupted": interrupted}
|
||||
|
||||
|
||||
def set_verdict(task_id, step_id, verdict, issues=None):
|
||||
"""The user's word on finished run `step_id` of task `task_id`: "good", "bad" or None (taken back), with
|
||||
"bad" what is off (`issues`, keys of ISSUES). A word given goes to the AI; one it has not fetched yet is
|
||||
corrected where it stands, so picking issues one by one says it once. Returns the verdict."""
|
||||
if verdict not in (*VERDICTS, None):
|
||||
raise RunControlError("verdict is good, bad or null")
|
||||
issues = [name for name in ISSUES if name in issues] if verdict == "bad" and isinstance(issues, list) else []
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
steps = (task or {}).get("steps", [])
|
||||
step = next((item for item in steps if item["id"] == step_id), None)
|
||||
if not step or step.get("tool") != "run_workflow" or step.get("state") != "done":
|
||||
raise RunControlError("No such finished run")
|
||||
if step.get("verdict") == verdict and step.get("issues", []) == issues:
|
||||
return verdict
|
||||
if verdict:
|
||||
step.update(verdict=verdict, issues=issues)
|
||||
number = sum(1 for item in steps[:steps.index(step) + 1] if item.get("tool") == "run_workflow")
|
||||
text = (f"The user says run {number} of this task (step {step_id}) turned out well." if verdict == "good" else
|
||||
f"The user says run {number} of this task (step {step_id}) did not turn out well: "
|
||||
+ ("; ".join(ISSUES[name] for name in issues) + ". Change what causes that (the way, a setting, the prompt) "
|
||||
"before running again." if issues else
|
||||
"change the way, a setting or the prompt before running again, or ask them what is off."))
|
||||
told = next((item for item in reversed(steps) if item.get("tool") == "user_verdict" and item.get("run") == step_id
|
||||
and not item.get("delivered")), None)
|
||||
if told:
|
||||
told.update(at=time.time(), verdict=verdict, issues=issues, text=text)
|
||||
else:
|
||||
ai_tasks._add_step(task, {"id": uuid.uuid4().hex[:10], "at": time.time(), "ms": 0, "tool": "user_verdict", "kind": "user",
|
||||
"ok": True, "state": "done", "summary": "user_verdict", "verdict": verdict, "issues": issues,
|
||||
"run": step_id, "delivered": False, "results": [], "text": text})
|
||||
else:
|
||||
step.pop("verdict", None)
|
||||
step.pop("issues", None)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task_id)
|
||||
return verdict
|
||||
|
||||
+22
-4
@@ -16,8 +16,12 @@ ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
|
||||
BAR = re.compile(r"(\d+)/(\d+)\s*\[([^<\]]*)<([^,\]]*),\s*([^,\]]*)(?:,\s*([^\]]*))?\]")
|
||||
CONSOLE_LINES = 5
|
||||
LINE_CHARS = 200
|
||||
# Megapixels a sampler can work at comfortably on a 12 GB card; scaled by the card's memory.
|
||||
COMFORT_MP_12GB = 4.2
|
||||
# Megapixels a sampler can work at comfortably on a 12 GB card; scaled by the card's memory. 8.4:
|
||||
# on 2026-10-29 a 6 GB card (RTX 4050) sampled 4 megapixels (Anima, 10 steps) in about 100 s.
|
||||
COMFORT_MP_12GB = 8.4
|
||||
DECODE_MP_12GB = 8.4 # what a plain VAEDecode takes comfortably on a 12 GB card, likewise scaled
|
||||
BIG_SAVE_MP = 64 # a saved picture past this (8192x8192) is heavy for the gallery and the viewers
|
||||
PNG_BYTES_PER_PIXEL = 1.2 # what a ComfyUI PNG of a generated picture takes, roughly
|
||||
LATENT_INPUTS = ("latent_image", "samples", "latent", "pixels", "image", "images")
|
||||
NOT_PICTURES = {"model", "clip", "vae", "upscale_model", "positive", "negative", "conditioning", "control_net",
|
||||
"mask", "sampler", "sigmas", "guider", "noise", "clip_vision", "style_model"}
|
||||
@@ -181,7 +185,8 @@ def estimate(prompt, vram_total_mb=None, image_size=_image_size):
|
||||
source = prompt.get(str(inputs["sigmas"][0])) if _linked(inputs.get("sigmas")) else None
|
||||
return int(source["inputs"]["steps"]) if source and _number((source.get("inputs") or {}).get("steps")) else None
|
||||
|
||||
comfort = COMFORT_MP_12GB * max(0.5, min(3.0, (vram_total_mb or 12288) / 12288))
|
||||
memory = max(0.5, min(3.0, (vram_total_mb or 12288) / 12288))
|
||||
comfort = COMFORT_MP_12GB * memory
|
||||
samplers, warnings = [], []
|
||||
for node_id, node in sorted(prompt.items(), key=lambda item: str(item[0])):
|
||||
if not isinstance(node, dict):
|
||||
@@ -201,9 +206,17 @@ def estimate(prompt, vram_total_mb=None, image_size=_image_size):
|
||||
**entry, "megapixels": round(megapixels, 1)})
|
||||
elif node.get("class_type") == "VAEDecode" and _linked(inputs.get("samples")):
|
||||
found = size(inputs["samples"][0], set())
|
||||
if found and found["width"] * found["height"] * found["batch"] / 1048576 > comfort * 2:
|
||||
if found and found["width"] * found["height"] * found["batch"] / 1048576 > DECODE_MP_12GB * memory:
|
||||
warnings.append({"kind": "big_decode", "node": str(node_id), "title": title,
|
||||
"width": round(found["width"]), "height": round(found["height"])})
|
||||
elif "Save" in str(node.get("class_type")) and _linked(inputs.get("images")):
|
||||
# What is finally saved: an upscale model after the last sampler can make it far larger.
|
||||
found = size(inputs["images"][0], set())
|
||||
if found and found["width"] * found["height"] / 1048576 > BIG_SAVE_MP:
|
||||
pixels = found["width"] * found["height"]
|
||||
warnings.append({"kind": "big_save", "node": str(node_id), "title": title, "width": round(found["width"]),
|
||||
"height": round(found["height"]), "megapixels": round(pixels / 1048576),
|
||||
"megabytes": round(pixels * PNG_BYTES_PER_PIXEL / 1048576, -1)})
|
||||
return {"samplers": samplers[:12], "warnings": warnings[:6], "comfort_mp": round(comfort, 1)}
|
||||
|
||||
|
||||
@@ -215,6 +228,11 @@ def warning_words(warning):
|
||||
"model enlarged the picture: very slow and may not fit in memory. If the user only wants it bigger, the "
|
||||
"upscale model alone (ImageUpscaleWithModel -> SaveImage, no sampler after it) is enough; else sample "
|
||||
"smaller (scale down before VAEEncode), use fewer steps or a lower denoise, or a tiled sampler.")
|
||||
if warning["kind"] == "big_save":
|
||||
return (f"{warning['title']} (node {warning['node']}) saves a {warning['width']}x{warning['height']} picture "
|
||||
f"({warning['megapixels']} megapixels, about {warning['megabytes']:.0f} MB as PNG): heavy to look at in the "
|
||||
"gallery and image viewers. Unless the user asked for that size, upscale less (2x instead of 4x) or start "
|
||||
"from a smaller picture.")
|
||||
if warning["kind"] == "big_sample":
|
||||
return (f"{where}: {warning['megapixels']} megapixels, more than this card is comfortable with; expect it to be "
|
||||
"very slow or run out of memory. Consider a smaller size and upscaling afterwards.")
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
"""What a private task (ai_privacy.py) keeps from the AI besides pictures: prompt text, picture
|
||||
names, and model names while the user keeps those too. Each is sealed: kept in the task
|
||||
(task["sealed"], {token: text}) and given to the AI as a token, "[P3]" for a text, "[img2]" for a
|
||||
picture's name, "[M1]" for a model (ai_sealed_models.py). The AI writes the token where the text
|
||||
goes (a prompt, a picture to load, a model to set, its own words around it) and expand() puts the
|
||||
text back before the tool runs, so the canvas and the runs get it while the AI never reads it.
|
||||
|
||||
mask() seals what a tool's answer holds, in this order: every text input of a node (ComfyUI's node
|
||||
list says which inputs are STRING: prompts, file name prefixes; numbers and choices stay), the
|
||||
prompt fields of saved prompts, combos, workflows, models and changes (PROMPT_KEYS, and a few per
|
||||
tool), what the user named (NAMED: the names and tags of combos, workflow cards and saved prompts;
|
||||
a combo's name is its model's name and the start of its prompt unless they renamed it), the models
|
||||
when asked to (`models`), picture file names anywhere, and last any sealed text wherever it still
|
||||
shows up.
|
||||
seal_answer() takes the user's prompt box (a "prompts" panel block, ui_ai_prompt_box.js): the
|
||||
prompts and reference pictures, sealed, with the tokens the AI gets for them, and the gist: the
|
||||
picture in words the user wrote for the AI to read, handed on as written but for the words they
|
||||
marked to hide (seal_marked, also for a message they leave): those are tokens inside it.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
|
||||
from . import ai_sealed_models, ai_tasks
|
||||
from .ai_sealed_models import MODEL_FILE, WIDGET_KEYS
|
||||
from .mcp_tools import tool_text
|
||||
|
||||
TOKEN = re.compile(r"\[(?:P|img|M)\d+\]")
|
||||
WORD = re.compile(r"[^\W\d_]") # a letter of any script
|
||||
PICTURE = re.compile(r"[^\s\"'<>|*?,;()\[\]{}]*\.(?:png|jpe?g|webp|gif|bmp|mp4|webm|mov)\b", re.IGNORECASE)
|
||||
# Fields whose text is a prompt, or holds one (a workflow's settings, a change's before and after).
|
||||
PROMPT_KEYS = {"prompt", "prompts", "positive", "negative", "positive_prompt", "negative_prompt", "notes",
|
||||
"settings", "before", "after", "filename_prefix", "trigger_words", "trainedWords", "header_words",
|
||||
"tags", "in_cards", "recipe", "combo", "said_as"} # the last five: names the user gave, where they stand alone
|
||||
NAMED = ("combos", "workflows", "prompts") # lists of what the user named: each one's name and tags are theirs
|
||||
TOOL_KEYS = {"get_model": {"description", "user_notes"}, "place_combo": {"name"},
|
||||
**{tool: {"more_names"} for tool in ("list_combos", "list_workflows", "list_saved_prompts")}}
|
||||
MIN_SEALED = 3 # shorter texts are not looked for elsewhere (they would match inside other words)
|
||||
MAX_PROMPTS = 8
|
||||
MAX_PROMPT = 4000
|
||||
MAX_GIST = 1000
|
||||
MARKED = re.compile("\u27e6([^\u27e6\u27e7]*)\u27e7") # words the user marked to hide, the page's button (web/modules/ai_hide_mark.js)
|
||||
MAX_PICTURES = 4
|
||||
_loose = {} # tokens of calls tied to no task, while this ComfyUI runs
|
||||
|
||||
|
||||
class Seals:
|
||||
"""The tokens of task `task_id` (None: the calls tied to no task); save() keeps new ones."""
|
||||
|
||||
def __init__(self, task_id):
|
||||
self.task_id = task_id
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id) if task_id else None
|
||||
self.tokens = dict(task.get("sealed") or {}) if task else _loose
|
||||
self.texts = {text: token for token, text in self.tokens.items()}
|
||||
self.changed = False
|
||||
|
||||
def seal(self, text, kind="P"):
|
||||
known = self.texts.get(text)
|
||||
if known and known.startswith(f"[{kind}"):
|
||||
return known
|
||||
token = f"[{kind}{1 + sum(1 for item in self.tokens if item.startswith(f'[{kind}'))}]"
|
||||
self.tokens[token], self.texts[text] = text, token
|
||||
self.changed = True
|
||||
return token
|
||||
|
||||
def save(self):
|
||||
if not self.changed or not self.task_id:
|
||||
return
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(self.task_id)
|
||||
if task:
|
||||
task["sealed"] = self.tokens
|
||||
ai_tasks._save(task)
|
||||
|
||||
|
||||
def expand(task_id, value):
|
||||
"""`value` (a tool's arguments) with every known token replaced by its text (a text sealed with a
|
||||
token in it, its text too). A model's token is its model_id, the name its loader lists it by among a
|
||||
node's values, and its card's name inside other words (ai_sealed_models.py)."""
|
||||
tokens = Seals(task_id).tokens
|
||||
|
||||
def put(match):
|
||||
token = match.group(0)
|
||||
return ai_sealed_models.named(tokens[token]) if token.startswith("[M") and token in tokens else tokens.get(token, token)
|
||||
|
||||
def text(words, widget):
|
||||
if words.startswith("[M") and words in tokens:
|
||||
return ai_sealed_models.written(tokens[words], widget)
|
||||
for _ in range(3):
|
||||
words, before = TOKEN.sub(put, words), words
|
||||
if words == before:
|
||||
break
|
||||
return words
|
||||
|
||||
def walk(item, widget=False):
|
||||
if isinstance(item, dict):
|
||||
return {key: walk(part, widget or key in WIDGET_KEYS) for key, part in item.items()}
|
||||
if isinstance(item, list):
|
||||
return [walk(part, widget) for part in item]
|
||||
return text(item, widget) if isinstance(item, str) else item
|
||||
return walk(value)
|
||||
|
||||
|
||||
def _sealable(text):
|
||||
"""Whether `text` holds words: not only tokens (sealed already), numbers or ids (a node's "6")."""
|
||||
return bool(WORD.search(TOKEN.sub("", text)))
|
||||
|
||||
|
||||
def _text_input(info, node_type, key, value):
|
||||
"""Whether input `key` of a `node_type` node is text the user wrote (STRING in ComfyUI's node list).
|
||||
A setting the page adds to a known node ("control_after_generate"): only words with a space in them;
|
||||
a node type it does not know: any string but a model file's name."""
|
||||
spec = (info.get(node_type) or {}).get("input")
|
||||
for group in ("required", "optional"):
|
||||
found = ((spec or {}).get(group) or {}).get(key)
|
||||
if found is not None:
|
||||
return isinstance(found, (list, tuple)) and bool(found) and found[0] == "STRING"
|
||||
if spec is not None:
|
||||
return any(mark in value for mark in (" ", ","))
|
||||
return not value.lower().endswith(MODEL_FILE)
|
||||
|
||||
|
||||
def seal_text(seals, value):
|
||||
"""A prompt field's value sealed (a string, or strings in a list); a model file's name stays."""
|
||||
if isinstance(value, str):
|
||||
return value if not _sealable(value) or value.lower().endswith(MODEL_FILE) else seals.seal(value)
|
||||
if isinstance(value, list):
|
||||
return [seal_text(seals, item) for item in value]
|
||||
return value
|
||||
|
||||
|
||||
def _walk(value, seals, info, keys):
|
||||
if isinstance(value, list):
|
||||
return [_walk(item, seals, info, keys) for item in value]
|
||||
if not isinstance(value, dict):
|
||||
return value
|
||||
out = {}
|
||||
node_type = value.get("class_type") or value.get("type")
|
||||
for key, item in value.items():
|
||||
if key in ("settings", "inputs", "widgets") and isinstance(item, dict) and isinstance(node_type, str):
|
||||
out[key] = {name: seals.seal(text) if isinstance(text, str) and _sealable(text) and _text_input(info, node_type, name, text)
|
||||
else _walk(text, seals, info, keys) for name, text in item.items()}
|
||||
elif key in keys and (isinstance(item, str) or isinstance(item, list) and all(isinstance(text, str) for text in item)):
|
||||
out[key] = seal_text(seals, item)
|
||||
elif key in NAMED and isinstance(item, list):
|
||||
out[key] = [{**named, **{part: seal_text(seals, named[part]) for part in ("name", "filename") if part in named}}
|
||||
if isinstance(named, dict) else named for named in _walk(item, seals, info, keys)]
|
||||
else:
|
||||
out[key] = _walk(item, seals, info, keys)
|
||||
return out
|
||||
|
||||
|
||||
def _strings(value, change):
|
||||
if isinstance(value, dict):
|
||||
return {key: _strings(item, change) for key, item in value.items()}
|
||||
if isinstance(value, list):
|
||||
return [_strings(item, change) for item in value]
|
||||
return change(value) if isinstance(value, str) else value
|
||||
|
||||
|
||||
def _hide(seals, text):
|
||||
"""`text` with every picture name in it, then every sealed text (longest first), as tokens."""
|
||||
text = PICTURE.sub(lambda match: seals.seal(match.group(0), "img"), text)
|
||||
for sealed in sorted((item for item in seals.texts if len(item) >= MIN_SEALED), key=len, reverse=True):
|
||||
if sealed in text:
|
||||
text = text.replace(sealed, seals.texts[sealed])
|
||||
return text
|
||||
|
||||
|
||||
def mask(result, task_id, info, tool="", models=False):
|
||||
"""Tool result `result` of a private task with its prompt text and picture names as tokens, and its
|
||||
models too when `models` (the user keeps their names, ai_sealed_models.py)."""
|
||||
if not isinstance(result, dict):
|
||||
return result
|
||||
seals = Seals(task_id)
|
||||
data = result.get("structuredContent")
|
||||
masked = None
|
||||
|
||||
def hidden(value):
|
||||
return _strings(ai_sealed_models.hide(value, seals) if models else value, lambda text: _hide(seals, text))
|
||||
if isinstance(data, (dict, list)):
|
||||
masked = hidden(_walk(data, seals, info or {}, PROMPT_KEYS | TOOL_KEYS.get(tool, set())))
|
||||
result["structuredContent"] = masked
|
||||
content = []
|
||||
for item in result.get("content") or []:
|
||||
if isinstance(item, dict) and item.get("type") == "text":
|
||||
try:
|
||||
same = masked is not None and json.loads(item.get("text") or "") == data
|
||||
except ValueError:
|
||||
same = False
|
||||
item = {**item, "text": tool_text(masked) if same else hidden(str(item.get("text") or ""))}
|
||||
content.append(item)
|
||||
if "content" in result:
|
||||
result["content"] = content
|
||||
seals.save()
|
||||
return result
|
||||
|
||||
|
||||
def seal_marked(seals, text):
|
||||
"""`text` with the words the user marked as tokens of `seals`; the rest stays for the AI to read."""
|
||||
return MARKED.sub(lambda match: seals.seal(match.group(1).strip()) if match.group(1).strip() else "", text)
|
||||
|
||||
|
||||
def seal_answer(task_id, prompts, negative="", pictures=None, gist=""):
|
||||
"""The user's prompt box: {prompts, negative, pictures} as tokens, the texts and picture names sealed
|
||||
in the task, the gist as the user wrote it for the AI (left out when empty), and a line saying them."""
|
||||
prompts = [str(item).strip() for item in (prompts if isinstance(prompts, list) else []) if str(item or "").strip()]
|
||||
gist = str(gist or "").strip()[:MAX_GIST]
|
||||
if not (prompts or gist) or len(prompts) > MAX_PROMPTS or any(len(item) > MAX_PROMPT for item in prompts):
|
||||
raise ValueError(f"Write the gist, or 1 to {MAX_PROMPTS} prompts of up to {MAX_PROMPT} characters")
|
||||
negative = str(negative or "").strip()[:MAX_PROMPT]
|
||||
named = []
|
||||
for picture in (pictures if isinstance(pictures, list) else [])[:MAX_PICTURES]:
|
||||
if isinstance(picture, dict) and isinstance(picture.get("filename"), str) and PICTURE.fullmatch(picture["filename"]):
|
||||
named.append("/".join(part for part in (str(picture.get("subfolder") or "").strip("/"), picture["filename"]) if part))
|
||||
seals = Seals(task_id)
|
||||
gist = seal_marked(seals, gist)
|
||||
answer = {"prompts": [seals.seal(item) for item in prompts], "negative": seals.seal(negative) if negative else "",
|
||||
"pictures": [seals.seal(item, "img") for item in named]}
|
||||
seals.save()
|
||||
answer["text"] = " ".join(answer["prompts"] + ([f"negative {answer['negative']}"] if negative else [])
|
||||
+ ([f"pictures {' '.join(answer['pictures'])}"] if named else [])) or "gist"
|
||||
if gist:
|
||||
answer["gist"] = gist
|
||||
return answer
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Model names of a private task as tokens, while the user keeps those too (ai_privacy.hides_models):
|
||||
a file name, its folder, the name on its card and its pages can say as much as a prompt does. Each
|
||||
model of the library is one token ("[M1]", sealed in the task as its model_id, ai_sealed.py) however
|
||||
a tool's answer writes it: its model_id, the name a loader lists it by, its file name inside a text;
|
||||
its card's name where a name stands (NAME_KEYS), and inside a text when it is more than one plain
|
||||
word (a model called "Anima" is not taken out of every sentence about Anima). A model's own entry
|
||||
(anything with a model_id) loses what tells it apart (ENTRY_DROPS); its type, base model, author's
|
||||
settings and the user's note for the AI stay, to pick by. A model file the library does not have
|
||||
(one a workflow names) is a token of its own. written() and named() are the way back: the model_id
|
||||
where a tool takes one, the loader's name among a node's values, the card's name inside the AI's words."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from . import ai_toolbox_catalog
|
||||
|
||||
MODEL_FILE = (".safetensors", ".gguf", ".ckpt", ".pt", ".pth", ".bin", ".sft", ".onnx")
|
||||
MODEL_ID = re.compile(r"[\w-]+:\d+:.+")
|
||||
LOOSE = re.compile(r"[^\s\"'<>|*?,;()\[\]{}]+\.(?:safetensors|gguf|ckpt|pth|pt|bin|sft|onnx)\b", re.IGNORECASE)
|
||||
ENTRY_DROPS = ("name", "file", "folder", "rel", "cover", "civitai_name", "version", "civitai_url", "download_link", "sha256")
|
||||
NAME_KEYS = {"name", "names", "more_names", "model", "models", "main_model", "loras", "label", "labels"}
|
||||
PLAIN_KEYS = {"type", "kind", "base", "base_model", "baseModel", "family", "source"} # never a model's name
|
||||
WIDGET_KEYS = {"values", "settings", "inputs", "widgets", "widgets_values"} # a node's values: models by their loader's names
|
||||
MAX_NAME = 260
|
||||
|
||||
|
||||
def _library():
|
||||
"""The library's models [{id, name, file}] as the materials picker lists them (read at most every few seconds)."""
|
||||
return ai_toolbox_catalog.everything()["models"]
|
||||
|
||||
|
||||
def _distinct(name):
|
||||
"""Whether card name `name` is looked for inside a text: not one plain word, which a sentence may use as itself."""
|
||||
return len(name) >= 4 and not (name.isascii() and name.isalpha())
|
||||
|
||||
|
||||
def _cut(words):
|
||||
"""The model whose card name begins with `words`, a name a tool cut short (mcp_fit.NAME), or None."""
|
||||
return next((model["id"] for model in _library() if str(model["name"] or "").startswith(words)), None) if len(words) >= 20 else None
|
||||
|
||||
|
||||
def _gather(value, out):
|
||||
if isinstance(value, dict):
|
||||
for key, item in value.items():
|
||||
out.append(str(key))
|
||||
_gather(item, out)
|
||||
elif isinstance(value, list):
|
||||
for item in value:
|
||||
_gather(item, out)
|
||||
elif isinstance(value, str):
|
||||
out.append(value)
|
||||
|
||||
|
||||
def _forms(blob):
|
||||
"""What the strings of an answer (`blob`) may call the library's models: {form: model_id} for anywhere
|
||||
in a text, {name: model_id} for where a name stands. Only the models `blob` can be naming."""
|
||||
anywhere, names = {}, {}
|
||||
for model in _library():
|
||||
shown, stem = str(model["name"] or ""), os.path.splitext(model["file"])[0]
|
||||
if not any(form and form in blob for form in (shown, stem)):
|
||||
continue
|
||||
place = model["id"].split(":", 2)[2]
|
||||
for form in (model["id"], place, place.replace("/", "\\"), model["file"]):
|
||||
anywhere.setdefault(form, model["id"])
|
||||
for form in (shown, stem):
|
||||
if form:
|
||||
names.setdefault(form, model["id"])
|
||||
if _distinct(form):
|
||||
anywhere.setdefault(form, model["id"])
|
||||
return anywhere, names
|
||||
|
||||
|
||||
def hide(value, seals):
|
||||
"""Tool answer `value` (its data, or a text of it) with every model as its token of `seals`."""
|
||||
strings = []
|
||||
_gather(value, strings)
|
||||
blob = "\n".join(strings)
|
||||
anywhere, names = _forms(blob)
|
||||
found = sorted((form for form in anywhere if form in blob), key=len, reverse=True)
|
||||
# A form counts where it stands by itself: "anima.safetensors" is not taken out of "my_anima.safetensors".
|
||||
known = re.compile(r"(?<![\w.-])(?:" + "|".join(re.escape(form) for form in found) + r")(?!\w)") if found else None
|
||||
|
||||
def text(words, key):
|
||||
if key in PLAIN_KEYS:
|
||||
return words
|
||||
whole = anywhere.get(words) or ((names.get(words) or _cut(words)) if key in NAME_KEYS else None)
|
||||
if whole:
|
||||
return seals.seal(whole, "M")
|
||||
swapped = known.sub(lambda match: seals.seal(anywhere[match.group(0)], "M"), words) if known else words
|
||||
if swapped == words and words.lower().endswith(MODEL_FILE) and len(words) <= MAX_NAME and "\n" not in words and ": " not in words:
|
||||
return seals.seal(words, "M") # a model file the library does not have, spaces in its name and all
|
||||
return LOOSE.sub(lambda match: seals.seal(match.group(0), "M"), swapped)
|
||||
|
||||
def walk(item, key=""):
|
||||
if isinstance(item, dict):
|
||||
entry = isinstance(item.get("model_id"), str)
|
||||
return {text(name, "") if isinstance(name, str) else name: walk(part, name)
|
||||
for name, part in item.items() if not (entry and name in ENTRY_DROPS)}
|
||||
if isinstance(item, list):
|
||||
return [walk(part, key) for part in item]
|
||||
return text(item, key) if isinstance(item, str) else item
|
||||
return walk(value)
|
||||
|
||||
|
||||
def written(text, widget):
|
||||
"""What the token of the model sealed as `text` puts into a call: the name its loader lists it by among
|
||||
a node's values (`widget`), else the model_id the tools take."""
|
||||
return text.split(":", 2)[2].replace("/", os.sep) if widget and MODEL_ID.fullmatch(text) else text
|
||||
|
||||
|
||||
def named(text):
|
||||
"""The card's name of the model sealed as `text`, for where the AI wrote its token inside its own words."""
|
||||
found = next((model for model in _library() if model["id"] == text), None)
|
||||
return str(found["name"] or found["file"]) if found else text
|
||||
@@ -0,0 +1,71 @@
|
||||
"""What a private task's AI was sent: every tool call as it came in (its arguments, tokens and
|
||||
all) and the text of the answer as it left, after ai_privacy.guard took out what the task keeps.
|
||||
Kept beside the task (user/anomalous/ai_sent/<task>/calls.jsonl, gone with it) so the user can
|
||||
read for themselves what went out and look for a word in it (the task's menu, "What the AI was
|
||||
sent", GET /anomalous/ai_tasks/{id}/sent). A line per call, the newest MAX_CALLS of them; a long
|
||||
text is cut at MAX_TEXT with how much was cut. Pictures are counted, not kept: a private task
|
||||
sends none."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import ai_tasks
|
||||
|
||||
MAX_CALLS = 300
|
||||
SPARE = 50 # lines over MAX_CALLS before the file is cut back to it
|
||||
MAX_TEXT = 20_000
|
||||
MAX_ARGUMENTS = 4_000
|
||||
_lock = threading.Lock()
|
||||
_counts = {} # task id -> lines in its file, while this ComfyUI runs
|
||||
|
||||
|
||||
def _path(task_id):
|
||||
return os.path.join(ai_tasks.marks_dir(task_id, "ai_sent"), "calls.jsonl")
|
||||
|
||||
|
||||
def _cut(text, limit):
|
||||
return text if len(text) <= limit else f"{text[:limit]}… (+{len(text) - limit})"
|
||||
|
||||
|
||||
def _lines(task_id):
|
||||
try:
|
||||
with open(_path(task_id), encoding="utf-8") as source:
|
||||
return source.readlines()
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def keep(task_id, tool, arguments, result):
|
||||
"""One call of private task `task_id` as its AI had it: what it wrote and what it was answered."""
|
||||
content = [item for item in (result.get("content") or [] if isinstance(result, dict) else []) if isinstance(item, dict)]
|
||||
texts = [str(item.get("text") or "") for item in content if item.get("type") == "text"]
|
||||
line = json.dumps({"at": time.time(), "tool": tool, "arguments": _cut(json.dumps(arguments, ensure_ascii=False), MAX_ARGUMENTS),
|
||||
"text": _cut("\n".join(texts), MAX_TEXT), "pictures": sum(1 for item in content if item.get("type") == "image"),
|
||||
"error": bool(isinstance(result, dict) and result.get("isError"))}, ensure_ascii=False) + "\n"
|
||||
path = _path(task_id)
|
||||
with _lock:
|
||||
if task_id not in _counts:
|
||||
_counts[task_id] = len(_lines(task_id))
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
with open(path, "a", encoding="utf-8") as out:
|
||||
out.write(line)
|
||||
_counts[task_id] += 1
|
||||
if _counts[task_id] > MAX_CALLS + SPARE:
|
||||
kept = _lines(task_id)[-MAX_CALLS:]
|
||||
with open(path + ".part", "w", encoding="utf-8") as out:
|
||||
out.writelines(kept)
|
||||
os.replace(path + ".part", path)
|
||||
_counts[task_id] = MAX_CALLS
|
||||
|
||||
|
||||
def calls(task_id):
|
||||
"""The calls kept for `task_id`, oldest first; [] when it has none."""
|
||||
found = []
|
||||
for line in _lines(task_id)[-MAX_CALLS:]:
|
||||
try:
|
||||
found.append(json.loads(line))
|
||||
except ValueError:
|
||||
continue
|
||||
return found
|
||||
@@ -0,0 +1,165 @@
|
||||
"""Skills: advice on how to do a kind of job, which an AI fetches when the job comes up, beside the
|
||||
ready-made blocks (reference_blocks.py), which build nodes. A block is something AMB does; a skill
|
||||
is something the AI reads (how to write a prompt a model can draw). Each skill is a data file in
|
||||
api/skills/ (<name>.json): what the AI is told it is for (short) and the advice itself (text, a
|
||||
line a point), and what the user's Ways panel shows (title, about, in both languages,
|
||||
web/modules/ui_ai_blocks.js). Advice that depends on the canvas's main model (by_model) comes
|
||||
in the form for its size: "large" from `large_from` parameters, "small" under it, "unknown" when the
|
||||
model or its size cannot be told (model_advice.main_params counts them from the file's header).
|
||||
A skill about choosing among ready-made blocks names their uses ("ways"): its answer then lists the
|
||||
blocks of those uses that can run here, each with when to take it, from the blocks' own files
|
||||
(reference_blocks.whens), so the skill's words hold only the judgment and stay as they are when a
|
||||
block is added. An answer must fit what an AI app shows in the chat (mcp_fit.BUDGET): a longer one
|
||||
is filed away unread.
|
||||
begin_task lists the skills on (name and short); get_toolbox(skill=…)
|
||||
gives one. The tools' own descriptions only point at a skill, so the list every app holds stays
|
||||
small. The user turns a skill off in the panel (user/anomalous/ai_skills.json): the AI is not
|
||||
offered it and get_toolbox refuses it. A skill marked private_only is for private tasks
|
||||
(ai_privacy.py) and is all a private task gets: begin_task points at it there instead of listing
|
||||
the others, and get refuses each kind in the other kind of task. A by_model skill's
|
||||
for_user words say the same to the user, on the prompt box of a private task. Each skill fetched is a step of the AI's task (note_use), with
|
||||
the form its advice came in, so the user sees what the AI went by; the run after it says so too.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
FOLDER = os.path.join(os.path.dirname(__file__), "skills")
|
||||
NOTE = "Advice: get_toolbox(skill=name) when its job comes up: "
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class SkillError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
def _load():
|
||||
found = {}
|
||||
for file in sorted(os.listdir(FOLDER)):
|
||||
if file.endswith(".json"):
|
||||
with open(os.path.join(FOLDER, file), encoding="utf-8") as source:
|
||||
found[file[:-5]] = json.load(source)
|
||||
return found
|
||||
|
||||
|
||||
SKILLS = _load()
|
||||
|
||||
|
||||
def _off_path():
|
||||
from . import ai_tasks
|
||||
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_skills.json")
|
||||
|
||||
|
||||
def turned_off():
|
||||
"""The skills the user turned off in the panel."""
|
||||
try:
|
||||
with open(_off_path(), encoding="utf-8") as file:
|
||||
return {name for name in json.load(file).get("off", []) if name in SKILLS}
|
||||
except (OSError, ValueError, AttributeError):
|
||||
return set()
|
||||
|
||||
|
||||
def set_on(name, on):
|
||||
"""Turns skill `name` on or off for the AI; the skills now off."""
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
if name not in SKILLS:
|
||||
raise SkillError(f"No skill {name!r}.")
|
||||
with _lock:
|
||||
off = turned_off() - {name} if on else turned_off() | {name}
|
||||
atomic_write_json(_off_path(), {"off": sorted(off)})
|
||||
return off
|
||||
|
||||
|
||||
def status():
|
||||
"""Every skill as the user's panel shows it."""
|
||||
off = turned_off()
|
||||
return [{"name": name, "title": skill["title"], "about": skill["about"], "on": name not in off,
|
||||
"private_only": skill.get("private_only") is True} for name, skill in SKILLS.items()]
|
||||
|
||||
|
||||
def directory():
|
||||
"""The skills on, "name (short)" each after a note on fetching them, a line for begin_task; "" when none is on."""
|
||||
off = turned_off()
|
||||
listed = [f"{name} ({skill['short']})" for name, skill in SKILLS.items() if name not in off and not skill.get("private_only")]
|
||||
return NOTE + "; ".join(listed) if listed else ""
|
||||
|
||||
|
||||
def by_model(name):
|
||||
"""Whether skill `name`'s advice depends on the canvas's main model."""
|
||||
return bool(SKILLS.get(name, {}).get("by_model"))
|
||||
|
||||
|
||||
def _form(forms, model):
|
||||
"""Which form of `forms` (a skill's by_model) is for `model` {name, params}: large, small or unknown."""
|
||||
params = (model or {}).get("params") or 0
|
||||
return "unknown" if not params else "large" if params >= forms["large_from"] else "small"
|
||||
|
||||
|
||||
def _model_name(model):
|
||||
return os.path.splitext(os.path.basename(str((model or {}).get("name") or "").replace("\\", "/")))[0]
|
||||
|
||||
|
||||
def get(name, model=None, private=False, info=None):
|
||||
"""The advice of skill `name`, for get_toolbox; `model`: the canvas's main model {name, params},
|
||||
for a skill whose advice depends on it; `private`: the task is private (only its skills); `info`:
|
||||
ComfyUI's node list, for a skill that lists the ways that can run here (None: all of them)."""
|
||||
if name not in SKILLS:
|
||||
raise SkillError(f"No skill {name!r}; begin_task lists them: {', '.join(SKILLS)}.")
|
||||
if name in turned_off():
|
||||
raise SkillError(f"The user turned the skill {name!r} off: do the job your own way.")
|
||||
if private and not SKILLS[name].get("private_only"):
|
||||
raise SkillError("This task is private: the user writes the prompts. Its skill is get_toolbox(skill=\"privacy\").")
|
||||
if not private and SKILLS[name].get("private_only"):
|
||||
raise SkillError(f"The skill {name!r} is for private tasks; this one is not.")
|
||||
skill = SKILLS[name]
|
||||
lines = list(skill["text"])
|
||||
answer = {"skill": name, "advice": lines}
|
||||
forms = skill.get("by_model")
|
||||
if forms:
|
||||
form = _form(forms, model)
|
||||
size = f"{model['params'] / 1e9:.1f} billion" if form != "unknown" else ""
|
||||
lines[1:1] = [line.format(model=_model_name(model) or "The model", size=size) for line in forms[form]]
|
||||
answer["for_model"] = {"form": form, "model": _model_name(model), "billions": round(model["params"] / 1e9, 1) if size else None}
|
||||
if skill.get("ways"):
|
||||
from . import reference_blocks
|
||||
|
||||
answer["ways"] = reference_blocks.whens(skill["ways"], info)
|
||||
return answer
|
||||
|
||||
|
||||
def for_user(model, name="prompt_pose"):
|
||||
"""Skill `name`'s words for the user on `model` {name, params} (its for_user form, {zh, en}); None when it is off."""
|
||||
skill = SKILLS.get(name) or {}
|
||||
forms = (skill.get("by_model") or {}).get("for_user")
|
||||
if not forms or name in turned_off():
|
||||
return None
|
||||
form = _form(skill["by_model"], model)
|
||||
billions = f"{model['params'] / 1e9:.1f}" if form != "unknown" else ""
|
||||
return {lang: text.format(model=_model_name(model), billions=billions) for lang, text in forms[form].items()}
|
||||
|
||||
|
||||
def note_use(client, answer, session="", task_id=None, now=None):
|
||||
"""Skill `answer` (get's) fetched by the AI, as a step of its task: which skill and, for one that goes
|
||||
by the model, the form it came in. The same skill in the same form again before a run adds nothing."""
|
||||
from . import ai_tasks
|
||||
|
||||
skill = SKILLS[answer["skill"]]
|
||||
now = time.time() if now is None else now
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": now, "ms": 0, "tool": "use_skill", "kind": "skill", "ok": True, "state": "done",
|
||||
"summary": "use_skill", "skill": answer["skill"], "title": skill["title"], "for_model": answer.get("for_model"), "results": []}
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks.current_task(client, now, session=session, task_id=task_id)
|
||||
for earlier in reversed(task.get("steps") or []):
|
||||
if earlier.get("tool") == "run_workflow":
|
||||
break
|
||||
if earlier.get("tool") == "use_skill" and earlier.get("skill") == step["skill"] and earlier.get("for_model") == step["for_model"]:
|
||||
return earlier
|
||||
ai_tasks._add_step(task, step)
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(task["id"])
|
||||
return step
|
||||
@@ -0,0 +1,52 @@
|
||||
"""What the home page's "Let the AI do it" section needs in one read (web/modules/ui_ai_starters.js):
|
||||
the user's saved workflows with their presets (方案, mcp_tools.plans) for the "Run my preset" card,
|
||||
whether the gates ask before a download (the card's tag shows only then), and the AI app that came
|
||||
last with the tool list it holds: a short one (mcp_tool_sets.py) cannot take a task handed over in
|
||||
Anomalous, which the section says. GET /anomalous/ai_starters, a read.
|
||||
"""
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import ai_gates, ai_tasks, mcp_directory, mcp_tool_sets
|
||||
from .mcp_tools import plans
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
|
||||
MAX_WORKFLOWS = 200
|
||||
|
||||
|
||||
def workflows():
|
||||
"""[{name, presets: [names]}] of the saved workflows, by name."""
|
||||
saved = plans()
|
||||
found = [{"name": (recipe.get("data") or {}).get("name") or recipe["filename"],
|
||||
"presets": [name for _file, name in saved.get(recipe["filename"], [])]}
|
||||
for recipe in _list_recipes(get_recipes_dir())]
|
||||
return sorted(found, key=lambda item: item["name"].lower())[:MAX_WORKFLOWS]
|
||||
|
||||
|
||||
def last_app(listing):
|
||||
"""{client, when, tools: all | core | mine | other | unknown} of the app that worked on a task last, or None.
|
||||
`listing(names)` gives the tool list a set offers, to tell which one the app fetched."""
|
||||
latest = next((task for task in ai_tasks.list_tasks(50) if task.get("client") and task["client"] != "AI"), None)
|
||||
if not latest:
|
||||
return None
|
||||
with ai_tasks._lock:
|
||||
fetched = mcp_directory._known().get(latest["client"])
|
||||
kind = "unknown"
|
||||
if fetched:
|
||||
sets = {"all": None, "core": mcp_tool_sets.chosen("core", _tools(listing)),
|
||||
"mine": mcp_tool_sets.chosen("mine", _tools(listing))}
|
||||
kind = next((name for name, names in sets.items()
|
||||
if (names is not None or name == "all") and mcp_directory.fingerprint(listing(names)) == fetched), "other")
|
||||
return {"client": latest["client"], "when": latest["updated"], "tools": kind}
|
||||
|
||||
|
||||
def _tools(listing):
|
||||
return {tool["name"]: tool for tool in listing(None)}
|
||||
|
||||
|
||||
def register_routes(app, listing):
|
||||
async def api_starters(_request):
|
||||
return web.json_response({"workflows": workflows(), "download_asks": ai_gates.policies().get("download") == "ask",
|
||||
"app": last_app(listing)})
|
||||
|
||||
app.router.add_get("/anomalous/ai_starters", api_starters)
|
||||
@@ -24,7 +24,7 @@ DEFAULT = "new"
|
||||
# Page actions (web/modules/mcp_bridge.js) that work on the canvas, so on the task's tab.
|
||||
TAB_ACTIONS = {"describe_canvas", "set_prompt", "set_model", "add_lora", "place_combo", "check_models", "fix_models",
|
||||
"run_workflow", "download_missing", "add_node", "add_nodes", "connect_nodes", "set_values", "remove_nodes", "new_workflow", "open_workflow",
|
||||
"fill_slots"}
|
||||
"fill_slots", "job_sheet"}
|
||||
_call = contextvars.ContextVar("anomalous_task_canvas", default=None)
|
||||
|
||||
|
||||
@@ -82,14 +82,17 @@ def begin_call(task_id):
|
||||
|
||||
|
||||
def spec(action):
|
||||
"""What the page needs for page action `action` of the call running now: {mode, tab, title}, or None."""
|
||||
"""What the page needs for page action `action` of the call running now: {mode, tab, title, from?}, or None.
|
||||
`from`: the tab of a canvas the user handed over (the task's "about"), what the task's work starts from."""
|
||||
holder = _call.get()
|
||||
if not holder or not holder.get("task") or action not in TAB_ACTIONS:
|
||||
return None
|
||||
task = ai_tasks.get_task(holder["task"])
|
||||
if not task:
|
||||
return None
|
||||
return {"mode": mode_of(task), "tab": (task.get("canvas") or {}).get("tab"), "title": (task.get("title") or "")[:60]}
|
||||
handed = next((item["tab"] for item in task.get("about") or [] if item.get("type") == "canvas"), None)
|
||||
return {"mode": mode_of(task), "tab": (task.get("canvas") or {}).get("tab"), "title": (task.get("title") or "")[:60],
|
||||
**({"from": handed} if handed else {})}
|
||||
|
||||
|
||||
def remember(tab):
|
||||
|
||||
+12
-6
@@ -3,12 +3,14 @@ user makes (activity_log.py tells note_activity of every new entry) becomes a st
|
||||
the AI called within USER_STEP_SECONDS, and the entry names that task back. The AI is told of it
|
||||
with its next call (ai_panels.attach_pending, a message of kind "change"), so it keeps the user's
|
||||
change instead of working over it. The AI's own canvas changes (via mcp) are its steps already;
|
||||
which actions count, and their stage, is action_catalog.USER_ACTIONS."""
|
||||
which actions count, and their stage, is action_catalog.USER_ACTIONS. A private task's
|
||||
(ai_privacy.py) are told with what was typed sealed (ai_sealed.py): the AI hears that a prompt
|
||||
changed, not what it says."""
|
||||
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from . import action_catalog, activity_log, ai_tasks
|
||||
from . import action_catalog, activity_log, ai_privacy, ai_sealed, ai_tasks
|
||||
|
||||
USER_STEP_SECONDS = 10 * 60
|
||||
|
||||
@@ -20,8 +22,8 @@ def _live_task(now):
|
||||
return max(live, key=lambda task: task.get("updated", 0)) if live else None
|
||||
|
||||
|
||||
def _change_text(entry):
|
||||
"""What the user changed, in words for the AI."""
|
||||
def _change_text(entry, seal=lambda value: value):
|
||||
"""What the user changed, in words for the AI; `seal(value)` for what was before and after."""
|
||||
if entry.get("action") == "image_delete" and entry.get("target"):
|
||||
return (f"Deleted the picture {entry['target']} (to the Recycle Bin): it is gone from the output folder, "
|
||||
"so do not use, show or hand it in again; another picture is needed for what it was for.")
|
||||
@@ -31,7 +33,7 @@ def _change_text(entry):
|
||||
lines = []
|
||||
for change in (entry.get("detail") or {}).get("changes") or []:
|
||||
if change.get("kind") == "changed":
|
||||
lines.append(f"{change.get('node')} {change.get('widget')}: {change.get('before')} -> {change.get('after')}")
|
||||
lines.append(f"{change.get('node')} {change.get('widget')}: {seal(change.get('before'))} -> {seal(change.get('after'))}")
|
||||
elif change.get("kind") == "opened":
|
||||
lines.append(f"opened the workflow {change.get('node')}")
|
||||
else:
|
||||
@@ -49,9 +51,13 @@ def note_activity(entry):
|
||||
task = _live_task(at)
|
||||
if not task:
|
||||
return None
|
||||
seals = ai_sealed.Seals(task["id"]) if ai_privacy.is_private(task) else None
|
||||
text = _change_text(entry, (lambda value: ai_sealed.seal_text(seals, value)) if seals else (lambda value: value))
|
||||
if seals:
|
||||
seals.save()
|
||||
step = {"id": uuid.uuid4().hex[:10], "at": at, "ms": 0, "tool": "user_activity", "kind": "user", "stage": stage,
|
||||
"ok": True, "state": "done", "summary": entry.get("action", ""),
|
||||
"text": ai_tasks._short(_change_text(entry), ai_tasks.MAX_NOTE), "activity": entry["id"],
|
||||
"text": ai_tasks._short(text, ai_tasks.MAX_NOTE), "activity": entry["id"],
|
||||
"delivered": False, "results": []}
|
||||
# Appended without moving "updated": the user's own changes do not keep the AI's task open.
|
||||
task["steps"].append(step)
|
||||
|
||||
+18
-2
@@ -2,7 +2,8 @@
|
||||
AI begins as new while on another (ai_tasks.begin_task), or one the user starts while looking at
|
||||
another (ai_tasks.start_task), keeps that one as its "from". The user can link a task to another
|
||||
or take the link off (set_from); links() gives a task's own "from" and the tasks that go on from it,
|
||||
and an AI beginning a task that goes on from another hears what was decided there."""
|
||||
and an AI beginning a task that goes on from another hears what was decided there. A task the user
|
||||
removes (remove) leaves no gap: those that went on from it go on from what it went on from."""
|
||||
|
||||
from . import ai_decisions, ai_tasks
|
||||
|
||||
@@ -35,12 +36,27 @@ def set_from(task_id, parent_id):
|
||||
return parent_id
|
||||
|
||||
|
||||
def remove(task_id):
|
||||
"""Takes task `task_id` off the list; those that went on from it now go on from what it went on from."""
|
||||
with ai_tasks._lock:
|
||||
tasks = ai_tasks._load()
|
||||
parent = (tasks.get(task_id) or {}).get("from")
|
||||
for task in tasks.values():
|
||||
if task.get("from") == task_id:
|
||||
if parent:
|
||||
task["from"] = parent
|
||||
else:
|
||||
task.pop("from")
|
||||
ai_tasks._save(task)
|
||||
return ai_tasks.remove_task(task_id)
|
||||
|
||||
|
||||
def links(task_id):
|
||||
"""{from: {id, title} | None, next: [{id, title}]} of task `task_id`, the next oldest first."""
|
||||
with ai_tasks._lock:
|
||||
tasks = ai_tasks._load()
|
||||
task = tasks.get(task_id) or {}
|
||||
brief = lambda item: {"id": item["id"], "title": item.get("title") or ""} # noqa: E731
|
||||
brief = lambda item: {"id": item["id"], "title": item.get("title") or "", "title_from": item.get("title_from") or ""} # noqa: E731
|
||||
parent = tasks.get(task.get("from"))
|
||||
later = sorted((item for item in tasks.values() if item.get("from") == task_id), key=lambda item: item.get("started", 0))
|
||||
return {"from": brief(parent) if parent else None, "next": [brief(item) for item in later]}
|
||||
|
||||
+36
-7
@@ -15,13 +15,15 @@ what changed since the task's run before it (ai_run_map.py); a run the user or t
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import ai_gates, ai_run_map, ai_run_watch, ai_tasks
|
||||
from . import ai_bases, ai_gates, ai_run_map, ai_run_watch, ai_tasks
|
||||
from .mcp_tools import ToolError, _image_content, _resolve_image, retext, tool_text
|
||||
|
||||
RUN_POLL_SECONDS = 2.0
|
||||
MAX_STATUS_WAIT = 55
|
||||
MAX_PREVIEWS = 8 # the last PreviewImage pictures of a run the AI is told of
|
||||
MAX_SHOWN = 4 # thumbnails of a finished run's pictures that come with the answer first telling of its end
|
||||
MAX_TEXTS = 4 # what a run's nodes showed as words (a tagger's tags), the last few
|
||||
TEXT_CHARS = 2000
|
||||
|
||||
|
||||
def _queue():
|
||||
@@ -42,6 +44,17 @@ def outputs_of(record_, folder="output"):
|
||||
return out
|
||||
|
||||
|
||||
def texts_of(record_):
|
||||
"""What a history record's nodes showed as words (a tagger's tags, a text display): [{node, text}]."""
|
||||
out = []
|
||||
for node, output in (record_.get("outputs") or {}).items():
|
||||
for key in ("tags", "text", "string"):
|
||||
words = [item for item in output.get(key) or [] if isinstance(item, str) and item.strip()]
|
||||
if words:
|
||||
out.append({"node": str(node), "text": " | ".join(words)[:TEXT_CHARS]})
|
||||
return out
|
||||
|
||||
|
||||
def _registry():
|
||||
"""ComfyUI's per-node progress of the prompt running now, or None (an older ComfyUI has none)."""
|
||||
try:
|
||||
@@ -106,7 +119,7 @@ def _finish_run(task_id, step_id, prompts, history, queued=None):
|
||||
if not prompts:
|
||||
_stop_run(task_id, step_id, "没有找到已排队的运行 / No queued run was found; completion cannot be confirmed")
|
||||
return True
|
||||
images, previews, failed, done = [], [], False, 0
|
||||
images, previews, texts, failed, done = [], [], [], False, 0
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
stopped = next((item.get("stop") for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
@@ -129,6 +142,7 @@ def _finish_run(task_id, step_id, prompts, history, queued=None):
|
||||
if image not in images: # a prompt ComfyUI answers from its cache repeats the same file
|
||||
images.append(image)
|
||||
previews += [image for image in outputs_of(record_, "temp") if image not in previews]
|
||||
texts += [text for text in texts_of(record_) if text not in texts]
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(task_id)
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == step_id), None)
|
||||
@@ -137,13 +151,16 @@ def _finish_run(task_id, step_id, prompts, history, queued=None):
|
||||
failed = failed or bool(step.get("submission_error"))
|
||||
stopped = step.get("stop")
|
||||
results = [item for item in step["results"] if item["type"] != "image"] + images[:ai_tasks.MAX_RESULTS]
|
||||
previews = previews[-MAX_PREVIEWS:]
|
||||
previews, texts = previews[-MAX_PREVIEWS:], texts[-MAX_TEXTS:]
|
||||
if (done < len(prompts) and results == step["results"] and step.get("prompts") == len(prompts)
|
||||
and previews == step.get("previews", []) and (not failed or step.get("ok") is False)):
|
||||
and previews == step.get("previews", []) and texts == step.get("texts", [])
|
||||
and (not failed or step.get("ok") is False)):
|
||||
return False # nothing new to keep: no write
|
||||
step["results"] = results
|
||||
if previews:
|
||||
step["previews"] = previews
|
||||
if texts:
|
||||
step["texts"] = texts
|
||||
step["state"] = "running" if done < len(prompts) else ("cancelled" if stopped else "error" if failed else "done")
|
||||
step["prompts"] = len(prompts)
|
||||
if failed:
|
||||
@@ -191,6 +208,9 @@ def watch(step_id, receipt):
|
||||
if not step:
|
||||
return # The user may remove the task while its submission is returning.
|
||||
step["prompt_ids"] = list(prompts)
|
||||
for key, value in (("bases", receipt.get("base_only")), ("base_check", receipt.get("base_check"))):
|
||||
if value:
|
||||
step[key] = value # ai_bases.py: bases to offer when it ends, or the picked one to compare
|
||||
step_at = step["at"]
|
||||
for other in task.get("steps", []): # a new run: no line to paste for an older one any more
|
||||
if other is not step:
|
||||
@@ -218,6 +238,8 @@ def watch(step_id, receipt):
|
||||
estimated = _estimate(task_id, step_id, item)
|
||||
if _finish_run(task_id, step_id, prompts, {k: v for k, v in history.items() if v},
|
||||
None if queued is None else set(queued)):
|
||||
ai_bases.offer(task_id, step_id, prompts, history)
|
||||
ai_bases.check(task_id, step_id, prompts, history)
|
||||
return
|
||||
try:
|
||||
progress = progress_of(prompts)
|
||||
@@ -307,7 +329,10 @@ def summary(run):
|
||||
else "Your run was stopped (stop_run).")
|
||||
if state == "done":
|
||||
pictures = [item["filename"] for item in run.get("results") or [] if item.get("type") == "image"]
|
||||
return f"Your run finished in {round(run.get('ms', 0) / 1000)} s with {len(pictures)} picture(s): {', '.join(pictures[:8])}."
|
||||
made = f"{len(pictures)} picture(s): {', '.join(pictures[:8])}" if pictures else "no pictures"
|
||||
words = f" and words from {len(run['texts'])} node(s) (texts)" if run.get("texts") else ""
|
||||
base = ai_bases.words(run)
|
||||
return f"Your run finished in {round(run.get('ms', 0) / 1000)} s with {made}{words}.{' ' + base if base else ''}"
|
||||
if state in ("asking", "allowed"):
|
||||
return "Your run waits for the user's OK in Anomalous; it has not started."
|
||||
return f"Your run did not finish: {run.get('error') or state}."
|
||||
@@ -425,6 +450,8 @@ def status(client, session="", task_id=None, wait_seconds=0, step_id=None):
|
||||
answer["previews_note"] = ("What the run's PreviewImage nodes showed (pose skeletons, masks…), in ComfyUI's temp "
|
||||
"folder: zoom_image(image, folder='temp') or get_image_info(image, folder='temp', "
|
||||
"include_image=true) shows them; there is no need to run a preprocessor yourself.")
|
||||
if run.get("texts"):
|
||||
answer["texts"] = run["texts"] # what nodes showed as words: a tagger's tags
|
||||
if run.get("error"):
|
||||
answer["error"] = run["error"]
|
||||
# What to do now, in one line a small model can follow (get_situation and the canvas tools say theirs too).
|
||||
@@ -433,9 +460,11 @@ def status(client, session="", task_id=None, wait_seconds=0, step_id=None):
|
||||
if ai_gates.run_wait() == "call" else
|
||||
"run_status(wait_seconds=55): it is still running; do not guess it is done or run it again."),
|
||||
"allowed": "run_status(wait_seconds=55): it is about to run.",
|
||||
"done": ("The first answer telling you it ended has its pictures attached (pictures_attached): look at them, "
|
||||
"done": "get_answers(wait_seconds=55): the user picks a base on Anomalous's panel." if run.get("bases") else
|
||||
("The first answer telling you it ended has its pictures attached (pictures_attached): look at them, "
|
||||
"then report what came out with images= its pictures; show_panel only to compare or ask. "
|
||||
"zoom_image looks closer."),
|
||||
"zoom_image looks closer.") if pictures or not run.get("texts") else
|
||||
("It made no pictures: texts has what its nodes showed (a tagger's tags); use them (set_prompt), then report."),
|
||||
"asking": "get_answers(wait_seconds=55): the run waits for the user's OK in Anomalous.",
|
||||
"cancelled": "It was stopped and nothing more comes of it: ask_user what to change before running again.",
|
||||
}.get(run.get("state"), "get_situation shows what went wrong (a missing model: check_workflow_models); fix it, then run again.")
|
||||
|
||||
+20
-15
@@ -77,18 +77,19 @@ def _path(task_id):
|
||||
return os.path.join(tasks_dir(), f"{task_id}.json")
|
||||
|
||||
|
||||
def marks_dir(task_id):
|
||||
"""Where the pictures the user marked for a task are kept (ai_marks.py); gone with the task."""
|
||||
return os.path.join(os.path.dirname(tasks_dir()), "ai_marks", re.sub(r"[^\w-]", "", task_id))
|
||||
def marks_dir(task_id, kind="ai_marks"):
|
||||
"""Where a task's side files are kept, gone with the task: the pictures the user marked (ai_marks.py); `kind` "ai_sent": what its AI was sent (ai_sent.py)."""
|
||||
return os.path.join(os.path.dirname(tasks_dir()), kind, re.sub(r"[^\w-]", "", task_id))
|
||||
|
||||
|
||||
def _forget(task_id):
|
||||
"""The task's own files: its record and its marked pictures (the plugin's, not the user's pictures)."""
|
||||
"""The task's own files: its record, its marked pictures and what its AI was sent (the plugin's, not the user's pictures)."""
|
||||
try:
|
||||
os.remove(_path(task_id))
|
||||
except OSError:
|
||||
pass
|
||||
shutil.rmtree(marks_dir(task_id), ignore_errors=True)
|
||||
for kind in ("ai_marks", "ai_sent"):
|
||||
shutil.rmtree(marks_dir(task_id, kind), ignore_errors=True)
|
||||
|
||||
|
||||
def _load():
|
||||
@@ -216,7 +217,7 @@ def _take_waiting(client, now, session=""):
|
||||
return task
|
||||
|
||||
|
||||
ABOUT_TYPES = {"model": "model_id", "image": "image", "combo": "name", "workflow": "name"} # what a task the user starts can be about
|
||||
ABOUT_TYPES = {"model": "model_id", "image": "image", "combo": "name", "workflow": "name", "canvas": "tab"} # what a user's task is about
|
||||
|
||||
|
||||
def _about(items):
|
||||
@@ -332,8 +333,9 @@ def begin_task(client, title, now=None, new_task=False, session="", task_id=None
|
||||
if new_task and previous:
|
||||
task["from"] = previous["id"] # begun from that one (ai_task_links.py)
|
||||
elif not task.get("title") or task.get("title_from") == "auto":
|
||||
task["title"] = _short(title, MAX_TITLE)
|
||||
task["title_from"] = "ai"
|
||||
said = next((step["text"] for step in task["steps"] if step.get("tool") == "user_message" and step.get("text")), "")
|
||||
task["title"] = _short(said or title, MAX_TITLE)
|
||||
task["title_from"] = "user" if said else "ai"
|
||||
_bind_owner(client, session, task["id"])
|
||||
task["updated"] = now
|
||||
_save(task)
|
||||
@@ -345,7 +347,7 @@ def _add_step(task, step):
|
||||
task["steps"].append(step)
|
||||
del task["steps"][:-MAX_STEPS]
|
||||
task["updated"] = max(task.get("updated", 0), step["at"] + step.get("ms", 0) / 1000)
|
||||
if task.get("title_from") == "auto" and not task.get("title") and step["kind"] not in ("look", "note") and step["ok"]:
|
||||
if task.get("title_from") == "auto" and not task.get("title") and step["kind"] not in ("look", "note", "skill") and step["ok"]:
|
||||
task["title"] = step["summary"][:MAX_TITLE]
|
||||
|
||||
|
||||
@@ -491,9 +493,10 @@ def record(client, tool, arguments, result, started, read_only=False, session=""
|
||||
|
||||
# ---------------------------------------------------------------- notes from the AI
|
||||
|
||||
def _output_media(path):
|
||||
"""An output-folder file the AI names ("subfolder/name.png"), as an image result, or None."""
|
||||
root = os.path.realpath(folder_paths.get_output_directory())
|
||||
def _output_media(path, folder="output"):
|
||||
"""An output-folder file the AI names ("subfolder/name.png"), as an image result, or None; with
|
||||
`folder` "temp", a run's preview there (gone when ComfyUI restarts)."""
|
||||
root = os.path.realpath(folder_paths.get_temp_directory() if folder == "temp" else folder_paths.get_output_directory())
|
||||
full = os.path.realpath(os.path.join(root, str(path).replace("\\", "/").lstrip("/")))
|
||||
if os.path.commonpath((root, full)) != root or not os.path.isfile(full):
|
||||
return None
|
||||
@@ -501,7 +504,7 @@ def _output_media(path):
|
||||
return None
|
||||
rel = os.path.relpath(full, root).replace(os.sep, "/")
|
||||
subfolder, _, filename = rel.rpartition("/")
|
||||
return {"type": "image", "filename": filename, "subfolder": subfolder, "folder": "output"}
|
||||
return {"type": "image", "filename": filename, "subfolder": subfolder, "folder": "temp" if folder == "temp" else "output"}
|
||||
|
||||
|
||||
def report(client, text, images=None, done=False, now=None, session="", task_id=None, reply_to=None):
|
||||
@@ -541,17 +544,19 @@ def task_summary(task, now=None):
|
||||
for step in steps:
|
||||
kinds[step["kind"]] = kinds.get(step["kind"], 0) + 1
|
||||
images = [item for step in steps for item in step.get("results") or [] if item.get("type") == "image"]
|
||||
made = {(item.get("folder"), item.get("subfolder"), item.get("filename")) for step in steps if step.get("kind") == "run"
|
||||
for item in step.get("results") or [] if item.get("type") == "image"} # not a picture a report only shows
|
||||
asks = sum(1 for step in steps if step.get("kind") == "panel" and not step["panel"].get("closed")
|
||||
for block in step["panel"]["blocks"] if block.get("type") in ("choices", "input", "picks")
|
||||
and block.get("id") and block["id"] not in step.get("answers", {}))
|
||||
asks += sum(1 for step in steps if step.get("state") == "asking")
|
||||
return {
|
||||
"id": task["id"], "title": task.get("title") or "", "client": task.get("client") or ("" if task.get("waiting") else "AI"),
|
||||
"id": task["id"], "title": task.get("title") or "", "title_from": task.get("title_from") or "", "client": task.get("client") or ("" if task.get("waiting") else "AI"),
|
||||
"started": task.get("started"), "updated": task.get("updated"), "ended": task.get("ended"),
|
||||
"waiting": bool(task.get("waiting")) and not task.get("ended"),
|
||||
"live": any(step.get("state") == "running" and now - step.get("at", 0) < RUN_WATCH_SECONDS for step in steps),
|
||||
"steps": len(steps), "kinds": kinds, "errors": unresolved(steps),
|
||||
"images": len(images), "cover": images[-1] if images else None, "asks": asks, "from": task.get("from"),
|
||||
"images": len(made), "cover": images[-1] if images else None, "asks": asks, "from": task.get("from"),
|
||||
}
|
||||
|
||||
|
||||
|
||||
+148
-15
@@ -1,14 +1,14 @@
|
||||
"""The AI workbench's routes over ai_tasks.py (the record) and ai_panels.py (panels, answers,
|
||||
messages): the list and one task (reads, open like the other pages), and the writes the page
|
||||
makes - start a task, rename or remove one, answer a panel, write to the AI, answer a gate and
|
||||
set the gates' policies (ai_gates.py), stop a run (ai_run_control.py), hand the AI pictures
|
||||
(ai_pictures.py) - (local_guard.py keeps the writes to this computer)."""
|
||||
set the gates' policies (ai_gates.py), stop a run or say how it turned out (ai_run_control.py),
|
||||
hand the AI pictures (ai_pictures.py) - (local_guard.py keeps the writes to this computer)."""
|
||||
|
||||
import asyncio
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import activity_log, ai_decisions, ai_gates, ai_marks, ai_pictures, ai_run_control, ai_task_canvas, ai_task_changes, ai_task_links, ai_tasks, ai_toolbox
|
||||
from . import activity_log, ai_callers, ai_darkroom, ai_decisions, ai_gates, ai_marks, ai_pictures, ai_privacy, ai_sent, ai_run_control, ai_task_canvas, ai_task_changes, ai_task_links, ai_tasks, ai_toolbox
|
||||
|
||||
|
||||
async def api_list(request):
|
||||
@@ -33,19 +33,56 @@ async def api_start(request):
|
||||
return web.json_response({"task": ai_tasks.task_summary(ai_tasks.start_task(title, about=body.get("about"), from_task=parent))})
|
||||
|
||||
|
||||
async def api_plan_auto(request):
|
||||
"""POST /anomalous/ai_plans/auto: {auto} - build the AI's plans without asking, or ask again (ai_plans.py)."""
|
||||
from . import ai_plans
|
||||
|
||||
body = await _body(request)
|
||||
ai_plans.set_auto((body or {}).get("auto") is True)
|
||||
return web.json_response({"auto": ai_plans.auto()})
|
||||
|
||||
|
||||
async def api_get(request):
|
||||
task = ai_tasks.get_task(request.match_info["task_id"])
|
||||
if task:
|
||||
task["canvas_mode"] = ai_task_canvas.mode_of(task) # the task's own choice, else the user's default
|
||||
task["private"] = ai_privacy.is_private(task) # the same for privacy mode
|
||||
task["reads_disk"] = ai_callers.reads_disk(task.get("client")) # an app privacy mode cannot keep from the files
|
||||
task["decisions"], task["decisions_gap"] = ai_decisions.decisions(task), ai_decisions.gap(task)
|
||||
task["links"] = ai_task_links.links(task["id"]) # the task it goes on from, those going on from it
|
||||
ai_pictures.mark_gone(task) # pictures deleted since: the page says so
|
||||
return web.json_response({"task": task}) if task else web.json_response({"error": "not_found"}, status=404)
|
||||
|
||||
|
||||
async def api_darkroom(request):
|
||||
"""GET /anomalous/ai_darkroom: {helper: {url, model, vision} | null}, the local model that reads for a private
|
||||
task (ai_darkroom.py); POST {detect: true} -> {found: [{url, models}]} on this computer's usual ports,
|
||||
POST {url, model, vision} keeps one, POST {off: true} drops it."""
|
||||
if request.method == "POST":
|
||||
body = await _body(request) or {}
|
||||
try:
|
||||
if body.get("detect") is True:
|
||||
return web.json_response({"found": await asyncio.to_thread(ai_darkroom.found)})
|
||||
if body.get("off") is True:
|
||||
ai_darkroom.turn_off()
|
||||
else:
|
||||
ai_darkroom.save(body.get("url"), body.get("model"), body.get("vision"))
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
return web.json_response({"helper": ai_darkroom.settings()})
|
||||
|
||||
|
||||
async def api_sent(request):
|
||||
"""GET /anomalous/ai_tasks/{id}/sent: {calls: [{at, tool, arguments, text, pictures, error}]}, what the AI of
|
||||
a private task wrote and was answered, as it went (ai_sent.py)."""
|
||||
calls = await asyncio.to_thread(ai_sent.calls, request.match_info["task_id"])
|
||||
return web.json_response({"calls": calls})
|
||||
|
||||
|
||||
async def api_change(request):
|
||||
"""POST /anomalous/ai_tasks/{id}: {"title": "…"} renames it, {"remove": true} takes it off the list,
|
||||
{"canvas": "new" | "current"} sets whether the AI works in a tab of the task's own, {"from": id | null}
|
||||
{"canvas": "new" | "current"} sets whether the AI works in a tab of the task's own, {"privacy": bool}
|
||||
whether its pictures are kept from the AI (ai_privacy.py), {"from": id | null}
|
||||
links it to the task it goes on from (or takes the link off)."""
|
||||
task_id = request.match_info["task_id"]
|
||||
try:
|
||||
@@ -55,12 +92,17 @@ async def api_change(request):
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"error": "Expected {title} or {remove: true}"}, status=400)
|
||||
if body.get("remove") is True:
|
||||
return web.json_response({"removed": ai_tasks.remove_task(task_id)})
|
||||
return web.json_response({"removed": ai_task_links.remove(task_id)})
|
||||
if "canvas" in body:
|
||||
try:
|
||||
return web.json_response({"canvas": ai_task_canvas.set_mode(task_id, body["canvas"])})
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
if "privacy" in body:
|
||||
try:
|
||||
return web.json_response({"privacy": ai_privacy.set_private(task_id, body["privacy"])})
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
if "from" in body:
|
||||
try:
|
||||
return web.json_response({"from": ai_task_links.set_from(task_id, body["from"] if isinstance(body["from"], str) else None)})
|
||||
@@ -73,8 +115,10 @@ async def api_change(request):
|
||||
|
||||
|
||||
async def api_answer(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/answer: {step, block, choice: [ids] | text | skip: true} - the user's answer on a panel."""
|
||||
from . import ai_panels, ai_repaint
|
||||
"""POST /anomalous/ai_tasks/{id}/answer: {step, block, choice: [ids] | text | prompts, negative, pictures, gist | skip: true} -
|
||||
the user's answer on a panel; a prompt box's texts are sealed and the AI gets tokens, its gist as written (ai_sealed.py).
|
||||
{step, block, choice, change: true}: another base taken instead of the one picked (ai_bases.change)."""
|
||||
from . import ai_bases, ai_panels, ai_plans, ai_repaint, ai_sealed
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
@@ -83,16 +127,31 @@ async def api_answer(request):
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"error": "Expected {step, block, choice | text}"}, status=400)
|
||||
task_id, step_id, skip = request.match_info["task_id"], str(body.get("step")), body.get("skip") is True
|
||||
if body.get("change") is True:
|
||||
try:
|
||||
answers = await asyncio.to_thread(ai_bases.change, task_id, step_id, body.get("choice"))
|
||||
except (ai_panels.PanelError, ValueError, OSError) as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
return web.json_response({"answers": answers})
|
||||
try:
|
||||
# A repaint area picked: its picture is made first (outside the lock), so the AI gets it with the answer.
|
||||
extra = await asyncio.to_thread(ai_repaint.prepare, task_id, step_id, body.get("choice"), body.get("text"), skip)
|
||||
except ai_repaint.RepaintError as error:
|
||||
if "prompts" in body and not skip:
|
||||
extra = await asyncio.to_thread(ai_sealed.seal_answer, task_id, body["prompts"], body.get("negative"), body.get("pictures"), body.get("gist"))
|
||||
else:
|
||||
# A repaint area picked: its picture is made first (outside the lock), so the AI gets it with the answer;
|
||||
# a base picked: it is kept in the output folder first (ai_bases.py).
|
||||
extra = (await asyncio.to_thread(ai_repaint.prepare, task_id, step_id, body.get("choice"), body.get("text"), skip)
|
||||
or await asyncio.to_thread(ai_bases.prepare, task_id, step_id, body.get("choice"), skip)
|
||||
or (None if skip else await asyncio.to_thread(ai_plans.prepare, task_id, step_id, body.get("choice"),
|
||||
body.get("plan"), body.get("auto"))))
|
||||
except (ai_repaint.RepaintError, ValueError, OSError) as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
try:
|
||||
answers = ai_panels.answer(task_id, step_id, str(body.get("block")), body.get("choice"), body.get("text"), skip=skip, extra=extra)
|
||||
except ai_panels.PanelError as error:
|
||||
ai_repaint.discard(extra)
|
||||
ai_bases.discard(extra)
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
await asyncio.to_thread(ai_bases.hold, task_id, extra)
|
||||
return web.json_response({"answers": answers})
|
||||
|
||||
|
||||
@@ -144,27 +203,44 @@ async def api_gate(request):
|
||||
|
||||
|
||||
async def api_gates(request):
|
||||
"""GET /anomalous/ai_gates: {policies: {gate: policy}, canvas_tab, tools}; POST {gate, policy} changes one,
|
||||
"""GET /anomalous/ai_gates: {policies: {gate: policy}, canvas_tab, tools, level}; POST {level: careful | balanced | free}
|
||||
applies a safety level, POST {gate, policy} changes one,
|
||||
POST {canvas_tab: new | current} whether new tasks work in a tab of their own (ai_task_canvas.py),
|
||||
POST {privacy: bool} whether new tasks are private, POST {privacy_models: bool} whether a private task
|
||||
keeps its models' names too (ai_privacy.py),
|
||||
POST {runs: call | wait}
|
||||
how the AI waits for a run (ai_gates.run_wait), POST {answer_wait: 60 | 180 | 600} for the user's answer,
|
||||
POST {no_answer: stop | recommended} what happens when that time runs out."""
|
||||
POST {no_answer: stop | recommended} what happens when that time runs out,
|
||||
POST {nodes: any | ways} which custom nodes the AI adds itself (ai_gates.node_rule),
|
||||
POST {routes: allow | ask} an AI placing nodes by itself (ai_gates.route_rule)."""
|
||||
def settings():
|
||||
return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(),
|
||||
"runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer()}
|
||||
return {"policies": ai_gates.policies(), "canvas_tab": ai_task_canvas.default_mode(), "privacy": ai_privacy.default_on(),
|
||||
"privacy_models": ai_privacy.hides_models(),
|
||||
"runs": ai_gates.run_wait(), "answer_wait": ai_gates.answer_wait(), "no_answer": ai_gates.no_answer(),
|
||||
"nodes": ai_gates.node_rule(), "routes": ai_gates.route_rule(), "level": ai_gates.level()}
|
||||
|
||||
if request.method == "GET":
|
||||
return web.json_response(settings())
|
||||
body = await _body(request)
|
||||
try:
|
||||
if "canvas_tab" in (body or {}):
|
||||
if "level" in (body or {}):
|
||||
ai_gates.set_level(body["level"])
|
||||
elif "canvas_tab" in (body or {}):
|
||||
ai_task_canvas.set_default(body["canvas_tab"])
|
||||
elif "privacy" in (body or {}):
|
||||
ai_privacy.set_default(body["privacy"])
|
||||
elif "privacy_models" in (body or {}):
|
||||
ai_privacy.set_hides_models(body["privacy_models"])
|
||||
elif "runs" in (body or {}):
|
||||
ai_gates.set_run_wait(body["runs"])
|
||||
elif "answer_wait" in (body or {}):
|
||||
ai_gates.set_answer_wait(body["answer_wait"])
|
||||
elif "no_answer" in (body or {}):
|
||||
ai_gates.set_no_answer(body["no_answer"])
|
||||
elif "nodes" in (body or {}):
|
||||
ai_gates.set_node_rule(body["nodes"])
|
||||
elif "routes" in (body or {}):
|
||||
ai_gates.set_route_rule(body["routes"])
|
||||
else:
|
||||
ai_gates.set_policy((body or {}).get("gate"), (body or {}).get("policy"))
|
||||
except ValueError as error:
|
||||
@@ -196,6 +272,19 @@ async def api_marked(request):
|
||||
return web.FileResponse(path, headers={"Cache-Control": "private, max-age=86400"})
|
||||
|
||||
|
||||
async def api_way_pick(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/way: {name, on} - a way (做法) the user picks for this task, or takes back."""
|
||||
from . import ai_panels
|
||||
|
||||
body = await _body(request) or {}
|
||||
try:
|
||||
step = ai_toolbox.pick_way(request.match_info["task_id"], str(body.get("name") or ""), bool(body.get("on")))
|
||||
except ai_toolbox.ToolboxError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
ai_panels.wake()
|
||||
return web.json_response({"step": step})
|
||||
|
||||
|
||||
async def api_toolbox_pick(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/toolbox: {mode, nodes, combos, workflows, prompts, models} - what the user hands the AI."""
|
||||
from . import ai_panels
|
||||
@@ -250,6 +339,19 @@ async def api_stop(request):
|
||||
return web.json_response(answer)
|
||||
|
||||
|
||||
async def api_verdict(request):
|
||||
"""POST /anomalous/ai_tasks/{id}/runs/{step}/verdict {verdict: good | bad | null, issues?: [what is off]} - the user's word on a run, told to the AI."""
|
||||
from . import ai_panels
|
||||
|
||||
body = await _body(request) or {}
|
||||
try:
|
||||
verdict = await asyncio.to_thread(ai_run_control.set_verdict, request.match_info["task_id"], request.match_info["step_id"], body.get("verdict"), body.get("issues"))
|
||||
except ai_run_control.RunControlError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
ai_panels.wake()
|
||||
return web.json_response({"verdict": verdict})
|
||||
|
||||
|
||||
async def api_toolbox(request):
|
||||
"""GET /anomalous/ai_toolbox?kind=all: what the picker lists; GET/POST /anomalous/ai_toolbox/favorites."""
|
||||
if request.path.endswith("/favorites"):
|
||||
@@ -268,12 +370,37 @@ async def api_toolbox(request):
|
||||
return web.json_response({"error": str(error)}, status=502)
|
||||
|
||||
|
||||
async def api_blocks(request):
|
||||
"""GET /anomalous/ai_blocks: every ready-made block and skill as the 做法 panel shows them
|
||||
(reference_blocks.status, ai_skills.status); POST {name, on} or {skill, on}: the user turns one on or off for the AI."""
|
||||
from . import ai_skills, mcp_canvas_edit, reference_blocks
|
||||
from .mcp_tools import ToolError
|
||||
|
||||
if request.method == "POST":
|
||||
body = await _body(request) or {}
|
||||
try:
|
||||
if "skill" in body:
|
||||
ai_skills.set_on(str(body.get("skill") or ""), bool(body.get("on")))
|
||||
else:
|
||||
reference_blocks.set_on(str(body.get("name") or ""), bool(body.get("on")))
|
||||
except (reference_blocks.BlockError, ai_skills.SkillError) as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
try:
|
||||
info = await asyncio.to_thread(mcp_canvas_edit.object_info, {"base_url": f"{request.scheme}://{request.host}"})
|
||||
except ToolError as error:
|
||||
return web.json_response({"error": str(error)}, status=502)
|
||||
return web.json_response({"blocks": reference_blocks.status(info), "jobs": list(reference_blocks.JOBS),
|
||||
"skills": ai_skills.status()})
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
activity_log.add_listener(ai_task_changes.note_activity) # the user's changes while an AI works
|
||||
app.router.add_get("/anomalous/ai_tasks", api_list)
|
||||
app.router.add_post("/anomalous/ai_tasks", api_start)
|
||||
app.router.add_post("/anomalous/ai_plans/auto", api_plan_auto)
|
||||
app.router.add_get("/anomalous/ai_tasks/{task_id}", api_get)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}", api_change)
|
||||
app.router.add_get("/anomalous/ai_tasks/{task_id}/sent", api_sent)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/answer", api_answer)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/message", api_message)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/typing", api_typing)
|
||||
@@ -281,11 +408,17 @@ def register_routes(app):
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/marks", api_marks)
|
||||
app.router.add_get("/anomalous/ai_tasks/{task_id}/marks/{name}", api_marked)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/toolbox", api_toolbox_pick)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/way", api_way_pick)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/stop", api_stop)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/runs/{step_id}/verdict", api_verdict)
|
||||
app.router.add_post("/anomalous/ai_tasks/{task_id}/pictures", api_pictures)
|
||||
app.router.add_post("/anomalous/ai_pictures/upload", api_upload)
|
||||
app.router.add_get("/anomalous/ai_toolbox", api_toolbox)
|
||||
app.router.add_get("/anomalous/ai_toolbox/favorites", api_toolbox)
|
||||
app.router.add_post("/anomalous/ai_toolbox/favorites", api_toolbox)
|
||||
app.router.add_get("/anomalous/ai_blocks", api_blocks)
|
||||
app.router.add_post("/anomalous/ai_blocks", api_blocks)
|
||||
app.router.add_get("/anomalous/ai_darkroom", api_darkroom)
|
||||
app.router.add_post("/anomalous/ai_darkroom", api_darkroom)
|
||||
app.router.add_get("/anomalous/ai_gates", api_gates)
|
||||
app.router.add_post("/anomalous/ai_gates", api_gates)
|
||||
|
||||
+27
-7
@@ -2,7 +2,8 @@
|
||||
types, whole custom node packs (by folder: every node type in it), combos, workflows, saved
|
||||
prompts and models picked for a task, and how strictly: prefer
|
||||
(the AI is told to use them first) or only (add_node and new_workflow refuse other node types,
|
||||
except ComfyUI's own built-in ones and the helpers that live only in the page). Favourites
|
||||
except ComfyUI's own built-in ones and the helpers that live only in the page). Ways too
|
||||
(reference_blocks.py, picked in the 做法 view: pick_way): add_nodes builds the picked one for its use. Favourites
|
||||
(user/anomalous/ai_toolbox.json) are what a task has until the user picks for it. A pick is a
|
||||
"user_toolbox" step the AI receives like a message (kind toolbox, ai_panels.py); begin_task
|
||||
names the task's picks and get_toolbox gives them in full. The catalogue the picker shows uses
|
||||
@@ -18,7 +19,7 @@ import uuid
|
||||
from . import ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
KINDS = ("nodes", "packs", "combos", "workflows", "prompts", "models")
|
||||
KINDS = ("nodes", "packs", "combos", "workflows", "prompts", "models", "ways")
|
||||
MODES = ("prefer", "only")
|
||||
MAX_ITEMS = 60
|
||||
MAX_NAME = 300
|
||||
@@ -36,7 +37,7 @@ def _path():
|
||||
def clean(picks):
|
||||
"""{kind: [names]} for KINDS: strings only, each kind once per name, at most MAX_ITEMS."""
|
||||
if not isinstance(picks, dict):
|
||||
raise ToolboxError("Expected {nodes, packs, combos, workflows, prompts, models}")
|
||||
raise ToolboxError("Expected {nodes, packs, combos, workflows, prompts, models, ways}")
|
||||
out = {}
|
||||
for kind in KINDS:
|
||||
items = picks.get(kind) or []
|
||||
@@ -82,14 +83,18 @@ def effective(task):
|
||||
|
||||
def words(box):
|
||||
"""The picks in a sentence or two for the AI."""
|
||||
from . import reference_blocks
|
||||
|
||||
parts = []
|
||||
labels = {"nodes": "node types", "packs": "custom node packs (every node type in these folders; get_toolbox lists them)",
|
||||
"combos": "combos (place_combo)", "workflows": "workflows (open_workflow opens one in a new tab)",
|
||||
"prompts": "prompts (combo:<name> / workflow:<name> are the prompts inside that combo or workflow)",
|
||||
"models": "models (model_id for set_model / add_lora / get_model)"}
|
||||
"models": "models (model_id for set_model / add_lora / get_model)",
|
||||
"ways": "ways of working (for that use add_nodes builds the one picked)"}
|
||||
for kind in KINDS:
|
||||
if box.get(kind):
|
||||
parts.append(f"{labels[kind]}: {', '.join(box[kind])}")
|
||||
names = [reference_blocks.call(name) or name for name in box[kind]] if kind == "ways" else box[kind]
|
||||
parts.append(f"{labels[kind]}: {', '.join(names)}")
|
||||
how = ("Use ONLY these node types (and those of the picked packs) when you add nodes; ComfyUI's own built-in nodes stay allowed. "
|
||||
if box.get("mode") == "only" else "Use these first. ")
|
||||
return f"The user picked these for you to work with. {how}{'; '.join(parts)}. get_toolbox gives them in full."
|
||||
@@ -119,6 +124,17 @@ def pick(task_id, picks, mode="prefer", now=None):
|
||||
return step
|
||||
|
||||
|
||||
def pick_way(task_id, name, on):
|
||||
"""Way `name` onto the picks of task `task_id` (`on`) or off them, the rest as they stand. Returns the step."""
|
||||
from . import reference_blocks
|
||||
|
||||
if name not in reference_blocks.BLOCKS:
|
||||
raise ToolboxError("No such way")
|
||||
box = effective(ai_tasks.get_task(task_id)) or {}
|
||||
ways = [item for item in box.get("ways") or [] if item != name] + ([name] if on else [])
|
||||
return pick(task_id, {**box, "ways": ways}, box.get("mode", "prefer"))
|
||||
|
||||
|
||||
def builtin(info):
|
||||
"""Whether a node type (its /object_info entry) is ComfyUI's own and free: not a custom node, not a paid API node."""
|
||||
module = str((info or {}).get("python_module") or "")
|
||||
@@ -131,11 +147,15 @@ def chosen(box, name, info):
|
||||
|
||||
|
||||
def refused(task_id, types, info):
|
||||
"""The node types among `types` the task's "only" picks do not allow (page-only helpers, unknown to /object_info, pass)."""
|
||||
"""The node types among `types` the task's "only" picks do not allow (page-only helpers, unknown to /object_info, pass;
|
||||
so do those of a way picked)."""
|
||||
from . import reference_blocks
|
||||
|
||||
box = effective(ai_tasks.get_task(task_id)) if task_id else None
|
||||
if not box or box.get("mode") != "only":
|
||||
return []
|
||||
return sorted({name for name in types if name in info and not chosen(box, name, info[name]) and not builtin(info[name])})
|
||||
ways = reference_blocks.node_types(box.get("ways") or [])
|
||||
return sorted({name for name in types if name in info and name not in ways and not chosen(box, name, info[name]) and not builtin(info[name])})
|
||||
|
||||
|
||||
def source_of(info):
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
"""Everything the materials picker (web/modules/ui_ai_toolbox.js) lists besides node types, in
|
||||
one answer (GET /anomalous/ai_toolbox?kind=all): combos with their cover and prompt, saved
|
||||
workflows with their cover, tags and prompts, prompts (the saved ones, and the ones inside
|
||||
combos and workflows, ids "combo:<name>" and "workflow:<name>"), and models with their cover,
|
||||
by the names and ids the AI's tools use. Read in one go and kept a few seconds, so the picker
|
||||
opens at once; node types come from the page's own node definitions. prompt_entries() also
|
||||
combos and workflows, ids "combo:<name>" and "workflow:<name>"), and models with their cover and
|
||||
what the Models page groups them by (`metadata`: its version page, its base model),
|
||||
by the names and ids the AI's tools use, and what the ways are called, for a picked way's chip. Read in one
|
||||
go and kept a few seconds, so the picker opens at once; node types come from the page's own node definitions. prompt_entries() also
|
||||
gives get_toolbox the text of picked prompts."""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import mcp_tools
|
||||
from . import mcp_tools, reference_blocks
|
||||
from .model_catalog import _collect_all_scan_models
|
||||
from .notebooks import _list_notebooks
|
||||
from .quick_index import compact_models
|
||||
@@ -61,7 +62,9 @@ def _models():
|
||||
place = f"{model['subfolder']}/{model['filename']}" if model["subfolder"] else model["filename"]
|
||||
out.append({"id": f"{model['type']}:{model['path_idx']}:{place}", "name": model["name"] or model["civitai_name"] or model["filename"],
|
||||
"file": model["filename"], "type": model["type"], "base": model["base"], "words": model["words"],
|
||||
"cover": model["preview_url"], "folder": model["subfolder"]})
|
||||
"cover": model["preview_url"], "folder": model["subfolder"],
|
||||
"metadata": {"model_id": model["page"], "version_id": model["version"], "version_name": model["version_name"],
|
||||
"baseModel": model["base"], "base_guess": model["base_guess"]}})
|
||||
return out
|
||||
|
||||
|
||||
@@ -77,10 +80,11 @@ def prompt_entries(combos, workflows):
|
||||
|
||||
|
||||
def everything(fresh=False):
|
||||
"""{combos, workflows, prompts, models}, read at most every CACHE_SECONDS."""
|
||||
"""{combos, workflows, prompts, models, ways}, read at most every CACHE_SECONDS."""
|
||||
with _lock:
|
||||
if fresh or _cache["data"] is None or time.monotonic() - _cache["at"] > CACHE_SECONDS:
|
||||
combos, workflows = _combos(), _workflows()
|
||||
_cache.update(at=time.monotonic(), data={"combos": combos, "workflows": workflows,
|
||||
"ways": {name: block["title"] for name, block in reference_blocks.BLOCKS.items()},
|
||||
"prompts": prompt_entries(combos, workflows), "models": _models()})
|
||||
return _cache["data"]
|
||||
|
||||
+3
-1
@@ -37,6 +37,7 @@ from .model_constants import CIVITAI_BACKUP_SUFFIXES, MEDIA_EXTENSIONS, PREVIEW_
|
||||
from .model_download import settings_path as download_settings_path
|
||||
from .notebooks import get_notebooks_dir
|
||||
from .parameters import _invalidate_parameter_notebooks_cache, get_parameters_dir
|
||||
from .workflow_saves import saves_dir
|
||||
from .path_utils import atomic_write_json, require_filename, resolve_within
|
||||
from .recipe_store import get_recipes_dir
|
||||
from .trash import TrashUnavailable, move_to_trash, trash_failure
|
||||
@@ -60,7 +61,7 @@ MAX_MEMBERS = 200_000
|
||||
KEEP_EXPORTS = 3 # finished exports kept in the temp folder for downloading
|
||||
CHUNK = 1024 * 1024
|
||||
|
||||
PART_ORDER = ("recipes", "combos", "materials", "parameters", "comfy_workflows")
|
||||
PART_ORDER = ("recipes", "combos", "materials", "parameters", "saves", "comfy_workflows")
|
||||
|
||||
|
||||
def _comfy_workflows_dir():
|
||||
@@ -74,6 +75,7 @@ def part_dirs():
|
||||
"combos": get_notebooks_dir(),
|
||||
"materials": get_materials_dir(),
|
||||
"parameters": get_parameters_dir(),
|
||||
"saves": saves_dir(),
|
||||
"comfy_workflows": _comfy_workflows_dir(),
|
||||
}
|
||||
|
||||
|
||||
@@ -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()}}
|
||||
@@ -0,0 +1,300 @@
|
||||
{
|
||||
"2x_animesharp_v4": {
|
||||
"title": "2x-AnimeSharpV4 (RCAN)",
|
||||
"file": "2x-AnimeSharpV4_RCAN.safetensors",
|
||||
"folder": "upscale_models",
|
||||
"subfolder": "",
|
||||
"size": 31053198,
|
||||
"sha256": "6470bb91d6622d6acdff81132c1a8615b961b919ce2b9a01ce993378500cfbe1",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "github",
|
||||
"url": "https://github.com/Kim2091/Kim2091-Models/releases/download/2x-AnimeSharpV4/2x-AnimeSharpV4_RCAN.safetensors",
|
||||
"page": "https://openmodeldb.info/models/2x-AnimeSharpV4"
|
||||
}
|
||||
]
|
||||
},
|
||||
"4x_ultrasharp": {
|
||||
"title": "4x-UltraSharp",
|
||||
"file": "4x-UltraSharp.pth",
|
||||
"folder": "upscale_models",
|
||||
"subfolder": "",
|
||||
"size": 66961958,
|
||||
"sha256": "a5812231fc936b42af08a5edba784195495d303d5b3248c24489ef0c4021fe01",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Kim2091/UltraSharp/resolve/main/4x-UltraSharp.pth",
|
||||
"page": "https://huggingface.co/Kim2091/UltraSharp"
|
||||
},
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/125843?fileId=90900",
|
||||
"page": "https://civitai.com/models/116225"
|
||||
}
|
||||
]
|
||||
},
|
||||
"4x_animesharp": {
|
||||
"title": "4x-AnimeSharp",
|
||||
"file": "4x-AnimeSharp.pth",
|
||||
"folder": "upscale_models",
|
||||
"subfolder": "",
|
||||
"size": 67010245,
|
||||
"sha256": "e7a7de2dafd7331c1992862bbbcd9e9712a9f9f8e6303f0aaa59b4341d359bab",
|
||||
"license": "CC-BY-NC-SA-4.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Kim2091/AnimeSharp/resolve/main/4x-AnimeSharp.pth",
|
||||
"page": "https://huggingface.co/Kim2091/AnimeSharp"
|
||||
}
|
||||
]
|
||||
},
|
||||
"face_yolov8m": {
|
||||
"title": "face_yolov8m (face detector)",
|
||||
"file": "face_yolov8m.pt",
|
||||
"folder": "ultralytics_bbox",
|
||||
"subfolder": "",
|
||||
"size": 52026019,
|
||||
"sha256": "717923c19b3f4bbf5250b728f1fa6b2cb72a33aed1d236ea9caf0e21ad943e5f",
|
||||
"license": "Apache-2.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Bingsu/adetailer/resolve/main/face_yolov8m.pt",
|
||||
"page": "https://huggingface.co/Bingsu/adetailer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"hand_yolov8s": {
|
||||
"title": "hand_yolov8s (hand detector)",
|
||||
"file": "hand_yolov8s.pt",
|
||||
"folder": "ultralytics_bbox",
|
||||
"subfolder": "",
|
||||
"size": 22507643,
|
||||
"sha256": "70b540063fbc385736d8258970744a4afbc4cbf7932134bae3b24cdadeadec06",
|
||||
"license": "Apache-2.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Bingsu/adetailer/resolve/main/hand_yolov8s.pt",
|
||||
"page": "https://huggingface.co/Bingsu/adetailer"
|
||||
}
|
||||
]
|
||||
},
|
||||
"sam3_1": {
|
||||
"title": "SAM 3.1 (finds things by words)",
|
||||
"file": "sam3.1_multiplex_fp16.safetensors",
|
||||
"folder": "checkpoints",
|
||||
"subfolder": "SAM3",
|
||||
"size": 1745546848,
|
||||
"sha256": "9ba99c92703c2e8b4f47de2d34a539bb8e18923049e238b780d70dbe6368eb03",
|
||||
"license": "SAM License",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Comfy-Org/sam3.1/resolve/main/checkpoints/sam3.1_multiplex_fp16.safetensors",
|
||||
"page": "https://huggingface.co/Comfy-Org/sam3.1"
|
||||
}
|
||||
]
|
||||
},
|
||||
"union_promax_sdxl": {
|
||||
"title": "xinsir ControlNet Union ProMax (SDXL)",
|
||||
"file": "xinsir-controlnet-union-sdxl-promax.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "SDXL_1.0",
|
||||
"size": 2513342408,
|
||||
"sha256": "9fae2e50cb431bfcbe05822b59ec2228df545ef27f711dea8949e9f4ed9f7cdc",
|
||||
"license": "Apache-2.0",
|
||||
"base": "SDXL 1.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/xinsir/controlnet-union-sdxl-1.0/resolve/main/diffusion_pytorch_model_promax.safetensors",
|
||||
"page": "https://huggingface.co/xinsir/controlnet-union-sdxl-1.0"
|
||||
},
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/655836?fileId=570909",
|
||||
"page": "https://civitai.com/models/136070"
|
||||
}
|
||||
]
|
||||
},
|
||||
"noob_tile": {
|
||||
"title": "NoobAI XL tile ControlNet (Eugeoter)",
|
||||
"file": "noob-sdxl-controlnet-tile.fp16.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "NoobAI",
|
||||
"size": 2502139136,
|
||||
"sha256": "d6d676f29153357ac8d57727e35b8edc090aa4de57e861b878872ff45c432615",
|
||||
"license": "see the model page",
|
||||
"base": "NoobAI",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/Eugeoter/noob-sdxl-controlnet-tile/resolve/main/diffusion_pytorch_model.fp16.safetensors",
|
||||
"page": "https://huggingface.co/Eugeoter/noob-sdxl-controlnet-tile"
|
||||
},
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/1290048?fileId=1194650",
|
||||
"page": "https://civitai.com/models/136070"
|
||||
}
|
||||
]
|
||||
},
|
||||
"xinsir_depth_sdxl": {
|
||||
"title": "xinsir ControlNet depth (SDXL)",
|
||||
"file": "controlnetxlCNXL_xinsirDepth.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "SDXL_1.0",
|
||||
"size": 2502139104,
|
||||
"sha256": "ea3770af4c33ea8ba8dccc0106ec5d5c5c6e504eb382119a3ccac3c9d973d344",
|
||||
"license": "Apache-2.0",
|
||||
"base": "SDXL 1.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/657836?fileId=572833",
|
||||
"page": "https://civitai.com/models/136070"
|
||||
}
|
||||
]
|
||||
},
|
||||
"noob_openpose": {
|
||||
"title": "NoobAI XL ControlNet openpose",
|
||||
"file": "noobaiXLControlnet_openposeModel.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "NoobAI",
|
||||
"size": 2502140008,
|
||||
"sha256": "918cfc4d7def165ea7a06bee10841fe525332515f8a0486c5cb2a171ec40b873",
|
||||
"license": "see the model page",
|
||||
"base": "NoobAI",
|
||||
"sources": [
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/1077649?fileId=982755",
|
||||
"page": "https://civitai.com/models/962537"
|
||||
}
|
||||
]
|
||||
},
|
||||
"thibauld_openpose_sdxl": {
|
||||
"title": "thibauld openpose LoRA ControlNet (SDXL)",
|
||||
"file": "controlnetxlCNXL_thibauldOpenposeLora.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "SDXL_1.0",
|
||||
"size": 774423024,
|
||||
"sha256": "8afa079285bf9384eaf8f6322884cb4f24bbe405da490f91f5540d3bff585e75",
|
||||
"license": "see the model page",
|
||||
"base": "SDXL 1.0",
|
||||
"sources": [
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/255155?fileId=197900",
|
||||
"page": "https://civitai.com/models/136070"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_lllite_depth": {
|
||||
"title": "anima-lllite-depth-1",
|
||||
"file": "anima-lllite-depth-1.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 7864128,
|
||||
"sha256": "81357e51cd59874b4fbdda1eb3117aa73eafe36cc7efb9cf80a152d6bc8d0602",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-depth-1.safetensors",
|
||||
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_lllite_pose": {
|
||||
"title": "anima-lllite-pose-1",
|
||||
"file": "anima-lllite-pose-1.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 23310040,
|
||||
"sha256": "ddb543be5e74ce8ca79ae45807aa0c1328a4888e00706727c6fd7100dde7c864",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-pose-1.safetensors",
|
||||
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_lllite_inpainting": {
|
||||
"title": "anima-lllite-inpainting-v2",
|
||||
"file": "anima-lllite-inpainting-v2.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 65772872,
|
||||
"sha256": "5242e677d2be34ee70ca7c97c3b14ff5ee49838c03fc1e60ac4852a180db6ef5",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-inpainting-v2.safetensors",
|
||||
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_lllite_lineart": {
|
||||
"title": "anima-lllite-lineart-1",
|
||||
"file": "anima-lllite-lineart-1.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 7787888,
|
||||
"sha256": "1abfde93493445390632788339627c37c69c7792aef1e8104cf15ea5f2d379e9",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/kohya-ss/Anima-LLLite/resolve/main/anima-lllite-lineart-1.safetensors",
|
||||
"page": "https://huggingface.co/kohya-ss/Anima-LLLite"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_tile_repair": {
|
||||
"title": "Anima Tile & Repair ControlNet-LLLite v2",
|
||||
"file": "animaTileRepair_v20.safetensors",
|
||||
"folder": "controlnet",
|
||||
"subfolder": "",
|
||||
"size": 94037744,
|
||||
"sha256": "be5a9ead6527329237a66a0eb234ccea4cddb140c1d1e1b7d0776e43f5ff1330",
|
||||
"license": "Civitai: credit not needed, derivatives allowed",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "civitai",
|
||||
"url": "https://civitai.com/api/download/models/3068951?fileId=2947687",
|
||||
"page": "https://civitai.com/models/2708551"
|
||||
}
|
||||
]
|
||||
},
|
||||
"anima_turbo_lora": {
|
||||
"title": "anima-turbo-lora-v0.2 (Anima, fewer steps)",
|
||||
"file": "anima-turbo-lora-v0.2.safetensors",
|
||||
"folder": "loras",
|
||||
"subfolder": "",
|
||||
"size": 148902616,
|
||||
"sha256": "1b55e40bdb1d0e5a78cb498f245fccfdaae97823265db957d2aabdcf4cd3caf1",
|
||||
"license": "CircleStone Labs Non-Commercial License v1.0",
|
||||
"base": "Anima",
|
||||
"sources": [
|
||||
{
|
||||
"site": "huggingface",
|
||||
"url": "https://huggingface.co/circlestone-labs/Anima-Official-LoRAs/resolve/main/anima-turbo-lora-v0.2.safetensors",
|
||||
"page": "https://huggingface.co/circlestone-labs/Anima-Official-LoRAs"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
{
|
||||
"job": "new_background",
|
||||
"rank": 4,
|
||||
"short": "new background",
|
||||
"title": {
|
||||
"zh": "换场景·人物不变",
|
||||
"en": "New scene, same character"
|
||||
},
|
||||
"about": {
|
||||
"zh": "自动抠出人物,只按新场景重画背景。",
|
||||
"en": "Cuts the character out and redraws only the background as the new scene."
|
||||
},
|
||||
"source": {
|
||||
"zh": "常见的“抠图换背景”做法:inspyrenet-rembg 抠人物,反选成背景,用 ComfyUI 自带的局部重绘节点只重画背景",
|
||||
"en": "The common cut-out-and-replace-the-background way: inspyrenet-rembg finds the character, its inverse is the background, ComfyUI's own inpainting nodes redraw only that"
|
||||
},
|
||||
"packs": {
|
||||
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
|
||||
},
|
||||
"takes": "picture",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"class_type": "InvertMask",
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"cut",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"background",
|
||||
0
|
||||
],
|
||||
"expand": 6,
|
||||
"tapered_corners": true
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"edge",
|
||||
0
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"cut",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-28",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 30
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
{
|
||||
"job": "follow",
|
||||
"want": "lines",
|
||||
"rank": 4,
|
||||
"short": "colour lines",
|
||||
"title": {
|
||||
"zh": "线稿上色",
|
||||
"en": "Colour a line drawing"
|
||||
},
|
||||
"about": {
|
||||
"zh": "给黑白线稿上色,颜色和画风写在提示词里。",
|
||||
"en": "Colours a line drawing; the prompt says the colours and style."
|
||||
},
|
||||
"source": {
|
||||
"zh": "Union 控制网的线稿模式(参考工作流作者的控制网组用的就是 Union)",
|
||||
"en": "The union ControlNet's lineart mode (the reference workflow's ControlNet group uses a union model)"
|
||||
},
|
||||
"packs": {},
|
||||
"takes": "reference",
|
||||
"control": "lineart",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"lines": {
|
||||
"class_type": "ImageInvert",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"lines",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.9,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.9
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"lines",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"anima_lllite_lineart"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "1216×832",
|
||||
"seconds": 34
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"packs": {
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"control": null,
|
||||
"lllite": "lineart",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"lines": {
|
||||
"class_type": "ImageInvert",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"lines",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"lines",
|
||||
0
|
||||
],
|
||||
"strength": 1.0,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 1.0,
|
||||
"preserve_wrapper": true
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model Anima's lineart LLLite steers it (strength 1.0). 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"job": "follow",
|
||||
"want": "layout",
|
||||
"rank": 2,
|
||||
"short": "its layout",
|
||||
"title": {
|
||||
"zh": "照着构图画",
|
||||
"en": "Follow a layout"
|
||||
},
|
||||
"about": {
|
||||
"zh": "读出参考图的深度,照着它的构图画。",
|
||||
"en": "Reads a picture's depth and draws to its layout."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「底图控制网」组(作者默认用深度)",
|
||||
"en": "The reference workflow's ControlNet group (depth, its default)"
|
||||
},
|
||||
"packs": {
|
||||
"DepthAnythingV2Preprocessor": "comfyui_controlnet_aux"
|
||||
},
|
||||
"takes": "reference",
|
||||
"control": "depth",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"depth": {
|
||||
"class_type": "DepthAnythingV2Preprocessor",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"ckpt_name": "depth_anything_v2_vitl.pth",
|
||||
"resolution": 1024
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"depth",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.7,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.7
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"depth",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"DepthAnythingV2Preprocessor": "comfyui_controlnet_aux",
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"control": null,
|
||||
"lllite": "depth",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"depth": {
|
||||
"class_type": "DepthAnythingV2Preprocessor",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"ckpt_name": "depth_anything_v2_vitl.pth",
|
||||
"resolution": 1024
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"depth",
|
||||
0
|
||||
],
|
||||
"strength": 1.0,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 1.0,
|
||||
"preserve_wrapper": true
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"depth",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model an Anima ControlNet-LLLite steers it, more gently than an SDXL ControlNet: say in the prompt what the picture shows (how many people, what they do), or the prompt wins. Strength 1.0 for all the steps; up to 1.5 holds it closer. run_workflow. 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."
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
"xinsir_depth_sdxl",
|
||||
"union_promax_sdxl",
|
||||
"anima_lllite_depth"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima",
|
||||
"size": "1216×832",
|
||||
"seconds": 115
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"job": "follow",
|
||||
"want": "pose",
|
||||
"rank": 1,
|
||||
"short": "its pose",
|
||||
"title": {
|
||||
"zh": "照着姿势画",
|
||||
"en": "Follow a pose"
|
||||
},
|
||||
"about": {
|
||||
"zh": "读出参考图的姿势,照着这个姿势画。",
|
||||
"en": "Reads a picture's pose and draws to it."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「底图控制网」组",
|
||||
"en": "The reference workflow's ControlNet group"
|
||||
},
|
||||
"packs": {
|
||||
"DWPreprocessor": "comfyui_controlnet_aux"
|
||||
},
|
||||
"takes": "reference",
|
||||
"control": "pose",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"pose": {
|
||||
"class_type": "DWPreprocessor",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"detect_hand": "enable",
|
||||
"detect_body": "enable",
|
||||
"detect_face": "enable",
|
||||
"resolution": 1024,
|
||||
"bbox_detector": "yolox_l.torchscript.pt",
|
||||
"pose_estimator": "dw-ll_ucoco_384_bs5.torchscript.pt",
|
||||
"scale_stick_for_xinsr_cn": "disable"
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"pose",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.8,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"pose",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"DWPreprocessor": "comfyui_controlnet_aux",
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"control": null,
|
||||
"lllite": "pose",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"pose": {
|
||||
"class_type": "DWPreprocessor",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"detect_hand": "enable",
|
||||
"detect_body": "enable",
|
||||
"detect_face": "enable",
|
||||
"resolution": 1024,
|
||||
"bbox_detector": "yolox_l.torchscript.pt",
|
||||
"pose_estimator": "dw-ll_ucoco_384_bs5.torchscript.pt",
|
||||
"scale_stick_for_xinsr_cn": "disable"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"pose",
|
||||
0
|
||||
],
|
||||
"strength": 1.0,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 1.0,
|
||||
"preserve_wrapper": true
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"pose",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model an Anima ControlNet-LLLite steers it, more gently than an SDXL ControlNet: say in the prompt what the picture shows (how many people, what they do), or the prompt wins. Strength 1.0 for all the steps; up to 1.5 holds it closer. run_workflow. Its pose skeleton is among the run's previews: if it is empty or wrong (a crowded or very stylised picture), try control_depth."
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
"noob_openpose",
|
||||
"thibauld_openpose_sdxl",
|
||||
"union_promax_sdxl",
|
||||
"anima_lllite_pose"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 64
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
{
|
||||
"job": "detail",
|
||||
"rank": 1,
|
||||
"short": "after",
|
||||
"title": {
|
||||
"zh": "部位细化(脸和手)",
|
||||
"en": "Face and hand detailing"
|
||||
},
|
||||
"about": {
|
||||
"zh": "出图后找出脸和手,各自放大重画再贴回去。",
|
||||
"en": "After a picture is made, faces and hands are found, redrawn larger and pasted back."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「部位细化」组",
|
||||
"en": "The reference workflow's detailing group"
|
||||
},
|
||||
"packs": {
|
||||
"FaceDetailer": "ComfyUI-Impact-Pack",
|
||||
"UltralyticsDetectorProvider": "ComfyUI-Impact-Subpack"
|
||||
},
|
||||
"detectors": {
|
||||
"face": "face_yolov8",
|
||||
"hand": "hand_yolov8"
|
||||
},
|
||||
"nodes": {
|
||||
"face_finder": {
|
||||
"class_type": "UltralyticsDetectorProvider",
|
||||
"inputs": {
|
||||
"model_name": "$face"
|
||||
}
|
||||
},
|
||||
"hand_finder": {
|
||||
"class_type": "UltralyticsDetectorProvider",
|
||||
"inputs": {
|
||||
"model_name": "$hand"
|
||||
}
|
||||
},
|
||||
"face": {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"bbox_detector": [
|
||||
"face_finder",
|
||||
0
|
||||
],
|
||||
"denoise": 0.4,
|
||||
"guide_size": 512
|
||||
}
|
||||
},
|
||||
"hands": {
|
||||
"class_type": "FaceDetailer",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"face",
|
||||
"image"
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"bbox_detector": [
|
||||
"hand_finder",
|
||||
0
|
||||
],
|
||||
"denoise": 0.45,
|
||||
"guide_size": 512
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"hands",
|
||||
"image"
|
||||
],
|
||||
"filename_prefix": "detailed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "run_workflow saves the picture as before and the detailed one beside it (prefix detailed).",
|
||||
"gives": {
|
||||
"save": "save"
|
||||
},
|
||||
"models": [
|
||||
"face_yolov8m",
|
||||
"hand_yolov8s"
|
||||
],
|
||||
"tested": {}
|
||||
}
|
||||
@@ -0,0 +1,492 @@
|
||||
{
|
||||
"job": "detail",
|
||||
"want": "hands",
|
||||
"rank": 2,
|
||||
"short": "enlarge, then hands (fallback)",
|
||||
"enlarge": {
|
||||
"megapixels": 4.2,
|
||||
"most": 2.0,
|
||||
"unknown": 1.5
|
||||
},
|
||||
"title": {
|
||||
"zh": "修手·先放大再修(备选)",
|
||||
"en": "Fix hands: enlarge, then fix (fallback)"
|
||||
},
|
||||
"about": {
|
||||
"zh": "整张图放大到约 400 万像素(小图 2 倍)轻轻重画,让手变大;再把每只手放大重画后贴回去:有 SAM3 模型时先按文字找出贴着手形的轮廓重画一遍,再用手部检测框补修一遍。",
|
||||
"en": "The whole picture is enlarged to about 4 megapixels (2× for a small one) and redrawn lightly, so the hands grow; then each hand is redrawn larger and pasted back: with the SAM3 model here, first inside the outline it finds by the word, then once more in the box the hand detector finds."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考作者的「先放大重画」和「手部精修」两步合在一起,2026-10-29 在 RTX 4050 上试出这组数;SAM3 按文字找手那一轮照他的连法,2026-11-03 加入",
|
||||
"en": "The reference author's light redraw after enlarging and his hand detailing, put together; these numbers tried on an RTX 4050 on 2026-10-29; the SAM3 pass finding hands by the word follows his wiring, added on 2026-11-03"
|
||||
},
|
||||
"packs": {
|
||||
"FaceDetailer": "ComfyUI-Impact-Pack",
|
||||
"UltralyticsDetectorProvider": "ComfyUI-Impact-Subpack"
|
||||
},
|
||||
"takes": "picture",
|
||||
"detectors": {
|
||||
"hand": "hand_yolov8"
|
||||
},
|
||||
"checkpoints": {
|
||||
"sam3": "sam3"
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.4
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"bigger": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"scale_by": "$enlarge"
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"bigger",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"hand_seeker": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"optional": true,
|
||||
"_meta": {
|
||||
"title": "SAM3"
|
||||
},
|
||||
"inputs": {
|
||||
"ckpt_name": "$sam3"
|
||||
}
|
||||
},
|
||||
"hand_words": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"optional": true,
|
||||
"_meta": {
|
||||
"title": "SAM3: what to find"
|
||||
},
|
||||
"inputs": {
|
||||
"text": "hand:8",
|
||||
"clip": [
|
||||
"hand_seeker",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"hand_shapes": {
|
||||
"class_type": "SAM3_Detect",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"model": [
|
||||
"hand_seeker",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"hand_words",
|
||||
0
|
||||
],
|
||||
"threshold": 0.5,
|
||||
"refine_iterations": 2,
|
||||
"individual_masks": false
|
||||
}
|
||||
},
|
||||
"hand_areas": {
|
||||
"class_type": "MaskToSEGS",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"hand_shapes",
|
||||
0
|
||||
],
|
||||
"combined": false,
|
||||
"crop_factor": 2.0,
|
||||
"bbox_fill": false,
|
||||
"drop_size": 10,
|
||||
"contour_fill": false
|
||||
}
|
||||
},
|
||||
"shaped_hands": {
|
||||
"class_type": "DetailerForEach",
|
||||
"like": "$sampler",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"segs": [
|
||||
"hand_areas",
|
||||
0
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"guide_size": 768,
|
||||
"max_size": 1024,
|
||||
"denoise": 0.45,
|
||||
"feather": 5,
|
||||
"noise_mask": true,
|
||||
"force_inpaint": true,
|
||||
"noise_mask_feather": 10,
|
||||
"wildcard": "detailed hand"
|
||||
}
|
||||
},
|
||||
"hand_finder": {
|
||||
"class_type": "UltralyticsDetectorProvider",
|
||||
"inputs": {
|
||||
"model_name": "$hand"
|
||||
}
|
||||
},
|
||||
"hands": {
|
||||
"class_type": "FaceDetailer",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"shaped_hands|$decode",
|
||||
0
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"bbox_detector": [
|
||||
"hand_finder",
|
||||
0
|
||||
],
|
||||
"guide_size": 768,
|
||||
"max_size": 1024,
|
||||
"denoise": 0.45,
|
||||
"bbox_threshold": 0.3,
|
||||
"bbox_crop_factor": 2.0,
|
||||
"feather": 5,
|
||||
"drop_size": 10,
|
||||
"wildcard": "detailed hand"
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"hands",
|
||||
"image"
|
||||
],
|
||||
"filename_prefix": "hands_fixed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"sam3_1",
|
||||
"anima_turbo_lora"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-03",
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima (Kirazuri v4, Turbo LoRA)",
|
||||
"size": "1280×832 → 2560×1664",
|
||||
"seconds": 105
|
||||
},
|
||||
{
|
||||
"model": "Anima (MiaoMiao Harem 1.5, Turbo LoRA, SAM3 pass)",
|
||||
"size": "832×1216 → 1664×2432",
|
||||
"seconds": 112
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"loras": {
|
||||
"turbo": "anima-turbo-lora"
|
||||
},
|
||||
"without_loras": "On an Anima model the Anima Turbo LoRA (anima-turbo-lora-v0.2, the user can get it in the ways panel) makes this about three times faster: 10 steps at CFG 1.",
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.4,
|
||||
"steps": 10,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal"
|
||||
}
|
||||
},
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"turbo": {
|
||||
"class_type": "LoraLoaderModelOnly",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"lora_name": "$turbo",
|
||||
"strength_model": 0.7
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"bigger": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"scale_by": "$enlarge"
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"bigger",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"hand_seeker": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"optional": true,
|
||||
"_meta": {
|
||||
"title": "SAM3"
|
||||
},
|
||||
"inputs": {
|
||||
"ckpt_name": "$sam3"
|
||||
}
|
||||
},
|
||||
"hand_words": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"optional": true,
|
||||
"_meta": {
|
||||
"title": "SAM3: what to find"
|
||||
},
|
||||
"inputs": {
|
||||
"text": "hand:8",
|
||||
"clip": [
|
||||
"hand_seeker",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"hand_shapes": {
|
||||
"class_type": "SAM3_Detect",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"model": [
|
||||
"hand_seeker",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"conditioning": [
|
||||
"hand_words",
|
||||
0
|
||||
],
|
||||
"threshold": 0.5,
|
||||
"refine_iterations": 2,
|
||||
"individual_masks": false
|
||||
}
|
||||
},
|
||||
"hand_areas": {
|
||||
"class_type": "MaskToSEGS",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"hand_shapes",
|
||||
0
|
||||
],
|
||||
"combined": false,
|
||||
"crop_factor": 2.0,
|
||||
"bbox_fill": false,
|
||||
"drop_size": 10,
|
||||
"contour_fill": false
|
||||
}
|
||||
},
|
||||
"shaped_hands": {
|
||||
"class_type": "DetailerForEach",
|
||||
"like": "$sampler",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image": [
|
||||
"$decode",
|
||||
0
|
||||
],
|
||||
"segs": [
|
||||
"hand_areas",
|
||||
0
|
||||
],
|
||||
"model": [
|
||||
"turbo",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"guide_size": 768,
|
||||
"max_size": 1024,
|
||||
"steps": 10,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal",
|
||||
"denoise": 0.45,
|
||||
"feather": 5,
|
||||
"noise_mask": true,
|
||||
"force_inpaint": true,
|
||||
"noise_mask_feather": 10,
|
||||
"wildcard": "detailed hand"
|
||||
}
|
||||
},
|
||||
"hand_finder": {
|
||||
"class_type": "UltralyticsDetectorProvider",
|
||||
"inputs": {
|
||||
"model_name": "$hand"
|
||||
}
|
||||
},
|
||||
"hands": {
|
||||
"class_type": "FaceDetailer",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"shaped_hands|$decode",
|
||||
0
|
||||
],
|
||||
"model": [
|
||||
"turbo",
|
||||
0
|
||||
],
|
||||
"clip": [
|
||||
"$sampler.positive.clip",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"bbox_detector": [
|
||||
"hand_finder",
|
||||
0
|
||||
],
|
||||
"guide_size": 768,
|
||||
"max_size": 1024,
|
||||
"steps": 10,
|
||||
"cfg": 1.0,
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal",
|
||||
"denoise": 0.45,
|
||||
"bbox_threshold": 0.3,
|
||||
"bbox_crop_factor": 2.0,
|
||||
"feather": 5,
|
||||
"drop_size": 10,
|
||||
"wildcard": "detailed hand"
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"hands",
|
||||
"image"
|
||||
],
|
||||
"filename_prefix": "hands_fixed"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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. On this Anima model the Anima Turbo LoRA (0.7) makes both passes 10 steps at CFG 1, about 105 s for 1280×832 (2×) on a 6 GB card. 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"job": "repaint",
|
||||
"rank": 3,
|
||||
"short": "built-in nodes",
|
||||
"title": {
|
||||
"zh": "局部重绘·官方节点",
|
||||
"en": "Repaint: built-in nodes"
|
||||
},
|
||||
"about": {
|
||||
"zh": "只用 ComfyUI 自带的节点重画圈出的地方,没装别的包时用。",
|
||||
"en": "ComfyUI's own nodes only: for when the packs above are missing."
|
||||
},
|
||||
"source": {
|
||||
"zh": "ComfyUI 官方的局部重绘做法",
|
||||
"en": "ComfyUI's own way to inpaint"
|
||||
},
|
||||
"packs": {},
|
||||
"takes": "inpaint",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.5
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
{
|
||||
"job": "repaint",
|
||||
"rank": 1,
|
||||
"short": "small fixes",
|
||||
"title": {
|
||||
"zh": "局部重绘·裁切缝合",
|
||||
"en": "Repaint: crop and stitch"
|
||||
},
|
||||
"about": {
|
||||
"zh": "把圈出的地方连同周围一圈裁出来放大重画,再贴回去,适合小地方。",
|
||||
"en": "Crops the marked area with a margin, redraws it larger and pastes it back: for small spots."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「局部重绘」组,和节点包自带的示例一致",
|
||||
"en": "The reference workflow's repaint group, as the pack's own example"
|
||||
},
|
||||
"packs": {
|
||||
"InpaintCropImproved": "ComfyUI-Inpaint-CropAndStitch",
|
||||
"InpaintStitchImproved": "ComfyUI-Inpaint-CropAndStitch"
|
||||
},
|
||||
"instead": "inpaint_basic",
|
||||
"takes": "inpaint",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"crop": {
|
||||
"class_type": "InpaintCropImproved",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
],
|
||||
"mask_blend_pixels": 32,
|
||||
"context_from_mask_extend_factor": 2.0,
|
||||
"output_resize_to_target_size": true,
|
||||
"output_target_width": 1024,
|
||||
"output_target_height": 1024,
|
||||
"downscale_algorithm": "lanczos",
|
||||
"upscale_algorithm": "lanczos"
|
||||
}
|
||||
},
|
||||
"condition": {
|
||||
"class_type": "InpaintModelConditioning",
|
||||
"inputs": {
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"pixels": [
|
||||
"crop",
|
||||
"cropped_image"
|
||||
],
|
||||
"mask": [
|
||||
"crop",
|
||||
"cropped_mask"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"stitch": {
|
||||
"class_type": "InpaintStitchImproved",
|
||||
"inputs": {
|
||||
"stitcher": [
|
||||
"crop",
|
||||
"stitcher"
|
||||
],
|
||||
"inpainted_image": [
|
||||
"$decode",
|
||||
0
|
||||
]
|
||||
},
|
||||
"wire_to": "$save"
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.45
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"packs": {
|
||||
"InpaintCropImproved": "ComfyUI-Inpaint-CropAndStitch",
|
||||
"InpaintStitchImproved": "ComfyUI-Inpaint-CropAndStitch",
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"lllite": "inpaint",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"patch": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"soft",
|
||||
0
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"crop",
|
||||
"cropped_image"
|
||||
],
|
||||
"strength": 1.0,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 1.0,
|
||||
"preserve_wrapper": true,
|
||||
"mask": [
|
||||
"crop",
|
||||
"cropped_mask"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"crop": {
|
||||
"class_type": "InpaintCropImproved",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
],
|
||||
"mask_blend_pixels": 32,
|
||||
"context_from_mask_extend_factor": 2.0,
|
||||
"output_resize_to_target_size": true,
|
||||
"output_target_width": 1024,
|
||||
"output_target_height": 1024,
|
||||
"downscale_algorithm": "lanczos",
|
||||
"upscale_algorithm": "lanczos"
|
||||
}
|
||||
},
|
||||
"condition": {
|
||||
"class_type": "InpaintModelConditioning",
|
||||
"inputs": {
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"pixels": [
|
||||
"crop",
|
||||
"cropped_image"
|
||||
],
|
||||
"mask": [
|
||||
"crop",
|
||||
"cropped_mask"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"stitch": {
|
||||
"class_type": "InpaintStitchImproved",
|
||||
"inputs": {
|
||||
"stitcher": [
|
||||
"crop",
|
||||
"stitcher"
|
||||
],
|
||||
"inpainted_image": [
|
||||
"$decode",
|
||||
0
|
||||
]
|
||||
},
|
||||
"wire_to": "$save"
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model its repaint LLLite guides it too; 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."
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
"anima_lllite_inpainting"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": null
|
||||
},
|
||||
{
|
||||
"model": "Anima",
|
||||
"size": "1216×832",
|
||||
"seconds": 130
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"job": "repaint",
|
||||
"want": "best",
|
||||
"rank": 2,
|
||||
"short": "best blend, slow",
|
||||
"title": {
|
||||
"zh": "局部重绘·LanPaint",
|
||||
"en": "Repaint: LanPaint"
|
||||
},
|
||||
"about": {
|
||||
"zh": "换成 LanPaint 的采样器,边缘融合最好,但慢。",
|
||||
"en": "LanPaint's sampler: the best blend at the edges, but slow."
|
||||
},
|
||||
"source": {
|
||||
"zh": "节点包 LanPaint 自带的 SDXL 示例",
|
||||
"en": "LanPaint's own SDXL example"
|
||||
},
|
||||
"packs": {
|
||||
"LanPaint_KSampler": "LanPaint",
|
||||
"LanPaint_ImageEncode": "LanPaint",
|
||||
"LanPaint_ImageDecode": "LanPaint"
|
||||
},
|
||||
"instead": "inpaint_crop_stitch",
|
||||
"takes": "inpaint",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "LanPaint_ImageEncode",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"sampler": {
|
||||
"class_type": "LanPaint_KSampler",
|
||||
"replaces": "$sampler",
|
||||
"inputs": {
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "LanPaint_ImageDecode",
|
||||
"replaces": "$decode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"sampler",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"picture",
|
||||
1
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 82
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
{
|
||||
"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.",
|
||||
"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",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-03",
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima (MiaoMiao Harem 1.5)",
|
||||
"size": "832×1216",
|
||||
"seconds": 78
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
{
|
||||
"job": "new_background",
|
||||
"want": "given",
|
||||
"rank": 5,
|
||||
"short": "given place",
|
||||
"title": {
|
||||
"zh": "放进另一张图",
|
||||
"en": "Put a character into a picture"
|
||||
},
|
||||
"about": {
|
||||
"zh": "把人物抠出来贴进你给的背景图,再轻轻重画让它融进去。",
|
||||
"en": "Pastes the cut-out character into a given picture, then lightly redraws so it fits."
|
||||
},
|
||||
"source": {
|
||||
"zh": "常见的“抠图合成再重绘融合”做法:inspyrenet-rembg 抠人物,ComfyUI 自带的节点缩放、贴图和局部重绘",
|
||||
"en": "The common cut-out, composite and blend-by-redrawing way: inspyrenet-rembg finds the character, ComfyUI's own nodes scale, paste and redraw"
|
||||
},
|
||||
"packs": {
|
||||
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
|
||||
},
|
||||
"takes": "picture",
|
||||
"scene": true,
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"place": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$scene"
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"class_type": "GetImageSize",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
]
|
||||
}
|
||||
},
|
||||
"fit": {
|
||||
"class_type": "ImageScale",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"place",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"width": [
|
||||
"size",
|
||||
0
|
||||
],
|
||||
"height": [
|
||||
"size",
|
||||
1
|
||||
],
|
||||
"crop": "center"
|
||||
}
|
||||
},
|
||||
"paste": {
|
||||
"class_type": "ImageCompositeMasked",
|
||||
"inputs": {
|
||||
"destination": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"source": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"resize_source": false,
|
||||
"mask": [
|
||||
"cut",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"class_type": "InvertMask",
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"cut",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {
|
||||
"mask": [
|
||||
"background",
|
||||
0
|
||||
],
|
||||
"expand": 6,
|
||||
"tapered_corners": true
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"paste",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"mask": [
|
||||
"edge",
|
||||
0
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"paste",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 0.5
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-28",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 40
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
{
|
||||
"job": "refine",
|
||||
"want": "background",
|
||||
"rank": 9,
|
||||
"short": "background only",
|
||||
"when": "another model redraws only the background, the character kept to the pixel.",
|
||||
"title": {
|
||||
"zh": "换模型精修·只改场景",
|
||||
"en": "Refine only the background"
|
||||
},
|
||||
"about": {
|
||||
"zh": "自动抠出人物,换另一个模型只重画背景,人物和它边上一圈原样不动;Anima 上有 Tile 时照着原图的线条画,可以改得更多。另存一张。",
|
||||
"en": "Cuts the character out; another model redraws only the background, the character and a band around it left as they were; on Anima with the Tile it follows the picture's lines and may change more. Saved beside it."
|
||||
},
|
||||
"source": {
|
||||
"zh": "常见的“抠图换背景”做法,只是背景不换新、只轻轻重画,强度按 2026-11-10 的场景重画对照定",
|
||||
"en": "The common cut-out-and-replace-the-background way, the background only lightly redrawn, not replaced; its strength from a background redraw comparison of 2026-11-10"
|
||||
},
|
||||
"packs": {
|
||||
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
|
||||
},
|
||||
"extras": {
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"second_positive": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"like": "$sampler.positive",
|
||||
"inputs": {
|
||||
"clip": ["second_model", 1]
|
||||
}
|
||||
},
|
||||
"second_negative": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"like": "$sampler.negative",
|
||||
"inputs": {
|
||||
"clip": ["second_model", 1]
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": ["$save.images", "*"],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"solid": {
|
||||
"class_type": "ThresholdMask",
|
||||
"inputs": {
|
||||
"mask": ["cut", 1],
|
||||
"value": 0.5
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {
|
||||
"mask": ["solid", 0],
|
||||
"expand": 12,
|
||||
"tapered_corners": true
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"class_type": "InvertMask",
|
||||
"inputs": {
|
||||
"mask": ["edge", 0]
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": ["$save.images", "*"],
|
||||
"vae": ["second_model", 2]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": ["encode", 0],
|
||||
"mask": ["background", 0]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": ["second_model", 0],
|
||||
"positive": ["second_positive", 0],
|
||||
"negative": ["second_negative", 0],
|
||||
"latent_image": ["area", 0],
|
||||
"denoise": 0.45
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["redraw", 0],
|
||||
"vae": ["second_model", 2]
|
||||
}
|
||||
},
|
||||
"paste": {
|
||||
"class_type": "ImageCompositeMasked",
|
||||
"inputs": {
|
||||
"destination": ["$save.images", "*"],
|
||||
"source": ["decode", 0],
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"resize_source": false,
|
||||
"mask": ["background", 0]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["paste", 0],
|
||||
"filename_prefix": "refined_background"
|
||||
}
|
||||
},
|
||||
"as_picture": {
|
||||
"class_type": "MaskToImage",
|
||||
"inputs": {
|
||||
"mask": ["background", 0]
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": ["as_picture", 0]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "The redraw's loader (second_model) starts as the same checkpoint: set_model it to the model the user wants for the background (ask_user, search_models), add_lora a style LoRA onto it if they want one, and write its prompts (second_positive, second_negative, copied from the first) in that model's style. Then run_workflow; the picture is saved beside it (prefix refined_background), the character the same to the pixel. Denoise 0.45: textures and light take the second model's look and the layout stays; at 0.6 the ground gets heavier and the edge round the character may show (dark chair legs most). The preview shows what is redrawn (white): part of the character in it, say so.",
|
||||
"gives": {
|
||||
"sampler": "redraw",
|
||||
"save": "save"
|
||||
},
|
||||
"own_model": {
|
||||
"nodes": ["second_model"],
|
||||
"copies_loras": false
|
||||
},
|
||||
"notes": "The background is the inverse of the character's mask grown 12 px, so the character and a 12 px band around it, where it meets a bench or the ground, stay as they were; the paste-back keeps them to the pixel. In the 2026-11-10 comparison (an Anima picture, Kirazuri redrawing the background) this way at 0.45 changed the scene about 3.6% on average and the character not at all; the bench still held the character up. The cut-out takes what stands out in front, often with what the character sits on or holds (a bench, an umbrella, a bag): kept with it, so where they touch stays as it was. On Anima an animaTileRepair LLLite (strength 0.7, the first 80% of the steps, as the Tile upscale) holds the picture's lines: in a test of 2026-11-10 at denoise 0.70 without it the background's windows vanished and the bag was redrawn as another, with it they stayed (lines kept 61% to 85% in the background, 77% to 92% on the character), so it redraws at 0.7; without the Tile file the node goes and 0.45 is the strength. How much 0.7 changes follows the sampler: dpmpp_2m_sde (the Anima picture way's) about 8% on the character in that test, euler on a canvas there about 5%, the lines kept either way.",
|
||||
"models": [],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-10",
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima (Harem 2.9B, then Kirazuri v4.0 on the background, the Tile, denoise 0.7)",
|
||||
"size": "1440×1024",
|
||||
"seconds": 250
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"optional_lllite": {
|
||||
"tile": "tile"
|
||||
},
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "UNETLoader",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"tile": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"model": ["second_model", 0],
|
||||
"lllite_name": "$tile",
|
||||
"image": ["$save.images", "*"],
|
||||
"strength": 0.7,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8,
|
||||
"preserve_wrapper": true
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": ["$save.images", "*"],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"solid": {
|
||||
"class_type": "ThresholdMask",
|
||||
"inputs": {
|
||||
"mask": ["cut", 1],
|
||||
"value": 0.5
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {
|
||||
"mask": ["solid", 0],
|
||||
"expand": 12,
|
||||
"tapered_corners": true
|
||||
}
|
||||
},
|
||||
"background": {
|
||||
"class_type": "InvertMask",
|
||||
"inputs": {
|
||||
"mask": ["edge", 0]
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": ["$save.images", "*"],
|
||||
"vae": ["$decode.vae", "*"]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": ["encode", 0],
|
||||
"mask": ["background", 0]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": ["tile|second_model", 0],
|
||||
"positive": ["$sampler.positive", "*"],
|
||||
"negative": ["$sampler.negative", "*"],
|
||||
"latent_image": ["area", 0],
|
||||
"denoise": 0.7
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["redraw", 0],
|
||||
"vae": ["$decode.vae", "*"]
|
||||
}
|
||||
},
|
||||
"paste": {
|
||||
"class_type": "ImageCompositeMasked",
|
||||
"inputs": {
|
||||
"destination": ["$save.images", "*"],
|
||||
"source": ["decode", 0],
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"resize_source": false,
|
||||
"mask": ["background", 0]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["paste", 0],
|
||||
"filename_prefix": "refined_background"
|
||||
}
|
||||
},
|
||||
"as_picture": {
|
||||
"class_type": "MaskToImage",
|
||||
"inputs": {
|
||||
"mask": ["background", 0]
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": ["as_picture", 0]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model the redraw's loader (second_model) is a UNET loader that starts as the canvas's model; it reuses the canvas's text encoder, VAE and prompts. set_model it to the other Anima model the user wants for the background (ask_user, search_models); a LoRA on the first model is not on it: add_lora it onto second_model when the user wants that look kept. Then run_workflow; the picture is saved beside it (prefix refined_background), the character the same to the pixel. The Tile (node tile) holds the picture's lines, so at denoise 0.7 walls, ground and sky take the second model's look and keep their places (windows stay windows, one track stays one); without the tile node (no animaTileRepair file here) set redraw's denoise to 0.45. At this strength colours may drift (grass, sky): say so. The preview shows what is redrawn (white): part of the character in it, say so."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
{
|
||||
"job": "refine",
|
||||
"want": "character",
|
||||
"rank": 8,
|
||||
"short": "character only",
|
||||
"when": "another model redraws only the character, the scene kept to the pixel.",
|
||||
"title": {
|
||||
"zh": "换模型精修·只改人物",
|
||||
"en": "Refine only the character"
|
||||
},
|
||||
"about": {
|
||||
"zh": "自动抠出人物,换另一个模型只重画人物,边缘慢慢减弱,场景原样不动;Anima 上有 Tile 时照着原图的线条画,可以改得更多。另存一张。",
|
||||
"en": "Cuts the character out; another model redraws only it, fading out at its edge, the scene left as it was; on Anima with the Tile it follows the picture's lines and may change more. Saved beside it."
|
||||
},
|
||||
"source": {
|
||||
"zh": "常见的抠图加渐变遮罩局部重画(ComfyUI 自带的 Differential Diffusion),强度按 2026-11-10 的人物重画对照定",
|
||||
"en": "The common cut-out with a graded-mask repaint (ComfyUI's own Differential Diffusion), its strength from a character redraw comparison of 2026-11-10"
|
||||
},
|
||||
"packs": {
|
||||
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
|
||||
},
|
||||
"extras": {
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"second_positive": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"like": "$sampler.positive",
|
||||
"inputs": {
|
||||
"clip": ["second_model", 1]
|
||||
}
|
||||
},
|
||||
"second_negative": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"like": "$sampler.negative",
|
||||
"inputs": {
|
||||
"clip": ["second_model", 1]
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": ["$save.images", "*"],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"solid": {
|
||||
"class_type": "ThresholdMask",
|
||||
"inputs": {
|
||||
"mask": ["cut", 1],
|
||||
"value": 0.5
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {
|
||||
"mask": ["solid", 0],
|
||||
"expand": 12,
|
||||
"tapered_corners": true
|
||||
}
|
||||
},
|
||||
"as_picture": {
|
||||
"class_type": "MaskToImage",
|
||||
"inputs": {
|
||||
"mask": ["edge", 0]
|
||||
}
|
||||
},
|
||||
"fade": {
|
||||
"class_type": "ImageBlur",
|
||||
"inputs": {
|
||||
"image": ["as_picture", 0],
|
||||
"blur_radius": 31,
|
||||
"sigma": 0.5
|
||||
}
|
||||
},
|
||||
"area_mask": {
|
||||
"class_type": "ImageToMask",
|
||||
"inputs": {
|
||||
"image": ["fade", 0],
|
||||
"channel": "red"
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": ["second_model", 0]
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": ["$save.images", "*"],
|
||||
"vae": ["second_model", 2]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": ["encode", 0],
|
||||
"mask": ["area_mask", 0]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": ["soft", 0],
|
||||
"positive": ["second_positive", 0],
|
||||
"negative": ["second_negative", 0],
|
||||
"latent_image": ["area", 0],
|
||||
"denoise": 0.45
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["redraw", 0],
|
||||
"vae": ["second_model", 2]
|
||||
}
|
||||
},
|
||||
"paste": {
|
||||
"class_type": "ImageCompositeMasked",
|
||||
"inputs": {
|
||||
"destination": ["$save.images", "*"],
|
||||
"source": ["decode", 0],
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"resize_source": false,
|
||||
"mask": ["area_mask", 0]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["paste", 0],
|
||||
"filename_prefix": "refined_character"
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": ["fade", 0]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "The redraw's loader (second_model) starts as the same checkpoint: set_model it to the model the user wants for the character (ask_user, search_models), add_lora a character or style LoRA onto it if they want one, and write its prompts (second_positive, second_negative, copied from the first) in that model's style. Then run_workflow; the picture is saved beside it (prefix refined_character), the scene the same to the pixel. Denoise 0.45: face, hair and clothes take the second model's look and the pose stays; 0.3 changes little; at 0.6 the face changes more and colours fade. The preview shows what is redrawn (white, fading at the edge): part of the character missing, or the background in it, say so.",
|
||||
"gives": {
|
||||
"sampler": "redraw",
|
||||
"save": "save"
|
||||
},
|
||||
"own_model": {
|
||||
"nodes": ["second_model"],
|
||||
"copies_loras": false
|
||||
},
|
||||
"notes": "The character's mask grows 12 px and is blurred about 15 px, so the redraw fades out at its edge; Differential Diffusion makes the mask's grey a weaker redraw, and the paste-back keeps everything outside it as it was. In the 2026-11-10 comparison (an Anima picture, RealSkin redrawing the character) this way at 0.45 changed the character about 5.5% on average and the scene 0.03%; the coat stayed on the bench. A small character in a wide picture gets few pixels for its face: repaint it (inpaint_crop_stitch) instead. The cut-out takes what stands out in front, often with what the character sits on or holds (a bench, an umbrella, a bag): redrawn with it, so where they touch still fits. On Anima an animaTileRepair LLLite (strength 0.7, the first 80% of the steps, as the Tile upscale) holds the picture's lines: in a test of 2026-11-10 at denoise 0.70 without it the background's windows vanished and the bag was redrawn as another, with it they stayed (lines kept 61% to 85% in the background, 77% to 92% on the character), so it redraws at 0.7; without the Tile file the node goes and 0.45 is the strength. How much 0.7 changes follows the sampler: dpmpp_2m_sde (the Anima picture way's) about 8% on the character in that test, euler on a canvas there about 5%, the lines kept either way.",
|
||||
"models": [],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-10",
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima (Harem 2.9B, then RealSkin 1.3 on the character, the Tile, denoise 0.7)",
|
||||
"size": "1440×1024",
|
||||
"seconds": 250
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"optional_lllite": {
|
||||
"tile": "tile"
|
||||
},
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "UNETLoader",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"tile": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"model": ["second_model", 0],
|
||||
"lllite_name": "$tile",
|
||||
"image": ["$save.images", "*"],
|
||||
"strength": 0.7,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8,
|
||||
"preserve_wrapper": true
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": ["$save.images", "*"],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"solid": {
|
||||
"class_type": "ThresholdMask",
|
||||
"inputs": {
|
||||
"mask": ["cut", 1],
|
||||
"value": 0.5
|
||||
}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {
|
||||
"mask": ["solid", 0],
|
||||
"expand": 12,
|
||||
"tapered_corners": true
|
||||
}
|
||||
},
|
||||
"as_picture": {
|
||||
"class_type": "MaskToImage",
|
||||
"inputs": {
|
||||
"mask": ["edge", 0]
|
||||
}
|
||||
},
|
||||
"fade": {
|
||||
"class_type": "ImageBlur",
|
||||
"inputs": {
|
||||
"image": ["as_picture", 0],
|
||||
"blur_radius": 31,
|
||||
"sigma": 0.5
|
||||
}
|
||||
},
|
||||
"area_mask": {
|
||||
"class_type": "ImageToMask",
|
||||
"inputs": {
|
||||
"image": ["fade", 0],
|
||||
"channel": "red"
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {
|
||||
"model": ["tile|second_model", 0]
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": ["$save.images", "*"],
|
||||
"vae": ["$decode.vae", "*"]
|
||||
}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {
|
||||
"samples": ["encode", 0],
|
||||
"mask": ["area_mask", 0]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": ["soft", 0],
|
||||
"positive": ["$sampler.positive", "*"],
|
||||
"negative": ["$sampler.negative", "*"],
|
||||
"latent_image": ["area", 0],
|
||||
"denoise": 0.7
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": ["redraw", 0],
|
||||
"vae": ["$decode.vae", "*"]
|
||||
}
|
||||
},
|
||||
"paste": {
|
||||
"class_type": "ImageCompositeMasked",
|
||||
"inputs": {
|
||||
"destination": ["$save.images", "*"],
|
||||
"source": ["decode", 0],
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"resize_source": false,
|
||||
"mask": ["area_mask", 0]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": ["paste", 0],
|
||||
"filename_prefix": "refined_character"
|
||||
}
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": ["fade", 0]
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model the redraw's loader (second_model) is a UNET loader that starts as the canvas's model; it reuses the canvas's text encoder, VAE and prompts. set_model it to the other Anima model the user wants for the character (ask_user, search_models); a LoRA on the first model is not on it: add_lora it onto second_model when the user wants that look kept. Then run_workflow; the picture is saved beside it (prefix refined_character), the scene the same to the pixel. The Tile (node tile) holds the picture's lines, so at denoise 0.7 the character takes the second model's look and nothing in it turns into something else (a bag stays that bag); without the tile node (no animaTileRepair file here) set redraw's denoise to 0.45. At this strength colours may drift (hair, a scarf): say so. The preview shows what is redrawn (white, fading at the edge): part of the character missing, or the background in it, say so."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
{
|
||||
"job": "refine",
|
||||
"rank": 6,
|
||||
"short": "other model",
|
||||
"when": "the picture done, another model lightly redraws all of it; with its own checkpoint it may cross families; several in a row are fine.",
|
||||
"title": {
|
||||
"zh": "换模型精修",
|
||||
"en": "Refine with another model"
|
||||
},
|
||||
"about": {
|
||||
"zh": "换另一个模型把整张图轻轻重画一遍,另存一张(单文件模型和 Anima 都行)。",
|
||||
"en": "Another model lightly redraws the whole picture, saved beside it (a single-file checkpoint or an Anima model)."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「Refiner 管线」:精修时可以换一个底模加载器,配一套独立的 LoRA 堆",
|
||||
"en": "The reference workflow's refiner: a base model loader of its own for refining, with a separate LoRA stack"
|
||||
},
|
||||
"packs": {},
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"second_positive": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"like": "$sampler.positive",
|
||||
"inputs": {
|
||||
"clip": [
|
||||
"second_model",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"second_negative": {
|
||||
"class_type": "CLIPTextEncode",
|
||||
"like": "$sampler.negative",
|
||||
"inputs": {
|
||||
"clip": [
|
||||
"second_model",
|
||||
1
|
||||
]
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"second_model",
|
||||
2
|
||||
]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"second_model",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"second_positive",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"second_negative",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 0.4
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"second_model",
|
||||
2
|
||||
]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "refined"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-02",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious) → NoobAI",
|
||||
"size": "832×1216",
|
||||
"seconds": 66
|
||||
},
|
||||
{
|
||||
"model": "Anima (Kirazuri → Realskin 1.3, denoise 0.4, with the base)",
|
||||
"size": "1216×832",
|
||||
"seconds": 148
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "UNETLoader",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"second_model",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 0.4
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "refined"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model the refine's loader (second_model) is a UNET loader that starts as the canvas's model; it reuses the canvas's text encoder, VAE and prompts. set_model it to the other Anima model the user wants (ask_user, search_models); a LoRA on the first model is not on it: add_lora it onto second_model when the user wants that look kept. 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
{
|
||||
"job": "process",
|
||||
"want": "cut",
|
||||
"rank": 1,
|
||||
"short": "cut-out",
|
||||
"title": {
|
||||
"zh": "背景移除",
|
||||
"en": "Remove the background"
|
||||
},
|
||||
"about": {
|
||||
"zh": "抠掉背景,存成透明背景的 PNG。",
|
||||
"en": "Cuts the background away into a transparent PNG."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「背景移除」组,换成你装的 inspyrenet-rembg",
|
||||
"en": "The reference workflow's background removal, with your inspyrenet-rembg"
|
||||
},
|
||||
"packs": {
|
||||
"InspyrenetRembg": "comfyui-inspyrenet-rembg"
|
||||
},
|
||||
"takes": "picture",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"torchscript_jit": "default"
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"cut",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "nobg"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "—",
|
||||
"size": "1280×832",
|
||||
"seconds": 8
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
{
|
||||
"job": "follow",
|
||||
"want": "style",
|
||||
"rank": 5,
|
||||
"short": "new style",
|
||||
"title": {
|
||||
"zh": "换画风·构图不变",
|
||||
"en": "New style, same layout"
|
||||
},
|
||||
"about": {
|
||||
"zh": "用轮廓线钉住构图,按新画风整张重画。",
|
||||
"en": "Outlines hold the layout while the whole picture is redrawn in a new style."
|
||||
},
|
||||
"source": {
|
||||
"zh": "常见的“控制网 + 以图生图”换风格做法,轮廓用 Union 控制网的 canny 模式",
|
||||
"en": "The common ControlNet-plus-img2img restyle, the outlines through the union ControlNet's canny mode"
|
||||
},
|
||||
"packs": {},
|
||||
"takes": "reference",
|
||||
"control": "lineart",
|
||||
"nodes": {
|
||||
"reference": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"edges": {
|
||||
"class_type": "Canny",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"low_threshold": 0.4,
|
||||
"high_threshold": 0.8
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"steer": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"edges",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.7,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"reference",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
},
|
||||
"wire_to": "$sampler"
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"edges",
|
||||
0
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"roles": {
|
||||
"sampler": {
|
||||
"denoise": 1.0
|
||||
}
|
||||
},
|
||||
"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"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-28",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216",
|
||||
"seconds": 41
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
{
|
||||
"job": "refine",
|
||||
"want": "midway",
|
||||
"rank": 7,
|
||||
"short": "switch mid-way",
|
||||
"when": "one picture: early steps on one model settle the composition, later steps on another of its family finish the look; may enlarge at the switch.",
|
||||
"title": {
|
||||
"zh": "半途换模型接着画",
|
||||
"en": "Switch models mid-way"
|
||||
},
|
||||
"about": {
|
||||
"zh": "前面的步数用原模型定下构图,后面的步数换另一个模型接着画完,画风和细节跟着它走。",
|
||||
"en": "The first steps keep the model's composition, then another model draws the rest, with its look and detail."
|
||||
},
|
||||
"source": {
|
||||
"zh": "复现 Aaalice 双模型路线时用的接力(两个 KSampler Advanced 分段),本机三组对比验证过",
|
||||
"en": "The relay used to reproduce the Aaalice two-model route (two KSampler Advanced parts), checked in three local comparisons"
|
||||
},
|
||||
"packs": {},
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"first": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"$sampler.latent_image",
|
||||
"*"
|
||||
],
|
||||
"add_noise": "enable",
|
||||
"start_at_step": 0,
|
||||
"end_at_step": 21,
|
||||
"return_with_leftover_noise": "enable"
|
||||
}
|
||||
},
|
||||
"second": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"replaces": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"second_model",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"first",
|
||||
0
|
||||
],
|
||||
"add_noise": "disable",
|
||||
"start_at_step": 21,
|
||||
"end_at_step": 10000,
|
||||
"return_with_leftover_noise": "disable"
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima (Kirazuri → Realskin 1.3 at step 21)",
|
||||
"size": "1216×832",
|
||||
"seconds": 79
|
||||
},
|
||||
{
|
||||
"model": "SDXL (Nova Anime XL → MiaoMiao Harem v2.0 at step 18 of 30)",
|
||||
"size": "832×1216",
|
||||
"seconds": 41
|
||||
},
|
||||
{
|
||||
"model": "Anima (MiaoMiao Harem 1.5 both parts, enlarged 1.5× at step 21)",
|
||||
"size": "832×1216 → 1248×1824",
|
||||
"seconds": 106
|
||||
}
|
||||
],
|
||||
"date": "2026-11-03"
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"nodes": {
|
||||
"second_model": {
|
||||
"class_type": "UNETLoader",
|
||||
"like": "$loader",
|
||||
"inputs": {}
|
||||
},
|
||||
"first": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"$sampler.latent_image",
|
||||
"*"
|
||||
],
|
||||
"add_noise": "enable",
|
||||
"start_at_step": 0,
|
||||
"end_at_step": 21,
|
||||
"return_with_leftover_noise": "enable",
|
||||
"steps": 35,
|
||||
"sampler_name": "dpmpp_2m_sde_gpu",
|
||||
"scheduler": "beta"
|
||||
}
|
||||
},
|
||||
"second": {
|
||||
"class_type": "KSamplerAdvanced",
|
||||
"replaces": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"second_model",
|
||||
0
|
||||
],
|
||||
"latent_image": [
|
||||
"first",
|
||||
0
|
||||
],
|
||||
"add_noise": "disable",
|
||||
"start_at_step": 21,
|
||||
"end_at_step": 10000,
|
||||
"return_with_leftover_noise": "disable",
|
||||
"steps": 35,
|
||||
"sampler_name": "dpmpp_2m_sde_gpu",
|
||||
"scheduler": "beta"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "The canvas's sampler became two, with the tested Anima sampling (35 steps, dpmpp_2m_sde_gpu, beta): the first runs steps 0 to 21 on the canvas's model, the second (second_model, a new UNET 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 Anima model the user wants (it reuses the text encoder and 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 (the tested route did). Move the switch with the first sampler's end_at_step and the second's start_at_step together (earlier: more of the second model, later: more of the first). Keep dpmpp_2m_sde_gpu: er_sde came out grainy split on two Anima models. 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."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
{
|
||||
"job": "process",
|
||||
"want": "tags",
|
||||
"rank": 3,
|
||||
"short": "its tags",
|
||||
"title": {
|
||||
"zh": "读图出标签",
|
||||
"en": "Tags from a picture"
|
||||
},
|
||||
"about": {
|
||||
"zh": "读出一张图的标签,交给 AI 当文字用。",
|
||||
"en": "Reads a picture's tags and hands them to the AI as words."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「图像反推」组",
|
||||
"en": "The reference workflow's interrogation group"
|
||||
},
|
||||
"packs": {
|
||||
"WD14Tagger|pysssss": "ComfyUI-WD14-Tagger"
|
||||
},
|
||||
"takes": "picture",
|
||||
"nodes": {
|
||||
"picture": {
|
||||
"class_type": "LoadImage",
|
||||
"inputs": {
|
||||
"image": "$image"
|
||||
}
|
||||
},
|
||||
"tags": {
|
||||
"class_type": "WD14Tagger|pysssss",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"picture",
|
||||
0
|
||||
],
|
||||
"model": "wd-eva02-large-tagger-v3",
|
||||
"threshold": 0.35,
|
||||
"character_threshold": 0.85,
|
||||
"replace_underscore": false,
|
||||
"trailing_comma": false,
|
||||
"exclude_tags": ""
|
||||
}
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "—",
|
||||
"size": "1280×832",
|
||||
"seconds": 166
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
{
|
||||
"job": "upscale",
|
||||
"rank": 1,
|
||||
"short": "1.25×",
|
||||
"when": "an upscale model, then a light redraw; nothing holds the picture, so it stays light.",
|
||||
"title": {
|
||||
"zh": "放大",
|
||||
"en": "Upscale"
|
||||
},
|
||||
"about": {
|
||||
"zh": "用放大模型放到 1.25 倍,再轻轻重画补细节。",
|
||||
"en": "An upscale model to 1.25×, then a light redraw for detail."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「通用放大」组",
|
||||
"en": "The reference workflow's general upscale group"
|
||||
},
|
||||
"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": {
|
||||
"upscaler": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {
|
||||
"model_name": "$upscaler"
|
||||
}
|
||||
},
|
||||
"enlarge": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {
|
||||
"upscale_model": [
|
||||
"upscaler",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"$save.images",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fit": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"enlarge",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"scale_by": "$fit"
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 0.35
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"class_type": "ColorMatch",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"image_target": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1.0,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"match|decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "upscaled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"extras": {
|
||||
"ColorMatch": "ComfyUI-KJNodes"
|
||||
},
|
||||
"models": [
|
||||
"2x_animesharp_v4",
|
||||
"4x_ultrasharp",
|
||||
"4x_animesharp"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216 → 1.25×",
|
||||
"seconds": 43
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
{
|
||||
"job": "upscale",
|
||||
"want": "character",
|
||||
"rank": 5,
|
||||
"short": "blurred background: only the character redrawn",
|
||||
"when": "a background blurred on purpose: all enlarged, only the character redrawn (a whole redraw draws the blur sharp); a sharp background: want='tile'.",
|
||||
"title": {
|
||||
"zh": "放大·只重画人物",
|
||||
"en": "Upscale, only the character redrawn"
|
||||
},
|
||||
"about": {
|
||||
"zh": "背景本来就虚化的图用:整张用放大模型拉大,背景不再动;自动抠出人物,只裁出那一块重画再缝回去。虚化原样保住,人物变清楚;Anima 上有 Tile 时照着原图的线条画。背景清楚的图请用「Tile 锁图放大」。",
|
||||
"en": "For a picture whose background is blurred on purpose: an upscale model enlarges it all and the background stays as it is; the character is cut out and only its crop is redrawn and stitched back. The blur is kept, the character gets sharper; on Anima with the Tile it follows the picture's lines. A sharp background: Upscale held by Tile."
|
||||
},
|
||||
"source": {
|
||||
"zh": "2026-11-10 的虚化背景放大对照:整张重画把远处的铁轨和树画清楚了,景深没了;整张拉大、只重画人物保住了虚化,只裁人物来重画最快",
|
||||
"en": "A blurred-background upscale comparison of 2026-11-10: a whole redraw drew the far tracks and trees sharp and the depth was lost; enlarging it all and redrawing only the character kept the blur, a crop of the character the fastest"
|
||||
},
|
||||
"packs": {
|
||||
"InspyrenetRembg": "comfyui-inspyrenet-rembg",
|
||||
"InpaintCropImproved": "ComfyUI-Inpaint-CropAndStitch",
|
||||
"InpaintStitchImproved": "ComfyUI-Inpaint-CropAndStitch"
|
||||
},
|
||||
"extras": {
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"upscaler": true,
|
||||
"upscale_by": 1.5,
|
||||
"nodes": {
|
||||
"own": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"copy_of": "$sampler.model",
|
||||
"_meta": {"title": "Upscale model"},
|
||||
"inputs": {}
|
||||
},
|
||||
"upscaler": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {"model_name": "$upscaler"}
|
||||
},
|
||||
"enlarge": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {"upscale_model": ["upscaler", 0], "image": ["$save.images", "*"]}
|
||||
},
|
||||
"fit": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {"image": ["enlarge", 0], "upscale_method": "lanczos", "scale_by": "$fit"}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {"image": ["fit", 0], "torchscript_jit": "default"}
|
||||
},
|
||||
"solid": {
|
||||
"class_type": "ThresholdMask",
|
||||
"inputs": {"mask": ["cut", 1], "value": 0.5}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {"mask": ["solid", 0], "expand": 12, "tapered_corners": true}
|
||||
},
|
||||
"as_picture": {
|
||||
"class_type": "MaskToImage",
|
||||
"inputs": {"mask": ["edge", 0]}
|
||||
},
|
||||
"fade": {
|
||||
"class_type": "ImageBlur",
|
||||
"inputs": {"image": ["as_picture", 0], "blur_radius": 31, "sigma": 0.5}
|
||||
},
|
||||
"area_mask": {
|
||||
"class_type": "ImageToMask",
|
||||
"inputs": {"image": ["fade", 0], "channel": "red"}
|
||||
},
|
||||
"crop": {
|
||||
"class_type": "InpaintCropImproved",
|
||||
"inputs": {
|
||||
"image": ["fit", 0],
|
||||
"mask": ["area_mask", 0],
|
||||
"downscale_algorithm": "lanczos",
|
||||
"upscale_algorithm": "lanczos",
|
||||
"preresize": false,
|
||||
"preresize_mode": "ensure minimum resolution",
|
||||
"preresize_min_width": 1024,
|
||||
"preresize_min_height": 1024,
|
||||
"preresize_max_width": 16384,
|
||||
"preresize_max_height": 16384,
|
||||
"mask_fill_holes": false,
|
||||
"mask_expand_pixels": 0,
|
||||
"mask_invert": false,
|
||||
"mask_blend_pixels": 0,
|
||||
"mask_hipass_filter": 0.0,
|
||||
"extend_for_outpainting": false,
|
||||
"extend_up_factor": 1.0,
|
||||
"extend_down_factor": 1.0,
|
||||
"extend_left_factor": 1.0,
|
||||
"extend_right_factor": 1.0,
|
||||
"context_from_mask_extend_factor": 1.2,
|
||||
"output_resize_to_target_size": false,
|
||||
"output_target_width": 1024,
|
||||
"output_target_height": 1024,
|
||||
"output_padding": "32",
|
||||
"device_mode": "cpu (compatible)"
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {"model": ["own", 0]}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {"pixels": ["crop", "cropped_image"], "vae": ["$decode.vae", "*"]}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {"samples": ["encode", 0], "mask": ["crop", "cropped_mask"]}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": ["soft", 0],
|
||||
"positive": ["$sampler.positive", "*"],
|
||||
"negative": ["$sampler.negative", "*"],
|
||||
"latent_image": ["area", 0],
|
||||
"denoise": 0.45
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {"samples": ["redraw", 0], "vae": ["$decode.vae", "*"]}
|
||||
},
|
||||
"stitch": {
|
||||
"class_type": "InpaintStitchImproved",
|
||||
"inputs": {"stitcher": ["crop", "stitcher"], "inpainted_image": ["decode", 0]}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": ["stitch", 0], "filename_prefix": "upscaled_character"}
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {"images": ["fade", 0]}
|
||||
}
|
||||
},
|
||||
"after": "run_workflow saves the picture as before and the 1.5x one beside it (prefix upscaled_character): the whole picture enlarged by an upscale model, the background left as it is, only the character (cut out, cropped with a margin) redrawn at denoise 0.45 with its own copy of the canvas's model and LoRAs (the loader titled Upscale model) and stitched back. Only for a background blurred on purpose: on a sharp one the character comes out sharper than the enlarged background; there use want=\"tile\". A second model for the character: set_model that loader to one of the same family. The preview shows what is redrawn (white): part of the character missing, or the background in it, say so.",
|
||||
"gives": {
|
||||
"sampler": "redraw",
|
||||
"save": "save"
|
||||
},
|
||||
"own_model": {
|
||||
"nodes": ["own_1", "own"],
|
||||
"copies_loras": true
|
||||
},
|
||||
"notes": "In the 2026-11-10 comparison (an Anima picture with a blurred background, 1440x1024 to 2160x1536, RealSkin) a whole Tile redraw at 0.85 drew the far tracks and trees sharp, hazed the picture and lost the depth; enlarging it all and redrawing only the character kept the background to the pixel and sharpened the face. The crop (the character's mask, 20% more around it, not resized) took 153 s where redrawing over the whole enlarged picture took 270 s. The mask is the refine_character way's: cut out, made solid, grown 12 px, blurred about 15 px for a fading edge with Differential Diffusion. On Anima an optional animaTileRepair LLLite holds the lines at denoise 0.7; without the Tile file the node goes and 0.45 is the strength.",
|
||||
"models": ["2x_animesharp_v4", "4x_ultrasharp", "anima_tile_repair"],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-10",
|
||||
"runs": [
|
||||
{
|
||||
"model": "Anima (Harem 1.5, its own copy redrawing the character, the Tile, denoise 0.7, 2x-AnimeSharpV4)",
|
||||
"size": "832×1216 → 1.5×",
|
||||
"seconds": 223
|
||||
}
|
||||
]
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"optional_lllite": {
|
||||
"tile": "tile"
|
||||
},
|
||||
"nodes": {
|
||||
"own": {
|
||||
"class_type": "UNETLoader",
|
||||
"copy_of": "$sampler.model",
|
||||
"_meta": {"title": "Upscale model"},
|
||||
"inputs": {}
|
||||
},
|
||||
"upscaler": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {"model_name": "$upscaler"}
|
||||
},
|
||||
"enlarge": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {"upscale_model": ["upscaler", 0], "image": ["$save.images", "*"]}
|
||||
},
|
||||
"fit": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {"image": ["enlarge", 0], "upscale_method": "lanczos", "scale_by": "$fit"}
|
||||
},
|
||||
"cut": {
|
||||
"class_type": "InspyrenetRembg",
|
||||
"inputs": {"image": ["fit", 0], "torchscript_jit": "default"}
|
||||
},
|
||||
"solid": {
|
||||
"class_type": "ThresholdMask",
|
||||
"inputs": {"mask": ["cut", 1], "value": 0.5}
|
||||
},
|
||||
"edge": {
|
||||
"class_type": "GrowMask",
|
||||
"inputs": {"mask": ["solid", 0], "expand": 12, "tapered_corners": true}
|
||||
},
|
||||
"as_picture": {
|
||||
"class_type": "MaskToImage",
|
||||
"inputs": {"mask": ["edge", 0]}
|
||||
},
|
||||
"fade": {
|
||||
"class_type": "ImageBlur",
|
||||
"inputs": {"image": ["as_picture", 0], "blur_radius": 31, "sigma": 0.5}
|
||||
},
|
||||
"area_mask": {
|
||||
"class_type": "ImageToMask",
|
||||
"inputs": {"image": ["fade", 0], "channel": "red"}
|
||||
},
|
||||
"crop": {
|
||||
"class_type": "InpaintCropImproved",
|
||||
"inputs": {
|
||||
"image": ["fit", 0],
|
||||
"mask": ["area_mask", 0],
|
||||
"downscale_algorithm": "lanczos",
|
||||
"upscale_algorithm": "lanczos",
|
||||
"preresize": false,
|
||||
"preresize_mode": "ensure minimum resolution",
|
||||
"preresize_min_width": 1024,
|
||||
"preresize_min_height": 1024,
|
||||
"preresize_max_width": 16384,
|
||||
"preresize_max_height": 16384,
|
||||
"mask_fill_holes": false,
|
||||
"mask_expand_pixels": 0,
|
||||
"mask_invert": false,
|
||||
"mask_blend_pixels": 0,
|
||||
"mask_hipass_filter": 0.0,
|
||||
"extend_for_outpainting": false,
|
||||
"extend_up_factor": 1.0,
|
||||
"extend_down_factor": 1.0,
|
||||
"extend_left_factor": 1.0,
|
||||
"extend_right_factor": 1.0,
|
||||
"context_from_mask_extend_factor": 1.2,
|
||||
"output_resize_to_target_size": false,
|
||||
"output_target_width": 1024,
|
||||
"output_target_height": 1024,
|
||||
"output_padding": "32",
|
||||
"device_mode": "cpu (compatible)"
|
||||
}
|
||||
},
|
||||
"tile": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"model": ["own", 0],
|
||||
"lllite_name": "$tile",
|
||||
"image": ["crop", "cropped_image"],
|
||||
"strength": 0.7,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8,
|
||||
"preserve_wrapper": true
|
||||
}
|
||||
},
|
||||
"soft": {
|
||||
"class_type": "DifferentialDiffusion",
|
||||
"inputs": {"model": ["tile|own", 0]}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncodeTiled",
|
||||
"inputs": {"pixels": ["crop", "cropped_image"], "vae": ["$decode.vae", "*"], "tile_size": 512, "overlap": 64, "temporal_size": 64, "temporal_overlap": 8}
|
||||
},
|
||||
"area": {
|
||||
"class_type": "SetLatentNoiseMask",
|
||||
"inputs": {"samples": ["encode", 0], "mask": ["crop", "cropped_mask"]}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": ["soft", 0],
|
||||
"positive": ["$sampler.positive", "*"],
|
||||
"negative": ["$sampler.negative", "*"],
|
||||
"latent_image": ["area", 0],
|
||||
"steps": 30,
|
||||
"cfg": 4.4,
|
||||
"sampler_name": "dpmpp_2m_sde_gpu",
|
||||
"scheduler": "sgm_uniform",
|
||||
"denoise": 0.7
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecodeTiled",
|
||||
"inputs": {"samples": ["redraw", 0], "vae": ["$decode.vae", "*"], "tile_size": 512, "overlap": 64, "temporal_size": 64, "temporal_overlap": 8}
|
||||
},
|
||||
"stitch": {
|
||||
"class_type": "InpaintStitchImproved",
|
||||
"inputs": {"stitcher": ["crop", "stitcher"], "inpainted_image": ["decode", 0]}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {"images": ["stitch", 0], "filename_prefix": "upscaled_character"}
|
||||
},
|
||||
"look": {
|
||||
"class_type": "PreviewImage",
|
||||
"inputs": {"images": ["fade", 0]}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model run_workflow saves the picture as before and the 1.5x one beside it (prefix upscaled_character): the whole picture enlarged by an upscale model, the background left as it is, only the character (cut out, cropped with a margin) redrawn with its own copy of the canvas's model and LoRAs (the loader titled Upscale model) and stitched back. The Tile (node tile) holds the character's lines, so it redraws at denoise 0.7 (30 steps, dpmpp_2m_sde_gpu, the tested route); without the tile node (no animaTileRepair file here) set redraw's denoise to 0.45. Only for a background blurred on purpose: on a sharp one the character comes out sharper than the enlarged background; there use want=\"tile\". A second model for the character: set_model that loader to another Anima model; the LoRAs stay. The preview shows what is redrawn (white): part of the character missing, or the background in it, say so."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
{
|
||||
"job": "upscale",
|
||||
"want": "tile",
|
||||
"rank": 2,
|
||||
"short": "held by Tile, own model",
|
||||
"when": "enlarge with a lock: Tile holds the picture under a strong redraw, for much more detail; its own model copy can be a second model of the same family.",
|
||||
"title": {
|
||||
"zh": "Tile 锁图放大",
|
||||
"en": "Upscale held by Tile"
|
||||
},
|
||||
"about": {
|
||||
"zh": "放大时用 Tile 钉住画面,重画得更狠、细节更多。放大这一段有自己的一份模型(默认和底图同一个,LoRA 一起带上),想换第二个模型只要换这一格;Anima 上用 Anima 的 Tile LLLite。",
|
||||
"en": "A Tile holds the picture while it is enlarged and redrawn, so the redraw can go stronger: more detail. The redraw has its own copy of the model (the base's, its LoRAs too); a second model is one swap of that loader. On Anima, Anima's Tile LLLite holds it."
|
||||
},
|
||||
"source": {
|
||||
"zh": "参考工作流的「通用放大」组(平铺控制网 0.6,到 80% 的步数)和它的放大加载器;Anima 是你验证过的两段放大卡(潜空间 1.22 倍 + Tile LLLite,降噪 0.85)",
|
||||
"en": "The reference workflow's general upscale (tile ControlNet 0.6, to 80% of the steps) and its upscale loader; on Anima the user's tested two-pass card (latent 1.22x, Tile LLLite, denoise 0.85)"
|
||||
},
|
||||
"packs": {},
|
||||
"upscaler": true,
|
||||
"upscale_by": 1.25,
|
||||
"control": "tile",
|
||||
"extras": {
|
||||
"ColorMatch": "ComfyUI-KJNodes"
|
||||
},
|
||||
"nodes": {
|
||||
"own": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"copy_of": "$sampler.model",
|
||||
"_meta": {
|
||||
"title": "Upscale model"
|
||||
},
|
||||
"inputs": {}
|
||||
},
|
||||
"upscaler": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {
|
||||
"model_name": "$upscaler"
|
||||
}
|
||||
},
|
||||
"enlarge": {
|
||||
"class_type": "ImageUpscaleWithModel",
|
||||
"inputs": {
|
||||
"upscale_model": [
|
||||
"upscaler",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"$save.images",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"fit": {
|
||||
"class_type": "ImageScaleBy",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"enlarge",
|
||||
0
|
||||
],
|
||||
"upscale_method": "lanczos",
|
||||
"scale_by": "$fit"
|
||||
}
|
||||
},
|
||||
"controlnet": {
|
||||
"class_type": "ControlNetLoader",
|
||||
"inputs": {
|
||||
"control_net_name": "$controlnet"
|
||||
}
|
||||
},
|
||||
"hold": {
|
||||
"class_type": "ControlNetApplyAdvanced",
|
||||
"inputs": {
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"control_net": [
|
||||
"controlnet",
|
||||
0
|
||||
],
|
||||
"image": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.6,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8
|
||||
}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncode",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"own",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"hold",
|
||||
0
|
||||
],
|
||||
"negative": [
|
||||
"hold",
|
||||
1
|
||||
],
|
||||
"latent_image": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"denoise": 0.5
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecode",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
]
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"class_type": "ColorMatch",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"fit",
|
||||
0
|
||||
],
|
||||
"image_target": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1.0,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"match|decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "upscaled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"variants": {
|
||||
"anima": {
|
||||
"packs": {
|
||||
"AnimaLLLiteApply_sdscripts": "ComfyUI-Anima-LLLite"
|
||||
},
|
||||
"upscaler": false,
|
||||
"control": null,
|
||||
"lllite": "tile",
|
||||
"nodes": {
|
||||
"own": {
|
||||
"class_type": "UNETLoader",
|
||||
"copy_of": "$sampler.model",
|
||||
"_meta": {
|
||||
"title": "Upscale model"
|
||||
},
|
||||
"inputs": {}
|
||||
},
|
||||
"encode": {
|
||||
"class_type": "VAEEncodeTiled",
|
||||
"inputs": {
|
||||
"pixels": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"tile_size": 512,
|
||||
"overlap": 64,
|
||||
"temporal_size": 64,
|
||||
"temporal_overlap": 8
|
||||
}
|
||||
},
|
||||
"enlarge": {
|
||||
"class_type": "LatentUpscaleBy",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"encode",
|
||||
0
|
||||
],
|
||||
"upscale_method": "bislerp",
|
||||
"scale_by": 1.22
|
||||
}
|
||||
},
|
||||
"tile": {
|
||||
"class_type": "AnimaLLLiteApply_sdscripts",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"own",
|
||||
0
|
||||
],
|
||||
"lllite_name": "$lllite",
|
||||
"image": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"strength": 0.7,
|
||||
"start_percent": 0.0,
|
||||
"end_percent": 0.8,
|
||||
"preserve_wrapper": true
|
||||
}
|
||||
},
|
||||
"redraw": {
|
||||
"class_type": "KSampler",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"model": [
|
||||
"tile",
|
||||
0
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"latent_image": [
|
||||
"enlarge",
|
||||
0
|
||||
],
|
||||
"steps": 30,
|
||||
"cfg": 4.4,
|
||||
"sampler_name": "dpmpp_2m_sde_gpu",
|
||||
"scheduler": "sgm_uniform",
|
||||
"denoise": 0.85
|
||||
}
|
||||
},
|
||||
"decode": {
|
||||
"class_type": "VAEDecodeTiled",
|
||||
"inputs": {
|
||||
"samples": [
|
||||
"redraw",
|
||||
0
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"tile_size": 512,
|
||||
"overlap": 64,
|
||||
"temporal_size": 64,
|
||||
"temporal_overlap": 8
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"class_type": "ColorMatch",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"image_target": [
|
||||
"decode",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1.0,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"match|decode",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "upscaled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"after": "On this Anima model the picture is enlarged 1.22x in latent space and redrawn at denoise 0.85, held by Anima's Tile LLLite (0.7, to 80% of the steps), with its own copy of the canvas's model and LoRAs (the loader titled Upscale model). For a second model (steadier, other skin or texture), set_model that loader to another Anima model; the LoRAs stay. Then run_workflow; the larger one is saved beside it (prefix upscaled). Compare face, hands and clothes with the base: Tile guides, it does not guarantee."
|
||||
}
|
||||
},
|
||||
"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",
|
||||
"noob_tile",
|
||||
"anima_tile_repair",
|
||||
"2x_animesharp_v4",
|
||||
"4x_ultrasharp"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-11-02",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216 → 1.25×",
|
||||
"seconds": 77
|
||||
},
|
||||
{
|
||||
"model": "Anima (Kirazuri → Realskin 1.3, Tile)",
|
||||
"size": "1216×832 → 1.22×",
|
||||
"seconds": 127
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
{
|
||||
"job": "upscale",
|
||||
"want": "2x",
|
||||
"rank": 4,
|
||||
"short": "2×, in tiles",
|
||||
"when": "2x redrawn tile by tile, for sizes that do not fit memory in one piece.",
|
||||
"title": {
|
||||
"zh": "分块放大",
|
||||
"en": "Tiled upscale"
|
||||
},
|
||||
"about": {
|
||||
"zh": "放大 2 倍,按小块逐块重画,多大的图都放得下。",
|
||||
"en": "2×, redrawn tile by tile, for any size."
|
||||
},
|
||||
"source": {
|
||||
"zh": "节点包 UltimateSDUpscale 自带的示例",
|
||||
"en": "UltimateSDUpscale's own example"
|
||||
},
|
||||
"packs": {
|
||||
"UltimateSDUpscale": "ComfyUI_UltimateSDUpscale"
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"notes": "ColorMatch mkl after the redraw is the source's, optional (KJNodes).",
|
||||
"upscale_by": 2.0,
|
||||
"nodes": {
|
||||
"upscaler": {
|
||||
"class_type": "UpscaleModelLoader",
|
||||
"inputs": {
|
||||
"model_name": "$upscaler"
|
||||
}
|
||||
},
|
||||
"tiles": {
|
||||
"class_type": "UltimateSDUpscale",
|
||||
"like": "$sampler",
|
||||
"inputs": {
|
||||
"image": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"model": [
|
||||
"$sampler.model",
|
||||
"*"
|
||||
],
|
||||
"positive": [
|
||||
"$sampler.positive",
|
||||
"*"
|
||||
],
|
||||
"negative": [
|
||||
"$sampler.negative",
|
||||
"*"
|
||||
],
|
||||
"vae": [
|
||||
"$decode.vae",
|
||||
"*"
|
||||
],
|
||||
"upscale_model": [
|
||||
"upscaler",
|
||||
0
|
||||
],
|
||||
"upscale_by": 2.0,
|
||||
"denoise": 0.3,
|
||||
"tile_width": 1024,
|
||||
"tile_height": 1024
|
||||
}
|
||||
},
|
||||
"match": {
|
||||
"class_type": "ColorMatch",
|
||||
"optional": true,
|
||||
"inputs": {
|
||||
"image_ref": [
|
||||
"$save.images",
|
||||
"*"
|
||||
],
|
||||
"image_target": [
|
||||
"tiles",
|
||||
0
|
||||
],
|
||||
"method": "mkl",
|
||||
"strength": 1.0,
|
||||
"multithread": true
|
||||
}
|
||||
},
|
||||
"save": {
|
||||
"class_type": "SaveImage",
|
||||
"inputs": {
|
||||
"images": [
|
||||
"match|tiles",
|
||||
0
|
||||
],
|
||||
"filename_prefix": "upscaled"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models": [
|
||||
"2x_animesharp_v4",
|
||||
"4x_ultrasharp"
|
||||
],
|
||||
"tested": {
|
||||
"gpu": "RTX 4050 Laptop",
|
||||
"vram_gb": 6,
|
||||
"date": "2026-10-27",
|
||||
"runs": [
|
||||
{
|
||||
"model": "SDXL (Illustrious)",
|
||||
"size": "832×1216 → 2×",
|
||||
"seconds": 167
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"""The model details' "Translate" (web/modules/ui_detail.js): its author's text from Civitai in the window's
|
||||
language, paragraph by paragraph through the providers prompts use (translation_routes.translate_chain: DeepL
|
||||
with a saved key, Google, MyMemory), a few at a time. A long paragraph goes in pieces cut at sentences; a piece
|
||||
none of them takes is cut again into ones MyMemory takes. What came back is kept in user/anomalous/translations,
|
||||
a file per text and language named by their hash, so the same text opens translated at once and offline, and
|
||||
nothing is written beside the models. POST /anomalous/translate_description {paragraphs: [text], target}
|
||||
-> {paragraphs, engine, kept} or {error}."""
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import time
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import ai_tasks, translation_routes
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
MAX_PARAGRAPHS = 400
|
||||
MAX_CHARS = 30000
|
||||
PIECE = 1500 # characters a free provider takes in one go
|
||||
SMALL = 450 # what MyMemory takes
|
||||
WORKERS = 4
|
||||
TARGETS = ("zh-CN", "en")
|
||||
SENTENCE = re.compile(r"(?<=[.!?。!?])\s+")
|
||||
|
||||
|
||||
def _folder():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "translations")
|
||||
|
||||
|
||||
def _key(paragraphs, target):
|
||||
return hashlib.sha256(("\n".join([target, *paragraphs])).encode("utf-8")).hexdigest()[:32]
|
||||
|
||||
|
||||
def _kept(key):
|
||||
try:
|
||||
with open(os.path.join(_folder(), f"{key}.json"), encoding="utf-8") as source:
|
||||
data = json.load(source)
|
||||
return data if isinstance(data, dict) and isinstance(data.get("paragraphs"), list) else None
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def pieces(text, size):
|
||||
"""`text` cut at sentence ends (or, past that, anywhere) into pieces of at most `size` characters."""
|
||||
out, now = [], ""
|
||||
for sentence in SENTENCE.split(text):
|
||||
while len(sentence) > size:
|
||||
out += [now] if now else []
|
||||
out.append(sentence[:size])
|
||||
sentence, now = sentence[size:], ""
|
||||
if now and len(now) + 1 + len(sentence) > size:
|
||||
out.append(now)
|
||||
now = sentence
|
||||
else:
|
||||
now = f"{now} {sentence}".strip()
|
||||
return out + ([now] if now else [])
|
||||
|
||||
|
||||
def _join(parts, target):
|
||||
return ("" if target == "zh-CN" else " ").join(part.strip() for part in parts)
|
||||
|
||||
|
||||
def _piece(text, target, engines):
|
||||
"""One piece in `target`, cut smaller for MyMemory when the providers that take long ones did not answer."""
|
||||
try:
|
||||
translated, engine, _why = translation_routes.translate_chain(text, target)
|
||||
except Exception: # noqa: BLE001 - a piece too long for MyMemory: in small ones
|
||||
if len(text) <= SMALL:
|
||||
raise
|
||||
return _join([_piece(small, target, engines) for small in pieces(text, SMALL)], target)
|
||||
engines.add(engine)
|
||||
return translated
|
||||
|
||||
|
||||
def _paragraph(text, target, engines):
|
||||
return _join([_piece(piece, target, engines) for piece in pieces(text, PIECE)], target)
|
||||
|
||||
|
||||
def translate(paragraphs, target):
|
||||
"""The paragraphs in `target`, kept for next time; raises when a provider answered none."""
|
||||
if (not isinstance(paragraphs, list) or not 0 < len(paragraphs) <= MAX_PARAGRAPHS
|
||||
or any(not isinstance(item, str) for item in paragraphs)):
|
||||
raise ValueError(f"paragraphs is a list of 1 to {MAX_PARAGRAPHS} texts")
|
||||
if target not in TARGETS:
|
||||
raise ValueError(f"target is one of {', '.join(TARGETS)}")
|
||||
paragraphs = [item.strip() for item in paragraphs]
|
||||
if sum(len(item) for item in paragraphs) > MAX_CHARS:
|
||||
raise ValueError(f"The text is over {MAX_CHARS} characters")
|
||||
key = _key(paragraphs, target)
|
||||
kept = _kept(key)
|
||||
if kept:
|
||||
return {**kept, "kept": True}
|
||||
engines = set()
|
||||
with ThreadPoolExecutor(max_workers=WORKERS) as pool:
|
||||
done = list(pool.map(lambda item: _paragraph(item, target, engines) if item else "", paragraphs))
|
||||
answer = {"paragraphs": done, "engine": ", ".join(sorted(engines)), "target": target, "at": time.time()}
|
||||
os.makedirs(_folder(), exist_ok=True)
|
||||
atomic_write_json(os.path.join(_folder(), f"{key}.json"), answer)
|
||||
return {**answer, "kept": False}
|
||||
|
||||
|
||||
async def api_translate_description(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
body = None
|
||||
if not isinstance(body, dict):
|
||||
return web.json_response({"error": "Expected {paragraphs, target}"}, status=400)
|
||||
try:
|
||||
return web.json_response(await asyncio.to_thread(translate, body.get("paragraphs"), body.get("target")))
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
except Exception as error: # noqa: BLE001 - every provider failed: the page says why, the original stays
|
||||
return web.json_response({"error": str(error)[:300] or type(error).__name__}, status=502)
|
||||
@@ -166,6 +166,28 @@ def civitai_by_link(url, name, file_hash=""):
|
||||
return _from_civitai_version(version, file_hash, name) if version else None
|
||||
|
||||
|
||||
MAX_VERSIONS = 60 # a model page's versions listed, newest first
|
||||
|
||||
|
||||
def civitai_versions(model_id):
|
||||
"""Every version on Civitai model page `model_id`, newest first, with its primary file to download:
|
||||
{name, versions: [{id, name, base_model, published, file_name, size, sha256, page}]}; None on 404.
|
||||
Raises SourceUnreachable when Civitai could not be asked."""
|
||||
data = _civitai_json(f"/api/v1/models/{int(model_id)}")
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
versions = []
|
||||
for version in (data.get("modelVersions") or [])[:MAX_VERSIONS]:
|
||||
if not isinstance(version, dict) or not str(version.get("id", "")).isdigit():
|
||||
continue
|
||||
found = _from_civitai_version({**version, "modelId": data.get("id"), "model": {"nsfw": data.get("nsfw")}}) or {}
|
||||
versions.append({"id": int(version["id"]), "name": version.get("name") or "", "base_model": version.get("baseModel") or "",
|
||||
"published": str(version.get("publishedAt") or version.get("createdAt") or "")[:10],
|
||||
**{key: found.get(key, "") for key in ("file_name", "size", "sha256", "page")}})
|
||||
versions.sort(key=lambda item: item["id"], reverse=True)
|
||||
return {"name": data.get("name") or "", "versions": versions}
|
||||
|
||||
|
||||
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
"""The example workflows Anomalous ships (api/examples/<id>.json), because a new install has no cards of
|
||||
its own: plans (title), each in a version per model family (version) with a note (about). Most use only
|
||||
ComfyUI's own nodes; a version may need a pack (packs) or a model, and says so. The Workflows page shows
|
||||
them as one card (web/modules/ui_recipe_examples.js); list_workflows gives those that can run here to an
|
||||
AI when no saved workflow fits, which opens and runs them like saved ones (file name "example:<id>").
|
||||
A file names no model and no picture: read() fills in ones that are here, for a version with a Tile a model
|
||||
the Tile fits (lllite_fit.py). What holds for a whole model family (Anima: several pictures are several
|
||||
runs) is written once: for an AI in model_profiles.json, added to each of the family's notes; for the
|
||||
user under the plan's heading (the page's words for `family`). Read only."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from . import lllite_fit, model_guide, workflow_route
|
||||
|
||||
FOLDER = os.path.join(os.path.dirname(__file__), "examples")
|
||||
PREFIX = "example:"
|
||||
ORDER = ("text_to_picture", "two_pass", "two_pass_anima", "two_pass_anima_tile", "two_pass_anima_character", "picture_to_picture", "repaint")
|
||||
LOADERS = ("CheckpointLoaderSimple", "UNETLoader", "CLIPLoader", "VAELoader", "AnimaLLLiteApply_sdscripts")
|
||||
FAMILIES = {"sd": ("SDXL", "SD 1.x"), "anima": ("anima",)} # what the examples' settings suit
|
||||
FOR_MODELS = {"sd": "SDXL, SD 1.5", "anima": "Anima"}
|
||||
PROFILES = {"anima": "Anima"} # the model_profiles.json family whose notes hold for every example of a family
|
||||
LOOK_AT = 40 # files looked at for a model of a family
|
||||
MAX_WITH_SETTINGS = 3 # saved workflows listed in full for an AI: an AI app files a long answer away, unread
|
||||
SHORT_NOTES = 120 # characters of a card's notes in a longer list
|
||||
PICTURES = (".png", ".jpg", ".jpeg", ".webp")
|
||||
|
||||
|
||||
def _load():
|
||||
examples = {}
|
||||
for key in ORDER:
|
||||
with open(os.path.join(FOLDER, f"{key}.json"), encoding="utf-8") as file:
|
||||
examples[key] = json.load(file)
|
||||
return examples
|
||||
|
||||
|
||||
EXAMPLES = _load()
|
||||
|
||||
|
||||
def _here():
|
||||
"""The loaders' drop-downs, in the form reference_blocks reads /object_info, and the nodes of the packs the
|
||||
examples need (whether they are here); a node type not here is left out."""
|
||||
import nodes
|
||||
|
||||
kinds = set(LOADERS) | {kind for example in EXAMPLES.values() for kind in example.get("packs") or {}}
|
||||
return {kind: {"input": nodes.NODE_CLASS_MAPPINGS[kind].INPUT_TYPES()} for kind in kinds if kind in nodes.NODE_CLASS_MAPPINGS}
|
||||
|
||||
|
||||
def _model(info, kind, field, family, fits=lambda name: True):
|
||||
"""The first file loader `kind` offers of `family` (those named for it looked at first) that `fits`, or ""."""
|
||||
from . import model_advice, reference_blocks
|
||||
|
||||
names = sorted(reference_blocks._choices(info, kind, field), key=lambda name: family not in name.lower())
|
||||
return next((name for name in names[:LOOK_AT] if model_advice.family(model_advice._main_base(name)) in FAMILIES[family] and fits(name)), "")
|
||||
|
||||
|
||||
def _picture():
|
||||
"""A picture in ComfyUI's input folder, its own example if that is there, else ""."""
|
||||
try:
|
||||
names = sorted(name for name in os.listdir(folder_paths.get_input_directory()) if name.lower().endswith(PICTURES))
|
||||
except OSError:
|
||||
return ""
|
||||
return "example.png" if "example.png" in names else names[0] if names else ""
|
||||
|
||||
|
||||
def _fills(info):
|
||||
"""{node type: the file it takes here} for the examples' loaders, "" for what is not here."""
|
||||
from . import reference_blocks
|
||||
|
||||
checkpoints = reference_blocks._choices(info, "CheckpointLoaderSimple", "ckpt_name")
|
||||
fills = {"CheckpointLoaderSimple": _model(info, "CheckpointLoaderSimple", "ckpt_name", "sd") or next(iter(checkpoints), ""),
|
||||
"UNETLoader": _model(info, "UNETLoader", "unet_name", "anima"), "LoadImage": _picture()}
|
||||
for kind, field, marks in reference_blocks.BLOCKS["picture"]["variants"]["anima"]["files"].values():
|
||||
fills[kind] = reference_blocks._file_like(info, kind, field, marks) or ""
|
||||
try:
|
||||
fills["AnimaLLLiteApply_sdscripts"] = reference_blocks._lllite(info, "tile")
|
||||
except reference_blocks.MissingModel:
|
||||
fills["AnimaLLLiteApply_sdscripts"] = ""
|
||||
return fills
|
||||
|
||||
|
||||
def _lacks(example, info, fills):
|
||||
"""What `example` needs and this ComfyUI lacks: [{pack}] or [{model: the loader's type}]."""
|
||||
packs = [{"pack": pack} for pack in dict.fromkeys(pack for kind, pack in (example.get("packs") or {}).items() if kind not in info)]
|
||||
kinds = {node["type"] for node in example["workflow"]["nodes"]}
|
||||
return packs + [{"model": kind} for kind in LOADERS if kind in kinds and kind in info and not fills.get(kind)]
|
||||
|
||||
|
||||
def read(example_id):
|
||||
"""Example `example_id` on files that are here (those missing stay blank), or None when there is no such example."""
|
||||
example = EXAMPLES.get(example_id)
|
||||
if not example:
|
||||
return None
|
||||
info = _here()
|
||||
fills = dict(_fills(info))
|
||||
tile = fills.get("AnimaLLLiteApply_sdscripts")
|
||||
if tile and any(node["type"] == "AnimaLLLiteApply_sdscripts" for node in example["workflow"]["nodes"]):
|
||||
# a Tile made for the 2B models stops the run on a larger one: open on one it fits, when there is one
|
||||
fills["UNETLoader"] = _model(info, "UNETLoader", "unet_name", "anima", lambda name: not lllite_fit.problem(name, tile)) or fills["UNETLoader"]
|
||||
workflow = json.loads(json.dumps(example["workflow"]))
|
||||
for node in workflow["nodes"]:
|
||||
if node["type"] in fills:
|
||||
node["widgets_values"][0] = fills[node["type"]]
|
||||
return {"name": example["name"], "workflow": workflow}
|
||||
|
||||
|
||||
def listing():
|
||||
"""What the page's window shows of each example, and what it lacks here."""
|
||||
info = _here()
|
||||
fills = _fills(info)
|
||||
return [{"id": key, "title": item["title"], "version": item["version"], "about": item["about"], "recommended": bool(item.get("recommended")),
|
||||
"family": item["family"], "lacks": _lacks(item, info, fills)} for key, item in EXAMPLES.items()]
|
||||
|
||||
|
||||
def _notes(item):
|
||||
"""An example's note for an AI, with what model_profiles.json says of its family."""
|
||||
family = model_guide.profile(PROFILES.get(item["family"])) or {}
|
||||
return " ".join([item["ai"], *family.get("notes", [])])
|
||||
|
||||
|
||||
def for_ai():
|
||||
"""The examples that can run here, as list_workflows gives saved workflows."""
|
||||
info = _here()
|
||||
fills = _fills(info)
|
||||
return [{"name": item["name"], "title": item["title"]["zh"], "example": True, "for_models": FOR_MODELS[item["family"]],
|
||||
"route": workflow_route.line(item["workflow"]), "notes": _notes(item)}
|
||||
for item in EXAMPLES.values() if not _lacks(item, info, fills)]
|
||||
|
||||
|
||||
def _short(card):
|
||||
"""A saved workflow in a longer list: what tells it apart."""
|
||||
shown = {key: value for key, value in card.items() if value and key not in ("settings", "saved")}
|
||||
return {**shown, "notes": shown["notes"][:SHORT_NOTES]} if "notes" in shown else shown
|
||||
|
||||
|
||||
def answer(found, fits, limit):
|
||||
"""What list_workflows gives: the saved workflows `found`, cut short when they are many, or else the examples
|
||||
that `fits`. The other examples go by name, so an AI with no card for the job knows of them."""
|
||||
examples = for_ai()
|
||||
fitting = [] if found else [item for item in examples if fits(item)]
|
||||
if len(found) > MAX_WITH_SETTINGS:
|
||||
found = [_short(card) for card in found]
|
||||
listed = found or fitting
|
||||
others = [item["name"] for item in examples if item not in fitting]
|
||||
return {"total_matches": len(listed), "workflows": listed[:limit], **({"examples": others} if others else {})}
|
||||
|
||||
|
||||
def file_of(name):
|
||||
"""The file name open_workflow and run_workflow take for the example called `name`, or None."""
|
||||
return next((PREFIX + key for key, item in EXAMPLES.items() if item["name"] == name), None)
|
||||
|
||||
|
||||
async def api_examples(request):
|
||||
"""GET /anomalous/example_workflows: the list; ?id=<id>: one, in `data` as /anomalous/recipe_full gives a card."""
|
||||
if "id" not in request.query:
|
||||
return web.json_response({"examples": await asyncio.to_thread(listing)})
|
||||
data = await asyncio.to_thread(read, request.query["id"])
|
||||
return web.json_response({"data": data}) if data else web.json_response({"error": "not_found"}, status=404)
|
||||
@@ -0,0 +1,578 @@
|
||||
{
|
||||
"name": "Example: picture to picture",
|
||||
"title": {
|
||||
"zh": "图生图",
|
||||
"en": "Picture to picture"
|
||||
},
|
||||
"about": {
|
||||
"zh": "从一张图出发,按提示词改。在 Load Image 节点选图;denoise 越高,改得越多。",
|
||||
"en": "Starts from a picture and changes it by the prompt. Pick it in the Load Image node; a higher denoise changes more."
|
||||
},
|
||||
"ai": "Redraws a picture (img2img) at denoise 0.6. open_workflow, set_model on its loader, then run_workflow with the prompt and image.",
|
||||
"version": {
|
||||
"zh": "SDXL / SD 1.5",
|
||||
"en": "SDXL / SD 1.5"
|
||||
},
|
||||
"family": "sd",
|
||||
"workflow": {
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 8,
|
||||
"last_link_id": 11,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
273.4576171875,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Checkpoint名称",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
4,
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
60
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"low quality, blurry, watermark, text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
40,
|
||||
420
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
102
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "image",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "选择文件上传",
|
||||
"name": "upload",
|
||||
"type": "IMAGEUPLOAD",
|
||||
"widget": {
|
||||
"name": "upload"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "遮罩",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "VAEEncode",
|
||||
"pos": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "像素",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
860,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
0.6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1240,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 10
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1500,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图片",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "文件名前缀",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"example"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
5,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
1,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
6,
|
||||
2,
|
||||
0,
|
||||
6,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
7,
|
||||
3,
|
||||
0,
|
||||
6,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
8,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
9,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
10,
|
||||
1,
|
||||
2,
|
||||
7,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
11,
|
||||
7,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"version": 0.4,
|
||||
"extra": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,638 @@
|
||||
{
|
||||
"name": "Example: repaint an area",
|
||||
"title": {
|
||||
"zh": "局部重绘",
|
||||
"en": "Repaint an area"
|
||||
},
|
||||
"about": {
|
||||
"zh": "只重画图上的一块。在 Load Image 节点选图,右键打开遮罩编辑器,涂出要改的地方。",
|
||||
"en": "Redraws one part of a picture. Pick it in the Load Image node, right-click to open the mask editor and paint the part to change."
|
||||
},
|
||||
"ai": "Redraws the area masked on the Load Image node (inpaint). The user paints that mask; to repaint for them yourself, add_nodes(use='repaint') is better.",
|
||||
"version": {
|
||||
"zh": "SDXL / SD 1.5",
|
||||
"en": "SDXL / SD 1.5"
|
||||
},
|
||||
"family": "sd",
|
||||
"workflow": {
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 13,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
273.4576171875,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Checkpoint名称",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
4,
|
||||
12
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
60
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"low quality, blurry, watermark, text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "LoadImage",
|
||||
"pos": [
|
||||
40,
|
||||
420
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
102
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "image",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "image"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "选择文件上传",
|
||||
"name": "upload",
|
||||
"type": "IMAGEUPLOAD",
|
||||
"widget": {
|
||||
"name": "upload"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "遮罩",
|
||||
"name": "MASK",
|
||||
"type": "MASK",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LoadImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"",
|
||||
"image"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "VAEEncode",
|
||||
"pos": [
|
||||
440,
|
||||
560
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "像素",
|
||||
"name": "pixels",
|
||||
"type": "IMAGE",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 4
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEEncode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "SetLatentNoiseMask",
|
||||
"pos": [
|
||||
660,
|
||||
560
|
||||
],
|
||||
"size": [
|
||||
165.134375,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "遮罩",
|
||||
"name": "mask",
|
||||
"type": "MASK",
|
||||
"link": 6
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "SetLatentNoiseMask"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
960,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
0.6
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1340,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 12
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1600,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图片",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 13
|
||||
},
|
||||
{
|
||||
"localized_name": "文件名前缀",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"example"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
3,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
4,
|
||||
1,
|
||||
2,
|
||||
5,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
5,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
6,
|
||||
4,
|
||||
1,
|
||||
6,
|
||||
1,
|
||||
"MASK"
|
||||
],
|
||||
[
|
||||
7,
|
||||
1,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
8,
|
||||
2,
|
||||
0,
|
||||
7,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
9,
|
||||
3,
|
||||
0,
|
||||
7,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
10,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
11,
|
||||
7,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
12,
|
||||
1,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
13,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"version": 0.4,
|
||||
"extra": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,523 @@
|
||||
{
|
||||
"name": "Example: text to picture",
|
||||
"title": {
|
||||
"zh": "文生图",
|
||||
"en": "Text to picture"
|
||||
},
|
||||
"about": {
|
||||
"zh": "最基础的一条路:写提示词,出一张图。",
|
||||
"en": "The plainest route: write a prompt, get a picture."
|
||||
},
|
||||
"ai": "One pass from words (txt2img). open_workflow, set_model on its loader, then run_workflow with the prompt.",
|
||||
"version": {
|
||||
"zh": "SDXL / SD 1.5",
|
||||
"en": "SDXL / SD 1.5"
|
||||
},
|
||||
"family": "sd",
|
||||
"workflow": {
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 7,
|
||||
"last_link_id": 9,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
273.4576171875,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Checkpoint名称",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
3
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
8
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
60
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
4
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
5
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"low quality, blurry, watermark, text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
440,
|
||||
540
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "宽度",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "高度",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "批量大小",
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
832,
|
||||
1216,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
860,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1240,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 8
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1500,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图片",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "文件名前缀",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"example"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
8,
|
||||
1,
|
||||
2,
|
||||
6,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
9,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"version": 0.4,
|
||||
"extra": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,741 @@
|
||||
{
|
||||
"name": "Example: two passes",
|
||||
"title": {
|
||||
"zh": "两段放大",
|
||||
"en": "Two passes"
|
||||
},
|
||||
"about": {
|
||||
"zh": "只用官方节点:先出一张图,放大 1.25 倍再重绘一遍。这是最简单的两段;想用放大模型或 Tile 锁图,用“做法”里的放大。",
|
||||
"en": "ComfyUI's own nodes only: a picture, then enlarged 1.25x and redrawn. The plainest two passes; for an upscale model or a Tile lock, use the upscale ways in Ways."
|
||||
},
|
||||
"ai": "A pass, then the latent enlarged 1.25x and redrawn at denoise 0.5. open_workflow, set_model on its loader, then run_workflow with the prompt. For an upscale model or a Tile lock, add_nodes(use='upscale') on a one-pass canvas does better.",
|
||||
"version": {
|
||||
"zh": "SDXL / SD 1.5",
|
||||
"en": "SDXL / SD 1.5"
|
||||
},
|
||||
"family": "sd",
|
||||
"workflow": {
|
||||
"id": "00000000-0000-0000-0000-000000000000",
|
||||
"revision": 0,
|
||||
"last_node_id": 9,
|
||||
"last_link_id": 14,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
40,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
273.4576171875,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Checkpoint名称",
|
||||
"name": "ckpt_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "ckpt_name"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
3,
|
||||
8
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "CLIP",
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
1,
|
||||
2
|
||||
]
|
||||
},
|
||||
{
|
||||
"localized_name": "VAE",
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
13
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
""
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
60
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 1
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
4,
|
||||
9
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"a quiet lake at sunrise, mountains in the distance, soft morning light, detailed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
440,
|
||||
300
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "clip",
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 2
|
||||
},
|
||||
{
|
||||
"localized_name": "文本",
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "text"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "条件",
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
5,
|
||||
10
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"low quality, blurry, watermark, text"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
440,
|
||||
540
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "宽度",
|
||||
"name": "width",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "width"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "高度",
|
||||
"name": "height",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "height"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "批量大小",
|
||||
"name": "batch_size",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "batch_size"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
6
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
832,
|
||||
1216,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
860,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 3
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 5
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
7
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 6,
|
||||
"type": "LatentUpscaleBy",
|
||||
"pos": [
|
||||
1240,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
82
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 7
|
||||
},
|
||||
{
|
||||
"localized_name": "缩放算法",
|
||||
"name": "upscale_method",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "upscale_method"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "缩放比例",
|
||||
"name": "scale_by",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "scale_by"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
11
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "LatentUpscaleBy"
|
||||
},
|
||||
"widgets_values": [
|
||||
"nearest-exact",
|
||||
1.25
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
1560,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
262
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "模型",
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 8
|
||||
},
|
||||
{
|
||||
"localized_name": "正面条件",
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 9
|
||||
},
|
||||
{
|
||||
"localized_name": "负面条件",
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 10
|
||||
},
|
||||
{
|
||||
"localized_name": "Latent图像",
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 11
|
||||
},
|
||||
{
|
||||
"localized_name": "种子",
|
||||
"name": "seed",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "seed"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "步数",
|
||||
"name": "steps",
|
||||
"type": "INT",
|
||||
"widget": {
|
||||
"name": "steps"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "cfg",
|
||||
"name": "cfg",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "cfg"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "采样器名称",
|
||||
"name": "sampler_name",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "sampler_name"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "调度器",
|
||||
"name": "scheduler",
|
||||
"type": "COMBO",
|
||||
"widget": {
|
||||
"name": "scheduler"
|
||||
},
|
||||
"link": null
|
||||
},
|
||||
{
|
||||
"localized_name": "降噪",
|
||||
"name": "denoise",
|
||||
"type": "FLOAT",
|
||||
"widget": {
|
||||
"name": "denoise"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
12
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
0,
|
||||
"randomize",
|
||||
28,
|
||||
5.5,
|
||||
"euler_ancestral",
|
||||
"normal",
|
||||
0.5
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 8,
|
||||
"type": "VAEDecode",
|
||||
"pos": [
|
||||
1940,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
140,
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "Latent",
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 12
|
||||
},
|
||||
{
|
||||
"localized_name": "vae",
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 13
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "图像",
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
14
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAEDecode"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
2200,
|
||||
120
|
||||
],
|
||||
"size": [
|
||||
270,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"localized_name": "图片",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 14
|
||||
},
|
||||
{
|
||||
"localized_name": "文件名前缀",
|
||||
"name": "filename_prefix",
|
||||
"type": "STRING",
|
||||
"widget": {
|
||||
"name": "filename_prefix"
|
||||
},
|
||||
"link": null
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"localized_name": "images",
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"links": null
|
||||
}
|
||||
],
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"example"
|
||||
]
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
2,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
2,
|
||||
1,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
3,
|
||||
1,
|
||||
0,
|
||||
5,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
4,
|
||||
2,
|
||||
0,
|
||||
5,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
3,
|
||||
0,
|
||||
5,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
6,
|
||||
4,
|
||||
0,
|
||||
5,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
7,
|
||||
5,
|
||||
0,
|
||||
6,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
8,
|
||||
1,
|
||||
0,
|
||||
7,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
9,
|
||||
2,
|
||||
0,
|
||||
7,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
10,
|
||||
3,
|
||||
0,
|
||||
7,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
11,
|
||||
6,
|
||||
0,
|
||||
7,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
12,
|
||||
7,
|
||||
0,
|
||||
8,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
13,
|
||||
1,
|
||||
2,
|
||||
8,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
14,
|
||||
8,
|
||||
0,
|
||||
9,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"version": 0.4,
|
||||
"extra": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+48
-7
@@ -14,6 +14,9 @@ from .media_routes import (
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS,
|
||||
_build_card_thumbnail,
|
||||
_build_video_poster,
|
||||
picture_placeholder,
|
||||
picture_size,
|
||||
shrunk_in_child,
|
||||
)
|
||||
from .path_utils import require_filename, resolve_within
|
||||
from .trash import move_to_trash, trash_failure
|
||||
@@ -25,6 +28,10 @@ GALLERY_IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.gif', '.avif'}
|
||||
# Videos a browser plays in the viewer; not .avi (Chrome and Edge cannot).
|
||||
GALLERY_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.mkv'}
|
||||
GALLERY_MEDIA = ("all", "image", "video")
|
||||
BIG_FILE_BYTES = 32 * 1024 * 1024 # from this size the listing reads a picture's pixel size
|
||||
PREVIEW_EDGE = 4096
|
||||
PREVIEW_PIXELS = 36_000_000 # past this (or PREVIEW_BYTES) the viewers show a PREVIEW_EDGE copy
|
||||
PREVIEW_BYTES = 64 * 1024 * 1024
|
||||
_gallery_snapshot_lock = threading.Lock()
|
||||
_gallery_snapshot = {"root": None, "expires": 0, "images": []}
|
||||
|
||||
@@ -36,18 +43,25 @@ def _collect_gallery_images(output_dir):
|
||||
ext = os.path.splitext(filename)[1].lower()
|
||||
if ext not in GALLERY_IMAGE_EXTENSIONS and ext not in GALLERY_VIDEO_EXTENSIONS:
|
||||
continue
|
||||
path = os.path.join(root, filename)
|
||||
try:
|
||||
mtime = os.path.getmtime(os.path.join(root, filename))
|
||||
stat = os.stat(path)
|
||||
except OSError:
|
||||
continue
|
||||
subfolder = os.path.relpath(root, output_dir)
|
||||
images.append({
|
||||
item = {
|
||||
"filename": filename,
|
||||
"subfolder": "" if subfolder == "." else subfolder.replace(os.sep, '/'),
|
||||
"type": "output",
|
||||
"mtime": mtime,
|
||||
"mtime": stat.st_mtime,
|
||||
"bytes": stat.st_size,
|
||||
"media_type": "video" if ext in GALLERY_VIDEO_EXTENSIONS else "image",
|
||||
})
|
||||
}
|
||||
# A big file's pixel size from its header: the card says it is a very large picture.
|
||||
size = picture_size(path) if stat.st_size >= BIG_FILE_BYTES and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS else None
|
||||
if size:
|
||||
item["width"], item["height"] = size
|
||||
images.append(item)
|
||||
images.sort(key=lambda item: (item['mtime'], item['subfolder'], item['filename']), reverse=True)
|
||||
return images
|
||||
|
||||
@@ -142,14 +156,41 @@ async def api_output_thumbnail(request):
|
||||
ext = os.path.splitext(file_path)[1].lower()
|
||||
if ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
|
||||
served = await asyncio.to_thread(_build_card_thumbnail, file_path)
|
||||
if served is None:
|
||||
return picture_placeholder(file_path)
|
||||
elif ext in CARD_POSTER_VIDEO_EXTENSIONS:
|
||||
poster = await asyncio.to_thread(_build_video_poster, file_path)
|
||||
if poster:
|
||||
served = poster
|
||||
served = await asyncio.to_thread(_build_video_poster, file_path)
|
||||
if not served: # no first frame (a broken file, no decoder): never the video itself as a picture
|
||||
return picture_placeholder(file_path)
|
||||
# Revalidate (cheap 304s): an output file can be replaced under the same name.
|
||||
return web.FileResponse(served, headers={'Cache-Control': 'no-cache'})
|
||||
|
||||
|
||||
async def api_output_preview(request):
|
||||
"""GET /anomalous/output_preview?filename&subfolder[&info=1] - an output picture as the viewers
|
||||
show it: itself, or for a very large one (past PREVIEW_PIXELS or PREVIEW_BYTES) a cached
|
||||
PREVIEW_EDGE px copy, so the browser never decodes hundreds of megapixels. `info=1`: JSON
|
||||
{width, height, bytes, preview} instead, for the viewer's note and its "Open the original"."""
|
||||
output_dir = folder_paths.get_output_directory()
|
||||
try:
|
||||
filename = require_filename(request.query.get('filename'))
|
||||
file_path = resolve_within(resolve_within(output_dir, request.query.get('subfolder', '')), filename)
|
||||
except ValueError:
|
||||
return web.json_response({"status": "error", "message": "Invalid parameters"}, status=400)
|
||||
if not os.path.isfile(file_path) or os.path.splitext(file_path)[1].lower() not in GALLERY_IMAGE_EXTENSIONS:
|
||||
return web.json_response({"status": "error", "message": "File not found"}, status=404)
|
||||
size, file_bytes = picture_size(file_path), os.path.getsize(file_path)
|
||||
large = file_bytes > PREVIEW_BYTES or bool(size) and size[0] * size[1] > PREVIEW_PIXELS
|
||||
if request.query.get('info') == '1':
|
||||
return web.json_response({"width": size and size[0], "height": size and size[1], "bytes": file_bytes, "preview": large})
|
||||
if not large:
|
||||
return web.FileResponse(file_path, headers={'Cache-Control': 'no-cache'})
|
||||
preview = await asyncio.to_thread(shrunk_in_child, file_path, PREVIEW_EDGE, 'preview')
|
||||
if not preview:
|
||||
return picture_placeholder(file_path)
|
||||
return web.FileResponse(preview, headers={'Cache-Control': 'no-cache', 'Content-Type': 'image/webp'})
|
||||
|
||||
|
||||
async def api_delete_gallery_image(request):
|
||||
try:
|
||||
data = await request.json()
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"""Run by media_routes.py as a child process: a smaller WebP of a picture too large to decode
|
||||
inside ComfyUI (past Pillow's pixel limit), so the memory it takes goes back when it ends.
|
||||
Arguments: the picture, the WebP to write, the longest edge, the most pixels it may decode."""
|
||||
|
||||
import sys
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
|
||||
def shrink(source, target, edge, most_pixels):
|
||||
Image.MAX_IMAGE_PIXELS = most_pixels
|
||||
with Image.open(source) as image:
|
||||
image.thumbnail((edge, edge), Image.Resampling.LANCZOS) # a JPEG decodes at a fraction; others whole, once
|
||||
image = ImageOps.exif_transpose(image) # on the small one: turning the whole picture would copy it
|
||||
if image.mode not in ('RGB', 'RGBA'):
|
||||
image = image.convert('RGBA' if 'transparency' in image.info else 'RGB')
|
||||
image.save(target, format='WEBP', quality=84, method=4)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
shrink(sys.argv[1], sys.argv[2], int(sys.argv[3]), int(sys.argv[4]))
|
||||
@@ -0,0 +1,413 @@
|
||||
"""任务单 (the job sheet): many runs of one workflow one after another. Each row is a prompt, settings by
|
||||
name, or nothing (the workflow as it is), with how many runs it gets; a pause between pictures if wanted. The
|
||||
ComfyUI page runs it (web/modules/job_sheet_run.js): it keeps the workflow as it was at the start, queues each
|
||||
run from a copy off the canvas, and the next one only when the one before has ended and the pause is over (one
|
||||
ahead with no pause). This file keeps the one sheet of this ComfyUI and where it stands: its rows, the runs'
|
||||
prompt ids, the pictures made, the page running it and what was asked of that page (pause, stop, go on). A
|
||||
page that said nothing for STALE seconds is gone: the sheet waits for a page to take it over, the same tab
|
||||
after a reload by itself. Stop takes the sheet's waiting runs out of ComfyUI's queue here and interrupts its
|
||||
running one. A sheet an AI gave (run_workflow(sheet=…), mcp_actions.py) is that call's step in the task: its
|
||||
progress, pictures and end are written on the step (ai_tasks.py), so run_status and the workbench follow it
|
||||
like a run. Kept in user/anomalous/job_sheet.json and its workflow beside it; every change goes to the pages
|
||||
as "anomalous.job_sheet"."""
|
||||
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import ai_tasks
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
MAX_ROWS = 200
|
||||
MAX_COUNT = 50 # runs of one row
|
||||
MAX_TOTAL = 1000 # runs of a sheet
|
||||
MAX_PAUSE = 3600 # seconds between pictures
|
||||
MAX_TEXT = 20000
|
||||
MAX_WORKFLOW = 20 * 1024 * 1024
|
||||
STALE = 45 # seconds without word from the page running a sheet: that page is gone
|
||||
LAST = 8 # newest pictures the card shows
|
||||
SEED_TOP = 1125899906842624 # ComfyUI's widest seed; web/modules/snapshot_plan.js
|
||||
ENDED = ("done", "stopped")
|
||||
WANTS = ("pause", "stop", "go")
|
||||
ROW_KEYS = ("prompt", "negative", "values", "count")
|
||||
PAGE = re.compile(r"^[0-9A-Za-z-]{8,64}$")
|
||||
SHEET, WORKFLOW = "job_sheet.json", "job_sheet_workflow.json"
|
||||
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
class SheetError(ValueError):
|
||||
"""What is wrong with a sheet or a word about it, said to whoever gave it."""
|
||||
|
||||
|
||||
def _path(name):
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), name)
|
||||
|
||||
|
||||
def _load():
|
||||
try:
|
||||
with open(_path(SHEET), encoding="utf-8") as source:
|
||||
sheet = json.load(source)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return sheet if isinstance(sheet, dict) and sheet.get("id") else None
|
||||
|
||||
|
||||
def _stale(sheet, now=None):
|
||||
return (time.time() if now is None else now) - sheet.get("heard", 0) > STALE
|
||||
|
||||
|
||||
def public(sheet):
|
||||
"""What the pages and the AI see of `sheet`: all but the runs' ids, with the run going (`now`, its place in
|
||||
the sheet) and whether its page is gone."""
|
||||
if not sheet:
|
||||
return None
|
||||
now = dict(sheet["ids"]).get(sheet["pending"][0]) if sheet["pending"] else None
|
||||
return {**{key: value for key, value in sheet.items() if key != "ids"}, "now": now, "stale": _stale(sheet)}
|
||||
|
||||
|
||||
def _tell(sheet):
|
||||
try:
|
||||
from server import PromptServer
|
||||
PromptServer.instance.send_sync("anomalous.job_sheet", {"sheet": public(sheet)})
|
||||
except Exception: # noqa: BLE001 - tests and early start have no server; the page reads it on open
|
||||
pass
|
||||
|
||||
|
||||
def _save(sheet):
|
||||
atomic_write_json(_path(SHEET), sheet)
|
||||
_tell(sheet)
|
||||
|
||||
|
||||
def _count(value, what):
|
||||
if isinstance(value, bool) or not isinstance(value, int) or not 1 <= value <= MAX_COUNT:
|
||||
raise SheetError(f"{what} is a whole number from 1 to {MAX_COUNT}.")
|
||||
return value
|
||||
|
||||
|
||||
def pause_of(value):
|
||||
if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= MAX_PAUSE:
|
||||
raise SheetError(f"pause is seconds between pictures, 0 to {MAX_PAUSE}.")
|
||||
return round(float(value), 1)
|
||||
|
||||
|
||||
def rows_of(rows, count=1):
|
||||
"""The rows checked: [{prompt?, negative?, values?, count}]. A row given as text is its prompt; a row with
|
||||
none of them runs the workflow as it is. `count`: a row's runs when it does not say."""
|
||||
from .mcp_fill import node_values
|
||||
from .mcp_tools import ToolError
|
||||
|
||||
if not isinstance(rows, list) or not 1 <= len(rows) <= MAX_ROWS:
|
||||
raise SheetError(f"rows is a list of 1 to {MAX_ROWS} rows.")
|
||||
count = _count(count, "count")
|
||||
out = []
|
||||
for at, row in enumerate(rows, 1):
|
||||
row = {"prompt": row} if isinstance(row, str) else row
|
||||
if not isinstance(row, dict):
|
||||
raise SheetError(f"Row {at} is a prompt, or {{prompt, negative, values, count}}.")
|
||||
unknown = sorted(set(row) - set(ROW_KEYS))
|
||||
if unknown:
|
||||
raise SheetError(f"Row {at}: {', '.join(unknown)[:80]} is no part of a row ({', '.join(ROW_KEYS)}).")
|
||||
item = {}
|
||||
for key in ("prompt", "negative"):
|
||||
text = row.get(key)
|
||||
if text is None:
|
||||
continue
|
||||
if not isinstance(text, str) or not text.strip() or len(text) > MAX_TEXT:
|
||||
raise SheetError(f"Row {at}: {key} is text of up to {MAX_TEXT} characters.")
|
||||
item[key] = text
|
||||
if row.get("values") is not None:
|
||||
try:
|
||||
item["values"] = node_values(row["values"])
|
||||
except ToolError as error:
|
||||
raise SheetError(f"Row {at}: {error}") from error
|
||||
item["count"] = _count(row.get("count", count), f"Row {at}'s count")
|
||||
out.append(item)
|
||||
total = sum(item["count"] for item in out)
|
||||
if total > MAX_TOTAL:
|
||||
raise SheetError(f"{total} runs in all; a sheet takes up to {MAX_TOTAL}.")
|
||||
return out
|
||||
|
||||
|
||||
def current():
|
||||
return public(_load())
|
||||
|
||||
|
||||
def workflow():
|
||||
try:
|
||||
with open(_path(WORKFLOW), encoding="utf-8") as source:
|
||||
return json.load(source)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _end_old(sheet):
|
||||
"""An unfinished sheet a new one replaces: it ends stopped, on its step too."""
|
||||
if sheet and sheet["state"] not in ENDED:
|
||||
_drop(sheet["pending"])
|
||||
sheet.update(state="stopped", want=None, ended=time.time(), note=sheet.get("note") or "replaced")
|
||||
_sync_step(sheet)
|
||||
|
||||
|
||||
def start(body):
|
||||
"""A new sheet from the page that runs it: {rows, count?, pause?, same_seeds?, workflow, title?, page, task?}."""
|
||||
page = body.get("page")
|
||||
if not isinstance(page, str) or not PAGE.match(page):
|
||||
raise SheetError("page is the id of the page that runs the sheet.")
|
||||
rows = rows_of(body.get("rows"), body.get("count", 1))
|
||||
pause = pause_of(body.get("pause", 0))
|
||||
flow = body.get("workflow")
|
||||
if not isinstance(flow, dict) or not flow.get("nodes"):
|
||||
raise SheetError("The sheet needs the workflow it runs, with its nodes.")
|
||||
if len(json.dumps(flow)) > MAX_WORKFLOW:
|
||||
raise SheetError("This workflow is too big for a sheet.")
|
||||
task = body.get("task") if isinstance(body.get("task"), str) and ai_tasks.get_task(body["task"]) else None
|
||||
now = time.time()
|
||||
with _lock:
|
||||
old = _load()
|
||||
if old and old["state"] not in ENDED and not _stale(old, now):
|
||||
raise SheetError(f"A sheet is going ({old['done']} of {old['total']} runs done): stop it first, or wait.")
|
||||
_end_old(old)
|
||||
sheet = {"id": uuid.uuid4().hex[:12], "by": "ai" if task else "user", "task": task, "step": None,
|
||||
"title": str(body.get("title") or "")[:120], "rows": rows, "pause": pause,
|
||||
"same_seeds": bool(body.get("same_seeds")), "base": random.randrange(SEED_TOP - MAX_COUNT),
|
||||
"total": sum(row["count"] for row in rows), "state": "running", "want": None, "at": 0, "done": 0,
|
||||
"failed": 0, "pictures": 0, "last": [], "ids": [], "pending": [], "page": page, "heard": now,
|
||||
"started": now, "ended": None, "note": ""}
|
||||
atomic_write_json(_path(WORKFLOW), flow)
|
||||
_save(sheet)
|
||||
return public(sheet)
|
||||
|
||||
|
||||
def _pictures(prompt_id):
|
||||
"""The pictures run `prompt_id` saved, from ComfyUI's history."""
|
||||
from .ai_task_runs import _queue, outputs_of
|
||||
|
||||
try:
|
||||
record = _queue().get_history(prompt_id=prompt_id).get(prompt_id)
|
||||
except Exception: # noqa: BLE001 - only the pictures are missed; the page said the run ended
|
||||
return []
|
||||
return outputs_of(record) if record else []
|
||||
|
||||
|
||||
def _drop(prompt_ids):
|
||||
"""`prompt_ids` out of ComfyUI's queue, the one running interrupted (ai_run_control.py: only while it is ours)."""
|
||||
if not prompt_ids:
|
||||
return
|
||||
from .ai_run_control import _interrupt
|
||||
from .ai_task_runs import _queue
|
||||
|
||||
try:
|
||||
queue = _queue()
|
||||
for prompt_id in prompt_ids:
|
||||
queue.delete_queue_item(lambda item, pid=prompt_id: item[1] == pid)
|
||||
_interrupt(queue, prompt_id)
|
||||
except Exception: # noqa: BLE001 - the queue may be gone with ComfyUI; nothing is left to stop then
|
||||
pass
|
||||
|
||||
|
||||
def report(body):
|
||||
"""Word from the page running the sheet: {id, page, at, queued: [[prompt id, run]], finished: [[prompt id,
|
||||
outcome]], state?, note?}; outcome "ok", "error", "interrupted" or "gone" (taken out of the queue). Answers the sheet,
|
||||
whose `want` says what was asked of the page."""
|
||||
pictures, dropped = [], []
|
||||
with _lock:
|
||||
sheet = _load()
|
||||
if not sheet or sheet["id"] != body.get("id"):
|
||||
raise SheetError("This sheet is gone.")
|
||||
if sheet["page"] != body.get("page"):
|
||||
raise SheetError("Another page runs this sheet now.")
|
||||
ended = sheet["state"] in ENDED
|
||||
known = {pid for pid, _ in sheet["ids"]}
|
||||
for item in body.get("queued") or []:
|
||||
if (isinstance(item, list) and len(item) == 2 and isinstance(item[0], str) and 0 < len(item[0]) <= 128
|
||||
and isinstance(item[1], int) and item[0] not in known):
|
||||
if not ended:
|
||||
sheet["ids"].append(item)
|
||||
sheet["pending"].append(item[0])
|
||||
known.add(item[0])
|
||||
if sheet["want"] == "stop" or sheet["state"] == "stopped":
|
||||
dropped.append(item[0]) # queued as stop was asked: it goes too
|
||||
if ended:
|
||||
_drop(dropped) # queuePrompt can return after the page's own Stop has already ended the sheet
|
||||
return public(sheet)
|
||||
sheet["heard"] = time.time()
|
||||
for item in body.get("finished") or []:
|
||||
if not (isinstance(item, list) and len(item) == 2 and item[0] in sheet["pending"]
|
||||
and item[1] in ("ok", "error", "interrupted", "gone")):
|
||||
continue
|
||||
sheet["pending"].remove(item[0])
|
||||
sheet["done"] += 1
|
||||
if item[1] != "ok":
|
||||
sheet["failed"] += 1
|
||||
made = _pictures(item[0]) if item[1] == "ok" else []
|
||||
pictures += made
|
||||
sheet["pictures"] += len(made)
|
||||
if pictures:
|
||||
sheet["last"] = (sheet["last"] + pictures)[-LAST:]
|
||||
at = body.get("at")
|
||||
if isinstance(at, int) and 0 <= at <= sheet["total"]:
|
||||
sheet["at"] = at
|
||||
state = "stopped" if sheet["want"] == "stop" else body.get("state")
|
||||
if state in ("running", "paused", "stopped", "done"):
|
||||
if state != sheet["state"]:
|
||||
sheet["want"] = None
|
||||
sheet["state"] = state
|
||||
if state in ENDED:
|
||||
sheet["ended"] = time.time()
|
||||
if isinstance(body.get("note"), str):
|
||||
sheet["note"] = body["note"][:500]
|
||||
_save(sheet)
|
||||
_sync_step(sheet, pictures)
|
||||
_drop(dropped)
|
||||
return public(sheet)
|
||||
|
||||
|
||||
def ask(sheet_id, want, page=None):
|
||||
"""The user or the AI asks the sheet to pause, stop or go on. A live page running it hears it and answers by
|
||||
its report; with that page gone it is done here, and "go" makes the asking page the one that runs it.
|
||||
Answers {sheet, run}: run means the asking page runs the sheet from now on."""
|
||||
if want not in WANTS:
|
||||
raise SheetError(f"want is one of {', '.join(WANTS)}.")
|
||||
dropped = []
|
||||
with _lock:
|
||||
sheet = _load()
|
||||
if not sheet or sheet["id"] != sheet_id:
|
||||
raise SheetError("This sheet is gone.")
|
||||
if sheet["state"] in ENDED:
|
||||
if want == "go":
|
||||
raise SheetError("This sheet has ended; start it again.")
|
||||
return {"sheet": public(sheet), "run": False}
|
||||
alive = not _stale(sheet)
|
||||
if want == "stop":
|
||||
dropped = list(sheet["pending"]) # here, whoever runs it: the GPU is free at once
|
||||
if want == "go" and (not alive or sheet["page"] == page):
|
||||
if not isinstance(page, str) or not PAGE.match(page):
|
||||
raise SheetError("Go on from a ComfyUI page: the sheet runs there.")
|
||||
sheet.update(state="running", want=None, page=page, heard=time.time(), note="")
|
||||
run = True
|
||||
elif alive and sheet["page"] != page:
|
||||
sheet["want"] = want
|
||||
run = False
|
||||
else: # no live page runs it, or the asking page does: it is done here
|
||||
sheet["want"] = None
|
||||
sheet["state"] = "stopped" if want == "stop" else "paused" if want == "pause" else sheet["state"]
|
||||
if want == "stop":
|
||||
sheet["ended"] = time.time()
|
||||
run = False
|
||||
_save(sheet)
|
||||
_sync_step(sheet)
|
||||
_drop(dropped)
|
||||
return {"sheet": public(sheet), "run": run}
|
||||
|
||||
|
||||
def clear(sheet_id):
|
||||
"""Takes away a sheet that is not going (the card's close)."""
|
||||
with _lock:
|
||||
sheet = _load()
|
||||
if not sheet or sheet["id"] != sheet_id:
|
||||
return
|
||||
if sheet["state"] not in ENDED and not _stale(sheet):
|
||||
raise SheetError("This sheet is going: stop it first.")
|
||||
_end_old(sheet)
|
||||
for name in (SHEET, WORKFLOW):
|
||||
try:
|
||||
os.remove(_path(name))
|
||||
except OSError:
|
||||
pass
|
||||
_tell(None)
|
||||
|
||||
|
||||
def attach(sheet_id, step_id):
|
||||
"""The AI's call that started sheet `sheet_id` became run step `step_id` (mcp_server.py)."""
|
||||
with ai_tasks._lock:
|
||||
task_id = next((task["id"] for task in ai_tasks._load().values()
|
||||
if any(step["id"] == step_id for step in task.get("steps") or [])), None)
|
||||
with _lock:
|
||||
sheet = _load()
|
||||
if not task_id or not sheet or sheet["id"] != sheet_id:
|
||||
return
|
||||
sheet.update(task=task_id, step=step_id)
|
||||
atomic_write_json(_path(SHEET), sheet)
|
||||
_sync_step(sheet, sheet["last"])
|
||||
|
||||
|
||||
def _words(sheet):
|
||||
return f"任务单 {sheet['done']}/{sheet['total']} / Job sheet: {sheet['done']} of {sheet['total']} runs"
|
||||
|
||||
|
||||
def _sync_step(sheet, pictures=()):
|
||||
"""Where the sheet stands, on the task step that started it: its progress while it goes, the newest pictures,
|
||||
and its end as a run's (done, cancelled, error; the line to paste to the AI as ai_task_runs.py gives it)."""
|
||||
if not sheet.get("task") or not sheet.get("step"):
|
||||
return
|
||||
from . import ai_gates
|
||||
|
||||
with ai_tasks._lock:
|
||||
task = ai_tasks._load().get(sheet["task"])
|
||||
step = next((item for item in (task or {}).get("steps", []) if item["id"] == sheet["step"]), None)
|
||||
if not step:
|
||||
return
|
||||
step["sheet"] = sheet["id"]
|
||||
step["sheet_total"] = sheet["total"]
|
||||
if pictures:
|
||||
kept = [item for item in step["results"] if item["type"] != "image"]
|
||||
shown = [item for item in step["results"] if item["type"] == "image"]
|
||||
step["results"] = kept + (shown + [item for item in pictures if item not in shown])[-ai_tasks.MAX_RESULTS:]
|
||||
if sheet["state"] not in ENDED:
|
||||
step["state"] = "running"
|
||||
paused = " · 暂停 / paused" if sheet["state"] == "paused" else ""
|
||||
step["progress"] = {"state": "running", "prompts": sheet["total"], "prompt": min(sheet["done"] + 1, sheet["total"]),
|
||||
"note": _words(sheet) + paused + (f" · {sheet['note']}" if sheet["note"] else "")}
|
||||
elif step.get("state") == "running":
|
||||
step.pop("progress", None)
|
||||
failed = sheet["failed"] and not sheet["pictures"]
|
||||
step["state"] = "cancelled" if sheet["state"] == "stopped" else "error" if failed else "done"
|
||||
step["prompts"] = sheet["done"]
|
||||
if sheet["failed"]:
|
||||
step["error"] = f"{sheet['failed']} of {sheet['done']} runs failed. {sheet['note']}".strip()
|
||||
step["ok"] = not failed
|
||||
step["ms"] = int((time.time() - step["at"]) * 1000)
|
||||
if step["state"] in ("done", "error") and not step.get("told") and ai_gates.run_wait() == "call":
|
||||
step["call_ai"] = True
|
||||
task["updated"] = max(task["updated"], time.time())
|
||||
ai_tasks._save(task)
|
||||
ai_tasks._notify(sheet["task"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- routes
|
||||
|
||||
async def _body(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
body = None
|
||||
if not isinstance(body, dict):
|
||||
raise SheetError("Expected a JSON object.")
|
||||
return body
|
||||
|
||||
|
||||
def _answer(run):
|
||||
async def handler(request):
|
||||
import asyncio
|
||||
|
||||
try:
|
||||
body = await _body(request) if request.method == "POST" else {}
|
||||
return web.json_response(await asyncio.to_thread(run, body))
|
||||
except SheetError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
return handler
|
||||
|
||||
|
||||
api_get = _answer(lambda body: {"sheet": current()})
|
||||
api_workflow = _answer(lambda body: {"workflow": workflow()})
|
||||
api_start = _answer(lambda body: {"sheet": start(body)})
|
||||
api_report = _answer(lambda body: {"sheet": report(body)})
|
||||
api_ask = _answer(lambda body: ask(body.get("id"), body.get("want"), body.get("page")))
|
||||
api_clear = _answer(lambda body: clear(body.get("id")) or {"sheet": None})
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Whether an Anima ControlNet-LLLite fits the drawing model it is put on, before a run. An LLLite file
|
||||
holds modules for each block of the model it was trained on (lllite_dit_blocks_<n>_…: 28 blocks for the 2B
|
||||
Anima models); on a model with more blocks (40 for the 2.9B ones) ComfyUI-Anima-LLLite stops the run with
|
||||
"depth_embed slices missing". Both counts are read from the files' headers, so nothing is told by a
|
||||
model's name or size. The page (web/modules/lllite_fit.js) asks with the LLLite nodes a run would send
|
||||
and the model each is put on; the AI's run is then refused with what to change. An example with a Tile
|
||||
opens on a model the Tile fits (example_workflows.py). A file whose header cannot tell is not judged.
|
||||
POST /anomalous/lllite_fit {uses: [{model, lllite}]} -> {problems: [{model, lllite, blocks, made_for}]}."""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
try:
|
||||
from ..model_kind import read_head
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_kind import read_head
|
||||
|
||||
MODEL_FOLDERS = ("diffusion_models", "unet", "checkpoints")
|
||||
MAX_USES = 20
|
||||
MODEL_BLOCK = re.compile(r"(?:^|\.)blocks\.(\d+)\.")
|
||||
LLLITE_BLOCK = re.compile(r"^lllite_dit_blocks_(\d+)_")
|
||||
|
||||
|
||||
def _path(folders, name):
|
||||
for folder in folders:
|
||||
try:
|
||||
path = folder_paths.get_full_path(folder, name)
|
||||
except (KeyError, TypeError):
|
||||
path = None
|
||||
if path and os.path.isfile(path):
|
||||
return path
|
||||
return None
|
||||
|
||||
|
||||
def _blocks(path, pattern):
|
||||
"""How many blocks the names in the header of `path` count (by `pattern`); 0 when it cannot tell."""
|
||||
fmt, header = read_head(path) if path else (None, None)
|
||||
if fmt != "safetensors" or not header:
|
||||
return 0
|
||||
return len({int(found.group(1)) for key in header if (found := pattern.search(key))})
|
||||
|
||||
|
||||
def problem(model, lllite):
|
||||
"""{model, lllite, blocks, made_for} when LLLite file `lllite` (as its node names it) has no modules for
|
||||
some blocks of drawing model `model` (as its loader names it), else None."""
|
||||
made_for = _blocks(_path(("controlnet",), lllite), LLLITE_BLOCK)
|
||||
blocks = _blocks(_path(MODEL_FOLDERS, model), MODEL_BLOCK)
|
||||
if not made_for or blocks <= made_for:
|
||||
return None
|
||||
short = lambda name: os.path.basename(str(name).replace("\\", "/")) # noqa: E731
|
||||
return {"model": short(model), "lllite": short(lllite), "blocks": blocks, "made_for": made_for}
|
||||
|
||||
|
||||
def check(uses):
|
||||
found = []
|
||||
for use in uses[:MAX_USES]:
|
||||
if isinstance(use, dict) and isinstance(use.get("model"), str) and isinstance(use.get("lllite"), str):
|
||||
odd = problem(use["model"], use["lllite"])
|
||||
if odd:
|
||||
found.append(odd)
|
||||
return found
|
||||
|
||||
|
||||
async def api_check(request):
|
||||
try:
|
||||
body = await request.json()
|
||||
except ValueError:
|
||||
body = None
|
||||
if not isinstance(body, dict) or not isinstance(body.get("uses"), list):
|
||||
return web.json_response({"error": "Expected {uses}"}, status=400)
|
||||
return web.json_response({"problems": await asyncio.to_thread(check, body["uses"])})
|
||||
+30
-7
@@ -6,7 +6,9 @@ version switches, saved settings) answers only requests from the computer ComfyU
|
||||
GET is local-only unless it is a read sent as POST (OPEN_POSTS); a GET is open unless it is not
|
||||
a plain read (LOCAL_GETS). A request is local when it comes from a loopback address, through no
|
||||
proxy, naming a loopback host; checking the Host also keeps out a page that reached this server
|
||||
under another name (DNS rebinding).
|
||||
under another name (DNS rebinding). A browser on this computer that opens ComfyUI at one of its own
|
||||
addresses (ComfyUI listening on its LAN address only) is local too: the request then comes from
|
||||
the very address it reached, which no other device can have, and names it.
|
||||
|
||||
ComfyUI on a rented cloud server is only ever opened from another device. An empty file named
|
||||
`allow_remote_changes` in the plugin's folder, made on that server, lets every device that can
|
||||
@@ -36,6 +38,7 @@ LOCAL_GETS = frozenset({
|
||||
"/anomalous/tts_version/releases",
|
||||
"/anomalous/import/folders",
|
||||
"/anomalous/civitai_check",
|
||||
"/anomalous/download/versions", # asks Civitai
|
||||
"/anomalous/usage/check", # reads every output and saved workflow
|
||||
})
|
||||
# Routes that answer other devices themselves (MCP replies in JSON-RPC).
|
||||
@@ -46,8 +49,9 @@ REMOTE_CHANGES = os.path.isfile(REMOTE_FILE)
|
||||
if REMOTE_CHANGES:
|
||||
print("[Anomalous Browser] allow_remote_changes 文件存在:任何能打开页面的设备都能扫描、移动、删除模型和改设置。"
|
||||
" The allow_remote_changes file is there: every device that can open the page may change things.", flush=True)
|
||||
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览。"
|
||||
" Only the computer ComfyUI runs on can do this; other devices can only look.")
|
||||
MESSAGE = ("只能在运行 ComfyUI 的电脑上操作,别的设备只能浏览;窗口顶上的「仅浏览」里有远程也能用的办法。"
|
||||
" Only the computer ComfyUI runs on can do this; other devices can only look."
|
||||
" The View only chip at the top of the window says how to use it remotely too.")
|
||||
|
||||
|
||||
def loopback_host(name):
|
||||
@@ -62,13 +66,32 @@ def loopback_host(name):
|
||||
return False
|
||||
|
||||
|
||||
def _address(name):
|
||||
"""An IP address from a peer, socket or Host name (IPv4 inside IPv6 as IPv4), or None."""
|
||||
try:
|
||||
address = ipaddress.ip_address(str(name or "").strip("[]").split("%")[0])
|
||||
except ValueError:
|
||||
return None
|
||||
return getattr(address, "ipv4_mapped", None) or address
|
||||
|
||||
|
||||
def _own_address(request):
|
||||
"""This server's end of the request's connection: the address the browser reached."""
|
||||
transport = request.transport
|
||||
sockname = transport.get_extra_info("sockname") if transport else None
|
||||
return _address(sockname[0]) if isinstance(sockname, (tuple, list)) and sockname else None
|
||||
|
||||
|
||||
def is_local(request):
|
||||
"""The request comes from this computer, directly, and names a loopback host."""
|
||||
if not loopback_host(request.remote or ""):
|
||||
return False
|
||||
"""The request comes from this computer, directly, and names a loopback host, or else this
|
||||
computer's own address that it came from and reached."""
|
||||
if any(header in request.headers for header in PROXY_HEADERS):
|
||||
return False
|
||||
return loopback_host(urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname)
|
||||
host = urllib.parse.urlsplit("//" + request.headers.get("Host", "")).hostname
|
||||
if loopback_host(request.remote or ""):
|
||||
return loopback_host(host)
|
||||
own = _own_address(request)
|
||||
return own is not None and _address(request.remote) == own and _address(host) == own
|
||||
|
||||
|
||||
def may_change(request):
|
||||
|
||||
@@ -399,6 +399,10 @@ async def api_get_materials_by_node_type(request):
|
||||
continue
|
||||
blocks = [block for block in _material_node_blocks(material, include_values=True) if block["type"] == node_type]
|
||||
if blocks:
|
||||
# A prompt's role, by what it is wired to in the workflow it came from.
|
||||
roles = _prompt_roles_for_workflow(material["workflow"], material.get("promptRoleOverrides"))
|
||||
for block in blocks:
|
||||
block["role"] = (roles.get(str(block["node_id"])) or {}).get("role") or ""
|
||||
# Where the values came from, for Current node: a kept picture, or a node saved there.
|
||||
source = material.get("source") if isinstance(material.get("source"), dict) else {}
|
||||
image = source.get("image") if isinstance(source.get("image"), dict) else {}
|
||||
|
||||
+161
-37
@@ -15,7 +15,7 @@ import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
from . import ai_gates, mcp_fill, model_advice, run_refusal
|
||||
from . import ai_bases, ai_darkroom, ai_gates, ai_plans, job_sheets, mcp_fill, model_advice, run_refusal
|
||||
from .lora_info import trains_text_encoder
|
||||
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
|
||||
from .mcp_tools import MAX_LIMIT, ToolError, _resolve_model, _schema
|
||||
@@ -60,9 +60,11 @@ def describe_canvas(ctx):
|
||||
return _page("describe_canvas")
|
||||
|
||||
|
||||
def set_prompt(ctx, prompt, role="positive", node_id=None, mode="replace"):
|
||||
def set_prompt(ctx, prompt, role="positive", node_id=None, mode="replace", rewrite=""):
|
||||
if role not in ("positive", "negative") or mode not in ("replace", "append"):
|
||||
raise ToolError("role is positive or negative; mode is replace or append.")
|
||||
if rewrite: # the user's local helper writes it, where the AI app may not read the text (ai_darkroom.py)
|
||||
prompt = ai_darkroom.write(str(prompt), str(rewrite))
|
||||
return _page("set_prompt", {"text": str(prompt), "role": role, "node_id": node_id, "mode": mode})
|
||||
|
||||
|
||||
@@ -121,7 +123,8 @@ def download_model(ctx, type, url="", hash="", folder=None, name=""):
|
||||
"folder": None if folder is None else str(folder), "name": str(name or "")}, timeout=90)
|
||||
|
||||
|
||||
def download_status(ctx):
|
||||
def downloads(ctx):
|
||||
"""How the model downloads of this ComfyUI run are going (run_status(downloads=true))."""
|
||||
status, data = _local(ctx, "GET", "/anomalous/download/status")
|
||||
if status != 200:
|
||||
raise ToolError(f"Download status unavailable ({status}).")
|
||||
@@ -135,34 +138,147 @@ def download_status(ctx):
|
||||
"failed (error says why; starting it again continues it), cancelled."}
|
||||
|
||||
|
||||
def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, **slots):
|
||||
MAX_PROMPTS = 8
|
||||
MAX_TRIES = 16 # prompts x batch in one call
|
||||
|
||||
|
||||
def _submit(batch, base_only=False, hold=None):
|
||||
"""Queues what the canvas holds `batch` times (its first pass alone with `base_only`; `hold`: the base the
|
||||
user picked, ai_bases.py): (receipt IDs, the page's answer); IDs are None when no valid receipt came."""
|
||||
try:
|
||||
answer = ask_page("run_workflow", {"batch": batch, **({"base_only": True} if base_only else {}),
|
||||
**({"hold": hold} if hold else {})}, required_capability="prompt_ids")
|
||||
except (PageUnavailable, PageRefused) as error:
|
||||
raise ToolError(str(error))
|
||||
page = answer if isinstance(answer, dict) else {}
|
||||
ids = page.get("prompt_ids")
|
||||
if (not isinstance(ids, list) or not ids or len(ids) > batch
|
||||
or any(not isinstance(pid, str) or not pid or len(pid) > 128 for pid in ids) or len(set(ids)) != len(ids)):
|
||||
return None, page
|
||||
return ids, page
|
||||
|
||||
|
||||
def _refused(page):
|
||||
return (run_refusal.say(page.get("refusal")) or page.get("submission_error")
|
||||
or "No valid submission receipts. Check ComfyUI's queue before retrying; do not repeat automatically.")
|
||||
|
||||
|
||||
def _base_words(answer, page, base_only, hold):
|
||||
"""What run_workflow says of the base: the bases to pick from coming, or the picked one held (ai_bases.py)."""
|
||||
if base_only and page.get("base"):
|
||||
answer["base_only"] = page["base"] # the run's step keeps it: its previews become the user's picks
|
||||
answer["note"] += (" Only the first pass runs, as previews: when it ends Anomalous shows the user these bases to "
|
||||
"pick one; the picked one is kept and its seed held for your next run_workflow.")
|
||||
held = page.get("held") or {}
|
||||
if hold and held.get("kept"):
|
||||
answer["held"] = f"The rest grows from the base the user picked ({hold['kept']}); it is not drawn again."
|
||||
elif hold and held.get("held"):
|
||||
answer["held"] = f"The base the user picked: seed {hold['seed']} on node {hold['node']}, held for every try."
|
||||
if hold.get("show"):
|
||||
answer["base_check"] = {"show": hold["show"], "kept": hold["kept"]} # compared when the run ends
|
||||
elif hold:
|
||||
answer["held"] = (f"The base the user picked was not held: {held.get('reason') or 'the page did not hold it'}; "
|
||||
"this run draws its own base.")
|
||||
|
||||
|
||||
def _sheet(ctx, sheet, batch, workflow, slots):
|
||||
"""run_workflow(sheet=...): the job sheet the user's page runs one run after another (job_sheets.py)."""
|
||||
task = ctx.get("task")
|
||||
if ai_plans.due(task, ai_plans.PREVIEWS) or (ai_plans.due(task, ai_plans.PICK) and ai_bases.hold_of(task) is None):
|
||||
raise ToolError("Nothing was run: the plan's bases come first, for the user to pick one (their rule). "
|
||||
"run_workflow() shows them; the sheet after.")
|
||||
if not isinstance(sheet, dict) or set(sheet) - {"rows", "count", "pause", "same_seeds"}:
|
||||
raise ToolError("sheet is {rows: [prompt or {prompt, negative, values, count}], count, pause, same_seeds}.")
|
||||
prompt = slots.pop("prompt", None) # with a sheet: the prompt of each row that has none
|
||||
given = sheet.get("rows")
|
||||
if prompt and isinstance(given, list):
|
||||
given = [{"prompt": prompt, **row} if isinstance(row, dict) and row.get("prompt") is None else row for row in given]
|
||||
try:
|
||||
rows = job_sheets.rows_of(given, sheet.get("count", batch))
|
||||
pause = job_sheets.pause_of(sheet.get("pause", 0))
|
||||
except job_sheets.SheetError as error:
|
||||
raise ToolError(str(error))
|
||||
filled = mcp_fill.fill(ctx, workflow, **slots)
|
||||
try:
|
||||
started = ask_page("job_sheet", {"rows": rows, "pause": pause, "same_seeds": bool(sheet.get("same_seeds")), "task": task})
|
||||
except (PageUnavailable, PageRefused) as error:
|
||||
raise ToolError(str(error))
|
||||
answer = {**(started or {}), "note": "Anomalous runs the sheet on the user's ComfyUI page, one run after another; "
|
||||
"it shows there and as this step. It takes a while: tell the user and end your turn. run_status says "
|
||||
"where it stands, stop_run stops it."}
|
||||
if filled:
|
||||
answer["filled"] = filled
|
||||
return answer
|
||||
|
||||
|
||||
def run_workflow(ctx, batch=1, wait_seconds=55, workflow=None, prompts=None, base_only=False, sheet=None, **slots):
|
||||
from .ai_run_tracking import checked_wait
|
||||
checked_wait(wait_seconds) # Reject a wait that is no number before anything can be submitted.
|
||||
batch = max(1, min(8, int(batch)))
|
||||
filled = mcp_fill.fill(ctx, workflow, **slots) # a saved workflow and its blanks first (mcp_fill.py)
|
||||
try:
|
||||
answer = ask_page("run_workflow", {"batch": batch}, required_capability="prompt_ids")
|
||||
except (PageUnavailable, PageRefused) as error:
|
||||
raise ToolError(str(error))
|
||||
ids = answer.get("prompt_ids") if isinstance(answer, dict) else None
|
||||
page = answer if isinstance(answer, dict) else {}
|
||||
if (not isinstance(ids, list) or not ids or len(ids) > batch
|
||||
or any(not isinstance(pid, str) or not pid or len(pid) > 128 for pid in ids) or len(set(ids)) != len(ids)):
|
||||
raise ToolError((run_refusal.say(answer.get("refusal")) or answer.get("submission_error") if isinstance(answer, dict) else None)
|
||||
or "No valid submission receipts. Check ComfyUI's queue before retrying; do not repeat automatically.")
|
||||
answer = {"prompt_ids": ids, "queued": len(ids), "requested": batch,
|
||||
if sheet is not None:
|
||||
if prompts is not None or base_only:
|
||||
raise ToolError("A sheet's prompts are its rows: give no prompts or base_only with it.")
|
||||
return _sheet(ctx, sheet, batch, workflow, slots)
|
||||
task = ctx.get("task")
|
||||
first_of_plan = not base_only and prompts is None and ai_plans.due(task, ai_plans.PREVIEWS)
|
||||
if first_of_plan: # the user's rule: a plan of several steps shows its bases to pick from before it all runs
|
||||
base_only, batch = True, 3
|
||||
elif not base_only and ai_plans.due(task, ai_plans.PICK) and ai_bases.hold_of(task) is None:
|
||||
ai_plans.mark(task, ai_plans.PICK, False) # said once: "go on without one" is the user's to say
|
||||
raise ToolError("Nothing was run: the user picks one of the plan's bases first (their rule). Anomalous shows them "
|
||||
"on that run when it ends: wait with get_answers, then run_workflow. If they say to go on without "
|
||||
"one, call run_workflow again.")
|
||||
if prompts is not None: # several prompts, queued in order as one run, each with the call's other blanks
|
||||
if slots.get("prompt") or not isinstance(prompts, list) or not 1 <= len(prompts) <= MAX_PROMPTS or any(not isinstance(text, str) or not text.strip() for text in prompts):
|
||||
raise ToolError(f"prompts is a list of 1 to {MAX_PROMPTS} prompts, given instead of prompt.")
|
||||
if len(prompts) * batch > MAX_TRIES:
|
||||
raise ToolError(f"{len(prompts)} prompts x batch {batch} is over {MAX_TRIES} tries in one call.")
|
||||
ids, groups, filled, page, stopped = [], [], None, {}, None
|
||||
hold = None if base_only else ai_bases.hold_of(ctx.get("task"))
|
||||
if base_only:
|
||||
ai_bases.forget(ctx.get("task")) # new bases: the earlier pick no longer holds
|
||||
for index, text in enumerate(prompts or [None]):
|
||||
try:
|
||||
done = mcp_fill.fill(ctx, workflow, **(slots if text is None else {**slots, "prompt": text}))
|
||||
except ToolError as error:
|
||||
if not ids:
|
||||
raise
|
||||
stopped = f"Prompt {index + 1} was not filled in: {error}"
|
||||
break
|
||||
filled = filled or done
|
||||
got, page = _submit(batch, base_only, hold)
|
||||
if got is None:
|
||||
if not ids:
|
||||
raise ToolError(_refused(page))
|
||||
stopped = f"Prompt {index + 1} was not queued: {_refused(page)}"
|
||||
break
|
||||
ids += got
|
||||
groups.append({"prompt": index + 1, "prompt_ids": got})
|
||||
if len(got) < batch:
|
||||
stopped = f"Only {len(got)} of {batch} tries of prompt {index + 1} were accepted. {run_refusal.say(page.get('refusal')) or ''}".strip()
|
||||
break
|
||||
requested = batch * len(prompts or [None])
|
||||
answer = {"prompt_ids": ids, "queued": len(ids), "requested": requested,
|
||||
"note": ("If this answer does not say it is done, the user tells you when it is: say it is running and end "
|
||||
"your turn; run_status then brings its pictures." if ai_gates.run_wait() == "call" else
|
||||
"Follow these runs with run_status (wait_seconds up to 55).")
|
||||
+ " Do not repeat the batch; accepted prompts are already queued."}
|
||||
_base_words(answer, page, base_only, hold)
|
||||
if base_only and ids:
|
||||
ai_plans.mark(task, ai_plans.PREVIEWS, False)
|
||||
ai_plans.mark(task, ai_plans.PICK, first_of_plan)
|
||||
elif ids:
|
||||
ai_plans.mark(task, ai_plans.PICK, False)
|
||||
if first_of_plan and ids:
|
||||
answer["plan_note"] = ("Ran the first pass alone, three bases: the user picks one before the whole plan runs (their "
|
||||
"rule for plans). Show them to pick from as said here, then run_workflow again for the rest.")
|
||||
if prompts is not None:
|
||||
answer["by_prompt"] = groups # which receipts are which prompt; the canvas keeps the last prompt
|
||||
if filled:
|
||||
answer["filled"] = filled
|
||||
# Some prompts may have been accepted before validation or a transport failure stopped the batch.
|
||||
if len(ids) < batch:
|
||||
answer["submission_error"] = f"Only {len(ids)} of {batch} prompts were accepted."
|
||||
told = run_refusal.say(page.get("refusal"))
|
||||
if told:
|
||||
answer["submission_error"] += f" {told}"
|
||||
if stopped:
|
||||
answer["submission_error"] = stopped
|
||||
return answer
|
||||
|
||||
|
||||
@@ -257,6 +373,7 @@ def speak(ctx, character, text, seed=None, language="auto"):
|
||||
|
||||
_NODE = {"type": "integer", "description": "A node id."}
|
||||
_MODEL = {"type": "string", "description": "model_id from search_models."}
|
||||
_PROMPT = {"type": "string", "description": "Your own? First get_toolbox(skill=\"prompt_pose\")."}
|
||||
|
||||
|
||||
def _action(name, title, description, run, properties=None, required=(), destructive=False, open_world=False,
|
||||
@@ -278,8 +395,9 @@ ACTIONS = dict([
|
||||
_action("set_prompt", "Write a prompt",
|
||||
"Writes text into the workflow's positive or negative prompt box (or the one on node_id). "
|
||||
"mode=append adds to what is there. One Ctrl+Z step.",
|
||||
set_prompt, {"prompt": {"type": "string"}, "role": {"type": "string", "enum": ["positive", "negative"], "default": "positive"},
|
||||
"node_id": _NODE, "mode": {"type": "string", "enum": ["replace", "append"], "default": "replace"}},
|
||||
set_prompt, {"prompt": _PROMPT, "role": {"type": "string", "enum": ["positive", "negative"], "default": "positive"},
|
||||
"node_id": _NODE, "mode": {"type": "string", "enum": ["replace", "append"], "default": "replace"},
|
||||
"rewrite": {"type": "string", "description": "How the user's local helper changes prompt first."}},
|
||||
["prompt"], destructive=True),
|
||||
_action("set_model", "Swap a node's model",
|
||||
"Puts a model into a loader node (checkpoint, LoRA, VAE…) on the canvas, replacing the one it had. "
|
||||
@@ -319,25 +437,31 @@ ACTIONS = dict([
|
||||
"(default: the user's download settings); name the file name (default: the site's). Never replaces a file.",
|
||||
download_model, {"type": {"type": "string"}, "url": {"type": "string"}, "hash": {"type": "string"},
|
||||
"folder": {"type": "string"}, "name": {"type": "string"}}, ["type"], open_world=True),
|
||||
_action("download_status", "Downloads", "How the downloads of this ComfyUI run are going.", download_status,
|
||||
read_only=True),
|
||||
_action("run_workflow", "Run the workflow",
|
||||
"Queues the workflow (uses the GPU). In the same call: workflow (a saved one) opens in the task's tab "
|
||||
"once and is reused; prompt, negative, seed (every sampler), width/height (the start latent), image "
|
||||
"(its LoadImage) and values (any setting by name: {\"steps\": 30}, or {\"12\": {\"denoise\": 0.5}} when "
|
||||
"several nodes have it) fill it in; the rest stays. Without workflow they fill the open canvas. Then "
|
||||
"run_status, never the output folder.",
|
||||
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1},
|
||||
_action("run_workflow", "Run the workflow, or a job sheet",
|
||||
"Queues the workflow (uses the GPU). workflow (a saved one) opens once in the task's "
|
||||
"tab; prompt, negative, seed (every sampler), width/height (the start latent), image (its LoadImage) and "
|
||||
"values (any setting by name: {\"steps\": 30}; {\"12\": {\"denoise\": 0.5}} for one node) fill it "
|
||||
"in, else the open canvas. Then run_status, never the output folder. Many prompts or settings, or a rest "
|
||||
"between pictures: one call with sheet, Anomalous runs it one by one.",
|
||||
run_workflow, {"batch": {"type": "integer", "minimum": 1, "maximum": 8, "default": 1,
|
||||
"description": "Tries, each its own seed."},
|
||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 55,
|
||||
"description": "Seconds to wait for it to end."},
|
||||
"workflow": {"type": "string", "description": "A name from list_workflows."},
|
||||
"prompt": {"type": "string"}, "negative": {"type": "string"},
|
||||
"description": "This call's wait for the run; a rest between pictures is sheet.pause."},
|
||||
"workflow": {"type": "string", "description": "A name or plan from list_workflows."},
|
||||
"prompt": _PROMPT, "negative": {"type": "string"},
|
||||
"prompts": {"type": "array", "items": {"type": "string"}, "maxItems": MAX_PROMPTS,
|
||||
"description": "Instead of prompt: several, queued in order as one run."},
|
||||
"seed": {"type": "integer", "minimum": 0},
|
||||
"width": {"type": "integer", "minimum": 16, "maximum": 16384},
|
||||
"height": {"type": "integer", "minimum": 16, "maximum": 16384},
|
||||
"image": {"type": "string", "description": "subfolder/name.png, output or input folder."},
|
||||
"image_node": {"type": "integer", "description": "Which LoadImage, when there are several."},
|
||||
"values": {"type": "object", "description": "Other settings by name, any node's."}}),
|
||||
"image_node": {"type": "integer", "description": "Which LoadImage, of several."},
|
||||
"values": {"type": "object", "description": "Other settings by name, any node's."},
|
||||
"base_only": {"type": "boolean", "description": "First pass only: previews to pick a base from."},
|
||||
"sheet": {"type": "object", "description": (
|
||||
"Many runs, run one after another by Anomalous: {rows: [prompt or {prompt, negative, "
|
||||
"values, count}], count: runs of each row (default batch), pause: seconds between "
|
||||
"pictures, same_seeds}. prompt: a row's when it has none.")}}),
|
||||
_action("open_in_anomalous", "Show a model",
|
||||
"Opens Anomalous Model Browser on this model's page, so the user sees its cover and details.",
|
||||
open_in_anomalous, {"model_id": _MODEL}, ["model_id"]),
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
openAmb: ['Open in Anomalous', '在 AMB 里看'], send: ['Send', '发送'], refresh: ['Refresh', '刷新'],
|
||||
picked: ['I picked in the card: ', '我在卡片里选了:'], typed: ['My answer in the card: ', '我在卡片里回答:'],
|
||||
youAnswered: ['You answered: ', '你回答了:'], none: ['No task to show yet.', '还没有任务可看。'],
|
||||
private: ['Privacy mode: the pictures are only shown in Anomalous.', '隐私模式:图只在 AMB 里看。'],
|
||||
};
|
||||
const t = key => words[key][zh ? 1 : 0];
|
||||
const card = document.getElementById('card');
|
||||
@@ -147,6 +148,7 @@
|
||||
}
|
||||
parts.push(pictures);
|
||||
}
|
||||
if (view.private) parts.push(el('div', 'note', t('private')));
|
||||
for (const item of view.answered || []) parts.push(el('div', 'note', `${t('youAnswered')}${item.question ? `${item.question} ` : ''}${item.answer}`));
|
||||
for (const question of view.questions || []) parts.push(questionBox(question));
|
||||
if (view.asking?.length) {
|
||||
|
||||
+7
-5
@@ -8,12 +8,12 @@ them from the AI; an AI that calls them anyway only reads its own task or answer
|
||||
question). Their results go to the card, not the AI. The card never answers a gate:
|
||||
an OK for what an AI may do is only given in Anomalous (ai_gates.py), so a tool the AI could
|
||||
reach can never grant it. The pictures it shows are the gallery's thumbnails, inline (the frame
|
||||
loads nothing from this server).
|
||||
loads nothing from this server); a private task's are not shown (ai_privacy.py).
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import ai_panels, ai_tasks
|
||||
from . import ai_panels, ai_privacy, ai_tasks
|
||||
from .mcp_tools import ToolError, _image_content, _resolve_image, _schema
|
||||
|
||||
EXTENSION = "io.modelcontextprotocol/ui"
|
||||
@@ -75,7 +75,8 @@ def _questions(step):
|
||||
panel = step["panel"]
|
||||
out = []
|
||||
for block in panel["blocks"]:
|
||||
if block["type"] not in ai_panels.INTERACTIVE or block["id"] in step.get("answers", {}) or panel.get("closed"):
|
||||
if (block["type"] not in ai_panels.INTERACTIVE or block["type"] in ("prompts", "plan") # answered in Anomalous only
|
||||
or block["id"] in step.get("answers", {}) or panel.get("closed")):
|
||||
continue
|
||||
options = block.get("options") or [{"id": item["id"], "label": item.get("label") or item["id"]} for item in block.get("items", [])]
|
||||
out.append({"panel": panel["id"], "block": block["id"], "type": block["type"], "question": block.get("question", ""),
|
||||
@@ -92,7 +93,8 @@ def task_view(ctx, panel=None, task=None):
|
||||
if not record:
|
||||
raise ToolError("No task to show.")
|
||||
steps = [step for step in record["steps"] if step.get("kind") != "look"]
|
||||
pictures = [result for step in record["steps"] for result in step.get("results") or [] if result.get("type") == "image"]
|
||||
private = ai_privacy.is_private(record) # its pictures stay in Anomalous
|
||||
pictures = [] if private else [result for step in record["steps"] for result in step.get("results") or [] if result.get("type") == "image"]
|
||||
seen, shown = set(), []
|
||||
for result in reversed(pictures):
|
||||
key = (result.get("subfolder"), result.get("filename"))
|
||||
@@ -107,7 +109,7 @@ def task_view(ctx, panel=None, task=None):
|
||||
"steps": [{"tool": step["tool"], "kind": step["kind"], "state": step.get("state", "done"), "ok": step.get("ok", True),
|
||||
"stage": step.get("stage"), "text": _words(step)}
|
||||
for step in steps[-MAX_STEPS:]],
|
||||
"pictures": list(reversed(shown)),
|
||||
"pictures": list(reversed(shown)), "private": private,
|
||||
"questions": [question for step in record["steps"] if step.get("kind") == "panel" for question in _questions(step)],
|
||||
"answered": [item for step in record["steps"] if step.get("kind") == "panel" for item in _answered(step)][-3:],
|
||||
"asking": [step.get("gate", {}).get("gate") for step in record["steps"] if step.get("state") == "asking"],
|
||||
|
||||
+15
-4
@@ -3,7 +3,9 @@ canvas lives in the browser).
|
||||
|
||||
`ask_page` sends {id, action, args} as the "anomalous.mcp" event over ComfyUI's websocket
|
||||
to every open ComfyUI page and waits. One page claims it (the first claim wins, visible
|
||||
pages claim first: web/modules/mcp_bridge.js), does the action with Anomalous's own canvas
|
||||
pages claim first: web/modules/mcp_bridge.js; while the page that did the last action is open,
|
||||
it gets the next too: it holds the task's tabs, and with ComfyUI open in two browser tabs one
|
||||
must not open a task's tab and the other then not find it), does the action with Anomalous's own canvas
|
||||
code and posts the outcome back; `claim` and `resolve` take those posts
|
||||
(routes in mcp_server.py). With no page open, or none answering in time, the tool fails
|
||||
with words the AI can pass on. Actions on the canvas carry the task's own tab
|
||||
@@ -11,13 +13,16 @@ with words the AI can pass on. Actions on the canvas carry the task's own tab
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
|
||||
TIMEOUT_SECONDS = 30
|
||||
MAX_WAITING = 20
|
||||
HOLD_SECONDS = 3 # how long the page that did the last action has to claim the next before another page may
|
||||
|
||||
_lock = threading.Lock()
|
||||
_waiting = {} # id -> {"event": Event, "claimed": bool, "outcome": dict | None}
|
||||
_last = {"page": None} # the page that did the last action (its own id, web/modules/mcp_bridge.js pageId)
|
||||
|
||||
|
||||
class PageUnavailable(Exception):
|
||||
@@ -44,7 +49,7 @@ def ask_page(action, args=None, timeout=TIMEOUT_SECONDS, required_capability=Non
|
||||
if task_canvas:
|
||||
args = {**(args or {}), "_task_canvas": task_canvas}
|
||||
request_id = uuid.uuid4().hex
|
||||
entry = {"event": threading.Event(), "claimed": False, "outcome": None, "required": required_capability}
|
||||
entry = {"event": threading.Event(), "claimed": False, "outcome": None, "required": required_capability, "sent": time.monotonic()}
|
||||
with _lock:
|
||||
if len(_waiting) >= MAX_WAITING:
|
||||
raise PageUnavailable("Too many canvas actions are waiting; try again in a moment.")
|
||||
@@ -65,8 +70,10 @@ def ask_page(action, args=None, timeout=TIMEOUT_SECONDS, required_capability=Non
|
||||
return outcome.get("value")
|
||||
|
||||
|
||||
def claim(request_id, capabilities=None):
|
||||
"""True for the first page that asks to do request `request_id`."""
|
||||
def claim(request_id, capabilities=None, page=None):
|
||||
"""True for the page that may do request `request_id`: the first that asks, but for HOLD_SECONDS
|
||||
only the page that did the last action (`page`, its id), when it is another: "later" tells
|
||||
another page to ask again then (it is the one left when that page was closed)."""
|
||||
with _lock:
|
||||
entry = _waiting.get(request_id)
|
||||
if entry is None or entry["claimed"] or entry["outcome"] is not None:
|
||||
@@ -75,7 +82,11 @@ def claim(request_id, capabilities=None):
|
||||
entry["outcome"] = {"ok": False, "error": "Refresh the ComfyUI browser page before running from AI. Nothing was queued."}
|
||||
entry["event"].set()
|
||||
return False
|
||||
if page and _last["page"] and page != _last["page"] and time.monotonic() - entry.get("sent", 0) < HOLD_SECONDS:
|
||||
return "later"
|
||||
entry["claimed"] = True
|
||||
if page:
|
||||
_last["page"] = page
|
||||
return True
|
||||
|
||||
|
||||
|
||||
+137
-15
@@ -7,12 +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_tasks, ai_toolbox, mcp_fill
|
||||
from . import ai_gates, ai_pictures, ai_tasks, ai_toolbox, mcp_fill, mcp_plans, mcp_routes, model_advice, model_download, reference_blocks
|
||||
from .mcp_actions import _action, _local, _page
|
||||
from .mcp_tools import ToolError
|
||||
from .media_routes import picture_size
|
||||
from .mcp_tools import ToolError, _resolve_model
|
||||
|
||||
MAX_FOUND = 20
|
||||
MAX_SEARCH = 5
|
||||
@@ -69,10 +71,22 @@ def _picked(ctx):
|
||||
|
||||
|
||||
def _allowed(ctx, types):
|
||||
"""Refuses node types the user's "only" picks leave out, before the page is asked."""
|
||||
if not ctx.get("task"):
|
||||
"""Refuses node types the user's "only" picks leave out, and, while the user lets custom nodes in only
|
||||
by the ways (ai_gates.node_rule), those of no way the task did not pick either, before the page is asked."""
|
||||
ways_only = ai_gates.node_rule() == "ways"
|
||||
if not ctx.get("task") and not ways_only:
|
||||
return
|
||||
info = object_info(ctx)
|
||||
if ways_only:
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) if ctx.get("task") else None
|
||||
ways = reference_blocks.node_types()
|
||||
outside = sorted({name for name in types if name in info and not ai_toolbox.builtin(info[name])
|
||||
and name not in ways and not ai_toolbox.chosen(box, name, info[name])})
|
||||
if outside:
|
||||
raise ToolError(f"The user lets custom nodes in only by the ready-made ways (add_nodes use=…): not {', '.join(outside)}. "
|
||||
"Use a way or ComfyUI's built-in nodes, or ask the user in the AI workbench (show_panel).")
|
||||
if not ctx.get("task"):
|
||||
return
|
||||
refused = ai_toolbox.refused(ctx["task"], types, info)
|
||||
if refused:
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"])) or {}
|
||||
@@ -208,13 +222,110 @@ def _api_nodes(nodes, canvas_refs=False):
|
||||
return nodes
|
||||
|
||||
|
||||
def add_nodes(ctx, nodes, near_node_id=None, replace=False):
|
||||
def _main_base(near_node_id):
|
||||
"""The base model of the model the canvas's sampler uses, as the page reads its loader (model_advice.py)."""
|
||||
line = _page("block_model", {"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")})
|
||||
return model_advice._main_base((line or {}).get("model") or "")
|
||||
|
||||
|
||||
def _base_of(name):
|
||||
"""ControlNet file `name`'s base model, as Anomalous knows it."""
|
||||
try:
|
||||
path = model_advice.folder_paths.get_full_path("controlnet", name)
|
||||
except (KeyError, TypeError):
|
||||
path = None
|
||||
return model_advice.base_of(path) if path else ""
|
||||
|
||||
|
||||
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"))
|
||||
family = model_advice.family(base)
|
||||
if kind not in model_advice.MAIN_TYPES or family not in (("anima",) if loader[0] == "UNETLoader" else ("SDXL", "SD 1.x", "")):
|
||||
raise ToolError(f"{model_id} is no model use picture draws with: an SDXL or SD 1.5 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, route=None,
|
||||
save_node_id=None, plan=None):
|
||||
if plan is not None:
|
||||
return mcp_plans.propose(ctx, plan)
|
||||
if route is not None:
|
||||
if mcp_plans.is_card(ctx, route):
|
||||
return mcp_plans.build_answered(ctx, route)
|
||||
return mcp_routes.build(ctx, str(route), model)
|
||||
if not isinstance(replace, bool):
|
||||
raise ToolError("replace must be true or false.")
|
||||
_api_nodes(nodes, canvas_refs=True)
|
||||
if [nodes is not None, block is not None, use is not None].count(True) != 1:
|
||||
raise ToolError("Give one of: use (from the blocks in begin_task's or get_situation's answer, with want), block (a ready-made one by name) or nodes (a group in API form).")
|
||||
if use is not None:
|
||||
task = ai_tasks.get_task(ctx["task"]) if ctx.get("task") else None
|
||||
try: # the way the user picked for this use, else the use's best block this ComfyUI can run
|
||||
block = reference_blocks.pick(str(use), None if want is None else str(want), object_info(ctx), bool(scene),
|
||||
(ai_toolbox.effective(task) or {}).get("ways") or ())
|
||||
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: 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()
|
||||
except reference_blocks.MissingModel: # a model just downloaded into a subfolder: ComfyUI's lists see it once read anew
|
||||
model_download.forget_file_lists()
|
||||
forget_object_info()
|
||||
built = build()
|
||||
nodes, replace = built["nodes"], True
|
||||
except reference_blocks.BlockError as error:
|
||||
raise ToolError(str(error))
|
||||
else:
|
||||
_api_nodes(nodes, canvas_refs=True)
|
||||
_allowed(ctx, [spec["class_type"] for spec in nodes.values()])
|
||||
return _next(_page("add_nodes", {"nodes": nodes, "replace": replace,
|
||||
"near_node_id": None if near_node_id is None else _id(near_node_id, "near_node_id")}, timeout=60))
|
||||
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"),
|
||||
"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"]:
|
||||
answer["tested"] = built["tested"] # so the AI can say how long it will take
|
||||
return answer
|
||||
|
||||
|
||||
def connect_nodes(ctx, from_node_id, to_node_id, input=None, output=None, replace=None):
|
||||
@@ -254,8 +365,7 @@ def new_workflow(ctx, name="", nodes=None):
|
||||
|
||||
|
||||
def open_workflow(ctx, workflow):
|
||||
filename, name = mcp_fill.saved_workflow(workflow)
|
||||
return _page("open_workflow", {"filename": filename, "name": name}, timeout=60)
|
||||
return _page("open_workflow", mcp_fill.opening(workflow)[0], timeout=60)
|
||||
|
||||
|
||||
_NODE_ID = {"type": "integer", "description": "A node id."}
|
||||
@@ -283,8 +393,19 @@ CANVAS_TOOLS = dict([
|
||||
"\"VAEEncode\", \"inputs\": {\"pixels\": [\"#12\", 0], \"vae\": [\"#4\", \"VAE\"]}}, \"b\": {..., \"inputs\": "
|
||||
"{\"samples\": [\"a\", 0]}, \"wire_to\": 15}}. A link names a key of the group or a canvas node \"#id\", and "
|
||||
"an output slot or name; wire_to as in add_node. Checked first; one Ctrl+Z step; the answer maps keys "
|
||||
"to node ids.",
|
||||
add_nodes, {"nodes": {"type": "object"}, "near_node_id": _NODE_ID, "replace": {"type": "boolean"}}, ["nodes"]),
|
||||
"to node ids. Or use (and want): a ready-made block for that use (begin_task lists them), the best this "
|
||||
"ComfyUI can run, spliced into the canvas's sampler by itself; near_node_id picks the sampler when there are several. "
|
||||
"plan: a whole picture in big steps, built on a new tab once the user agrees on its card.",
|
||||
add_nodes, {"nodes": {"type": "object"}, "use": {"type": "string", "enum": list(reference_blocks.JOBS)},
|
||||
"want": {"type": "string", "description": "Kind from begin_task (best, 2x, pose…)."},
|
||||
"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"},
|
||||
"model": {"type": "string", "description": "use=picture: a model_id, else the task's picked one."},
|
||||
"route": {"type": "string", "description": "The route ask_user(routes=true) got, or a plan card's id the user said build to: built whole on a new tab."},
|
||||
"plan": {"type": "array", "maxItems": 6, "items": {"type": "object"},
|
||||
"description": "Steps, the picture first: [{use: 'picture', model}, {use: 'refine', model}, {use: 'detail'}, {use: 'upscale'}]; use and want as above."}}),
|
||||
_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 "
|
||||
@@ -308,7 +429,8 @@ CANVAS_TOOLS = dict([
|
||||
"or [node id, output slot]). Checked before it opens; the answer maps your ids to node ids.",
|
||||
new_workflow, {"name": {"type": "string"}, "nodes": {"type": "object"}}),
|
||||
_action("open_workflow", "Open a saved workflow",
|
||||
"Opens one of the user's saved workflows (list_workflows names them) in a new tab, leaving the open one "
|
||||
"as it was, to run it or build on it with add_node, connect_nodes and set_values.",
|
||||
open_workflow, {"workflow": {"type": "string", "description": "A workflow name from list_workflows."}}, ["workflow"]),
|
||||
"Opens a saved workflow or a gallery picture's own in a new tab, the open one kept, "
|
||||
"to run it or build on it with add_node, connect_nodes and set_values.",
|
||||
open_workflow, {"workflow": {"type": "string", "description": "A list_workflows name or a search_images picture."}},
|
||||
["workflow"]),
|
||||
])
|
||||
|
||||
@@ -17,7 +17,7 @@ import json
|
||||
import os
|
||||
import time
|
||||
|
||||
from . import action_catalog, ai_tasks
|
||||
from . import action_catalog, ai_tasks, mcp_fit
|
||||
from .mcp_tools import retext
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
@@ -33,12 +33,12 @@ STALE_NOTE = ("Your app holds an older list of Anomalous's tools (Anomalous was
|
||||
_lists = None # app -> fingerprint of the tool list it fetched last
|
||||
|
||||
|
||||
def directory(specs):
|
||||
"""{heading: "tool (title), ..."} for the tools of `specs` {name: spec}."""
|
||||
def directory(specs, titles=True):
|
||||
"""{heading: "tool (title), ..."} for the tools of `specs` {name: spec}; the names alone without `titles`."""
|
||||
sections = {}
|
||||
for name, entry in action_catalog.TOOLS.items():
|
||||
if name in specs:
|
||||
sections.setdefault(entry.stage, []).append(f"{name} ({specs[name].get('title') or name})")
|
||||
sections.setdefault(entry.stage, []).append(f"{name} ({specs[name].get('title') or name})" if titles else name)
|
||||
return {"note": NOTE, **{heading: ", ".join(sections[stage]) for stage, heading in SECTIONS if stage in sections}}
|
||||
|
||||
|
||||
@@ -47,7 +47,10 @@ def attach(result, name, specs):
|
||||
data = result.get("structuredContent") if isinstance(result, dict) and not result.get("isError") else None
|
||||
if not isinstance(data, dict) or not (name == "begin_task" or (name == "get_situation" and not data.get("task"))):
|
||||
return result
|
||||
retext(result, {**data, "directory": directory(specs)})
|
||||
answer = {**data, "directory": directory(specs)}
|
||||
if mcp_fit.size(answer) > mcp_fit.BUDGET: # a long message of the user's with it: the tools by name, to stay readable
|
||||
answer["directory"] = directory(specs, titles=False)
|
||||
retext(result, answer)
|
||||
return result
|
||||
|
||||
|
||||
|
||||
+58
-17
@@ -1,7 +1,9 @@
|
||||
"""run_workflow's blanks: a daily run in one call, so a small model need not read and edit the canvas.
|
||||
|
||||
With `workflow` (a saved workflow's name) the run opens it in the task's tab the first time and
|
||||
reuses that tab after (the page marks a tab open_workflow opened with the workflow's file); what
|
||||
With `workflow` (a saved workflow's name, or one of its 方案 as "workflow / plan": a parameter set saved
|
||||
under it, applied as its Apply button does, or a gallery picture, whose own workflow is opened: `opening`)
|
||||
the run opens it in the task's tab the first time and reuses that tab after (the page marks a tab
|
||||
open_workflow opened with the workflow's file, or "picture:" and the picture); what
|
||||
the AI fills in goes into it first (web/modules/mcp_canvas_edit.js fill_slots): the positive and
|
||||
negative prompt boxes, the seed of every sampler, the empty latent's width and height, the
|
||||
picture its LoadImage reads (from the input or the output folder, found here, never copied;
|
||||
@@ -12,9 +14,13 @@ do; set_values takes the same). No fixed list of blanks grows here. The rest sta
|
||||
changes, and it is one Ctrl+Z step logged as the AI's.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from . import ai_task_canvas, ai_tasks
|
||||
from .ai_pictures import to_input
|
||||
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
|
||||
from .mcp_tools import ToolError, _resolve_folder_image, list_workflows
|
||||
from .mcp_tools import IMAGE_TYPES, ToolError, _resolve_folder_image, _resolve_image, plans
|
||||
from .recipe_images import _embedded_workflow_payload
|
||||
|
||||
SLOTS = ("prompt", "negative", "seed", "width", "height", "image", "image_node", "values")
|
||||
MAX_VALUES = 60
|
||||
@@ -49,26 +55,62 @@ def node_values(values):
|
||||
|
||||
|
||||
def saved_workflow(name):
|
||||
"""(file name, name) of the user's saved workflow called `name`."""
|
||||
"""(file name, name, plan) of the user's saved workflow called `name`, or of its 方案 "workflow / plan"
|
||||
(plan: the parameter set's file name, else None), or of the example called that."""
|
||||
from . import example_workflows
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
|
||||
name = str(name or "").strip()
|
||||
if not name or not any(item["name"] == name for item in list_workflows(name, 50)["workflows"]):
|
||||
raise ToolError(f"No saved workflow called {name}; list_workflows names them.")
|
||||
filename = next((recipe["filename"] for recipe in _list_recipes(get_recipes_dir())
|
||||
if (recipe["data"].get("name") or recipe["filename"]) == name), None)
|
||||
return filename, name
|
||||
files = {}
|
||||
for recipe in _list_recipes(get_recipes_dir()):
|
||||
files.setdefault(recipe["data"].get("name") or recipe["filename"], recipe["filename"])
|
||||
if name and name in files:
|
||||
return files[name], name, None
|
||||
example = example_workflows.file_of(name)
|
||||
if example:
|
||||
return example, name, None
|
||||
workflow, _, plan = name.rpartition(" / ")
|
||||
found = next((file for file, plan_name in plans().get(files.get(workflow), []) if plan_name == plan), None)
|
||||
if not found:
|
||||
raise ToolError(f"No saved workflow or plan called {name}; list_workflows names them.")
|
||||
return files[workflow], name, found
|
||||
|
||||
|
||||
def picture_workflow(name):
|
||||
"""{subfolder, filename} of gallery picture `name` (in the output folder) when the name is a
|
||||
picture's and it carries a workflow; None when the name is no picture's."""
|
||||
if os.path.splitext(str(name or "").strip())[1].lower() not in IMAGE_TYPES:
|
||||
return None
|
||||
rel, path = _resolve_image(str(name).strip())
|
||||
if not (_embedded_workflow_payload(path) or {}).get("workflow"):
|
||||
raise ToolError(f"{rel} carries no workflow to open: only the pictures ComfyUI saves with one do.")
|
||||
subfolder, _, filename = rel.rpartition("/")
|
||||
return {"subfolder": subfolder, "filename": filename}
|
||||
|
||||
|
||||
def opening(name):
|
||||
"""(what the page's open_workflow takes, the mark it leaves on the tab) for `name`: a saved workflow,
|
||||
one of its plans or an example (saved_workflow), else a gallery picture's own workflow."""
|
||||
try:
|
||||
filename, shown, plan = saved_workflow(name)
|
||||
except ToolError:
|
||||
picture = picture_workflow(name) # not a saved one: a picture's own, to change what made it
|
||||
if not picture:
|
||||
raise
|
||||
rel = "/".join(part for part in (picture["subfolder"], picture["filename"]) if part)
|
||||
return {"picture": picture, "name": os.path.splitext(picture["filename"])[0]}, f"picture:{rel}"
|
||||
return {"filename": filename, "name": shown, "plan": plan}, (f"{filename}#{plan}" if plan else filename)
|
||||
|
||||
|
||||
def _picture(image):
|
||||
"""The value a LoadImage takes for picture `image`: in the input folder as it is, from the
|
||||
output folder with ComfyUI's "[output]" mark (it reads it from there)."""
|
||||
output folder its copy there (ai_pictures.to_input)."""
|
||||
for folder in ("input", "output"):
|
||||
try:
|
||||
rel, _path = _resolve_folder_image(image, folder)
|
||||
except ToolError:
|
||||
continue
|
||||
return rel if folder == "input" else f"{rel} [output]"
|
||||
return rel if folder == "input" else to_input(rel)
|
||||
raise ToolError(f"{image} is in neither ComfyUI's input nor its output folder; search_images lists the pictures in the output.")
|
||||
|
||||
|
||||
@@ -88,15 +130,14 @@ def fill(ctx, workflow=None, **slots):
|
||||
slots["image"] = _picture(slots["image"])
|
||||
if "values" in slots:
|
||||
node_values(slots["values"])
|
||||
filename = name = None
|
||||
if workflow:
|
||||
filename, name = saved_workflow(workflow)
|
||||
args, opened = opening(workflow) if workflow else ({}, None) # opened: what the page marks the tab with
|
||||
name = args.get("name")
|
||||
task = ai_tasks.get_task(ctx.get("task")) if ctx.get("task") else None
|
||||
no_tab_yet = bool(task) and ai_task_canvas.mode_of(task) == "new" and not (task.get("canvas") or {}).get("tab")
|
||||
filled = None if workflow and no_tab_yet else _page("fill_slots", {"filename": filename, **slots})
|
||||
filled = None if workflow and no_tab_yet else _page("fill_slots", {"filename": opened, **slots})
|
||||
if workflow and (filled is None or filled.get("needs_open")):
|
||||
_page("open_workflow", {"filename": filename, "name": name}, timeout=60)
|
||||
filled = _page("fill_slots", {"filename": filename, **slots})
|
||||
_page("open_workflow", args, timeout=60)
|
||||
filled = _page("fill_slots", {"filename": opened, **slots})
|
||||
if filled.get("needs_open"):
|
||||
raise ToolError(f"The saved workflow {name} did not open; nothing was run.")
|
||||
return {**({"workflow": name} if name else {}), **(filled.get("set") or {})}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Answers an AI app reads in the chat. Past about 4,000 characters Antigravity (and apps like it) keeps
|
||||
a tool's answer as a file for the AI to open, and an AI told to use only Anomalous's tools never
|
||||
opens it: it sees nothing and asks again with smaller limits (the 1.63 exam: search_models eight
|
||||
times for one LoRA). An answer that is one list and longer than BUDGET keeps the items that fit, in
|
||||
their order, and says how many it left out, by name when its items have one, and how to reach
|
||||
them. Tools that fit their answers themselves (get_situation, search_models, list_workflows) are
|
||||
under it already; this is the net under the rest (list_combos, recent_activity and any list to
|
||||
come). Nothing is shortened inside an item: a prompt cut in half could be put in as it is.
|
||||
mcp_server.py runs it after the step is recorded, so the timeline keeps the whole answer."""
|
||||
|
||||
from .mcp_tools import retext, tool_text
|
||||
|
||||
# UTF-8 bytes. Antigravity's logs show answers filed from 4,099 bytes (3,627 characters, some of them
|
||||
# Chinese at three bytes each) while longer ones were shown at other times: stay under the lowest.
|
||||
BUDGET = 3800
|
||||
NAME = 60 # characters of a left-out item's name
|
||||
|
||||
|
||||
def size(data):
|
||||
return len(tool_text(data).encode("utf-8"))
|
||||
# A canvas cut short would read as a smaller canvas: get_situation gives every node in brief.
|
||||
WHOLE = {"describe_canvas"}
|
||||
|
||||
|
||||
def _the_list(data):
|
||||
"""The key of the one list of items in `data`, or None when there is none or more than one."""
|
||||
keys = [key for key, value in data.items() if isinstance(value, list) and value and all(isinstance(item, dict) for item in value)]
|
||||
return keys[0] if len(keys) == 1 else None
|
||||
|
||||
|
||||
def fit(result, name):
|
||||
"""`result` (an MCP tools/call result) with its one list cut to BUDGET, when it is longer."""
|
||||
data = result.get("structuredContent") if isinstance(result, dict) else None
|
||||
if name in WHOLE or result.get("isError") or not isinstance(data, dict) or size(data) <= BUDGET:
|
||||
return result
|
||||
key = _the_list(data)
|
||||
if not key:
|
||||
return result
|
||||
items = list(data[key])
|
||||
kept = {**data, key: items}
|
||||
while size(kept) > BUDGET and len(items) > 1:
|
||||
items.pop()
|
||||
left = data[key][len(items):]
|
||||
named = [str(item["name"])[:NAME] for item in left if item.get("name")]
|
||||
kept["left_out"] = (f"{len(left)} more {key} left out to keep this answer readable in the chat"
|
||||
+ (" (more_names)" if named else "") + ": a narrower query, a smaller limit or the next page lists them")
|
||||
if named:
|
||||
kept["more_names"] = named # what there is, to ask for by name
|
||||
if len(items) < len(data[key]):
|
||||
retext(result, kept)
|
||||
return result
|
||||
@@ -0,0 +1,167 @@
|
||||
"""read_guide: Anomalous's own manual for the AI, a topic at a time, so the tool list carries the
|
||||
names and the details come when they are needed. The topics are what is kept for people already,
|
||||
in English or Chinese, so the two never drift apart: the README between `<!-- guide:name -->`
|
||||
marks (both languages carry the same names), Model Check's rules (docs/guides/model-check-rules*.md,
|
||||
its sections), and the panel blocks' fields for show_panel and update_panel. A topic longer than
|
||||
one answer (mcp_fit.BUDGET) comes in parts, cut between paragraphs. Read from disk at each call:
|
||||
the files are small and the AI asks rarely."""
|
||||
|
||||
import os
|
||||
import re
|
||||
|
||||
from . import mcp_fit
|
||||
from .mcp_tools import ToolError, _tool
|
||||
|
||||
PLUGIN = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
README = os.path.join(PLUGIN, "README.md")
|
||||
RULES = {"en": os.path.join(PLUGIN, "docs", "guides", "model-check-rules.en.md"),
|
||||
"zh": os.path.join(PLUGIN, "docs", "guides", "model-check-rules.md")}
|
||||
HALVES = {"en": ('<h2 id="english">', '<h2 id="中文">'), "zh": ('<h2 id="中文">', "### 🧪")}
|
||||
MARK = re.compile(r"^<!-- guide:([a-z_]+) -->$")
|
||||
ENDS = ("### ", "<h2", "<details", "</details>") # a marked part of the README ends there, or at the next mark
|
||||
PART_BYTES = mcp_fit.BUDGET - 600 # room for the answer's other fields
|
||||
TITLES = { # the list's words for each topic; a README mark without one is listed by its first line
|
||||
"idea": "What Anomalous is for", "ai_workbench": "The AI workbench: how it works with you",
|
||||
"names": "Scanning: real names, covers and trigger words", "model_check": "Model Check: missing models put back",
|
||||
"workflows": "Workflow cards, their stages and presets", "saves": "Canvas saves (存档)",
|
||||
"job_sheet": "Job sheet (任务单): many prompts or settings, a rest between pictures",
|
||||
"picture_workflow": "A picture's own workflow, from the gallery", "combos": "Combos: model, LoRAs and prompt together",
|
||||
"prompts": "Prompt Studio", "voices": "Character voices (Anomalous_TTS)", "undo": "The activity log and undo",
|
||||
"light": "What it takes from the computer", "install": "Installing, and finding it after (the cube button)",
|
||||
"pages": "Every page and tool in detail", "connect_ai": "Connecting an AI app", "open": "Opening, shortcuts, updating",
|
||||
"network": "Network, cloud servers and backups", "model_check_rules": "Model Check's full rules",
|
||||
"panel": "show_panel's blocks and update_panel",
|
||||
}
|
||||
|
||||
PANEL = """show_panel's blocks, top to bottom, each {type, ...}:
|
||||
- heading {text}; text {text, tone: normal|muted|tip|warning}.
|
||||
- images {items: [{image: 'subfolder/name.png' from search_images, caption, marks: [{x, y, r | w, h, label, color: red|green|blue|yellow|purple}]}], layout: row|grid|compare}. marks are circles or boxes with a label; x/y/w/h/r are fractions 0-1 of the picture, x/y the centre; a click on the label zooms there.
|
||||
- slider {before: {image}, after: {image}, before_label, after_label}: one picture over the other, a line to drag.
|
||||
- picks {id, question, items: [{id, image, label, reason, recommended}] (2-12), multiple}: pictures to choose from; the user can also put one's workflow on the canvas.
|
||||
- slider and picks: zoom {x, y, w, h} shows only that part, enlarged.
|
||||
- diagram {nodes: [{id, label, detail, node_id, color}], edges: [{from, to, label}], direction: right|down}: boxes and arrows Anomalous lays out; node_id is a canvas node it finds.
|
||||
- models {items: [{model_id from search_models, note}]}: cards with their covers, a click opens them.
|
||||
- table {columns: [...], rows: [[...]]}; list {items: [...], ordered}; params {items: [{label, value}]}.
|
||||
- choices {id, question, options: [{id, label, detail}] (2-8), multiple}: buttons the user presses.
|
||||
- input {id, question, placeholder}: a line the user types.
|
||||
Each block may have a stable id; show_panel returns the block ids in display order. Plain text only (no markup, no links); up to 30 blocks, 12 pictures or models a block.
|
||||
|
||||
update_panel adds new output at the bottom of a panel and keeps what the user saw, in its order: send only the new blocks (repeated unchanged blocks are kept once). To correct a block or a question in place, send it with its returned id and the same type; blocks left out stay. Never reuse an old block id for a new result: append it, or show_panel for the next phase. Up to 30 blocks a panel; closed=true stops answers, answers given stay."""
|
||||
|
||||
|
||||
def _read(path):
|
||||
try:
|
||||
with open(path, encoding="utf-8") as source:
|
||||
return source.read()
|
||||
except OSError:
|
||||
return ""
|
||||
|
||||
|
||||
def _plain(lines):
|
||||
"""Text people read, without the README's pictures and badges, blank runs folded."""
|
||||
kept = [line.rstrip() for line in lines if not line.lstrip().startswith(("<p align", "<br", "<img"))]
|
||||
return re.sub(r"\n{3,}", "\n\n", "\n".join(kept)).strip()
|
||||
|
||||
|
||||
def _title(text):
|
||||
first = next((line for line in text.splitlines() if line.strip()), "")
|
||||
return re.sub(r"[*#`|]", "", first).strip()[:90]
|
||||
|
||||
|
||||
def _readme(lang):
|
||||
"""{name: text} of the README's marked parts in that language."""
|
||||
start, end = HALVES[lang]
|
||||
text = _read(README)
|
||||
half = text.split(start, 1)[-1].split(end, 1)[0] if start in text else ""
|
||||
parts, name, lines = {}, None, []
|
||||
for line in half.splitlines():
|
||||
mark = MARK.match(line.strip())
|
||||
if mark or (name and line.startswith(ENDS)):
|
||||
if name:
|
||||
parts[name] = _plain(lines)
|
||||
name, lines = (mark.group(1), []) if mark else (None, [])
|
||||
continue
|
||||
if name:
|
||||
lines.append(line)
|
||||
if name:
|
||||
parts[name] = _plain(lines)
|
||||
return parts
|
||||
|
||||
|
||||
def _rules(lang):
|
||||
"""Model Check's rules as people read them, without the section for those who maintain the code."""
|
||||
sections = re.split(r"\n(?=## )", _read(RULES[lang]))
|
||||
return _plain("\n".join(section for section in sections if "(for the AI" not in section and "给维护" not in section).splitlines())
|
||||
|
||||
|
||||
def _pieces(text):
|
||||
"""Paragraphs, a heading kept with the paragraph under it."""
|
||||
pieces = []
|
||||
for paragraph in text.split("\n\n"):
|
||||
if pieces and pieces[-1].startswith("#") and "\n" not in pieces[-1]:
|
||||
pieces[-1] += "\n\n" + paragraph
|
||||
else:
|
||||
pieces.append(paragraph)
|
||||
return pieces
|
||||
|
||||
|
||||
def _parts(text):
|
||||
"""`text` cut between paragraphs (or lines) into parts that fit an answer."""
|
||||
size = lambda value: len(value.encode("utf-8"))
|
||||
pieces = []
|
||||
for piece in _pieces(text):
|
||||
pieces.extend(piece.splitlines() if size(piece) > PART_BYTES else [piece])
|
||||
parts = [""]
|
||||
for piece in pieces:
|
||||
joined = f"{parts[-1]}\n\n{piece}" if parts[-1] else piece
|
||||
if size(joined) > PART_BYTES and parts[-1]:
|
||||
parts.append(piece)
|
||||
else:
|
||||
parts[-1] = joined
|
||||
return parts
|
||||
|
||||
|
||||
def topics(lang):
|
||||
"""{name: text} of every topic in that language, the README's in its order first."""
|
||||
found = _readme(lang)
|
||||
found["model_check_rules"] = _rules(lang)
|
||||
found["panel"] = PANEL
|
||||
return found
|
||||
|
||||
|
||||
def _is_tool(name):
|
||||
from .mcp_server import TOOLS # here, not at the top: the server imports this file
|
||||
return name in TOOLS
|
||||
|
||||
|
||||
def read_guide(topic="", lang="en", part=1):
|
||||
lang = "zh" if str(lang or "").lower().startswith("zh") else "en"
|
||||
found = topics(lang)
|
||||
if not topic:
|
||||
return {"topics": [{"topic": name, "title": TITLES.get(name) or _title(text)} for name, text in found.items()],
|
||||
"note": "read_guide(topic=...) gives one. With lang='zh' the README's parts are in Chinese, with the "
|
||||
"words the Chinese window shows: use them when you guide a user who writes Chinese."}
|
||||
if topic not in found:
|
||||
tool = " It is a tool: its description and its parameters' say how." if _is_tool(topic) else ""
|
||||
raise ToolError(f"No topic {topic!r}.{tool} The topics: {', '.join(found)}.")
|
||||
parts = _parts(found[topic])
|
||||
try:
|
||||
part = min(max(int(part), 1), len(parts))
|
||||
except (TypeError, ValueError):
|
||||
part = 1
|
||||
answer = {"topic": topic, "text": parts[part - 1]}
|
||||
if len(parts) > 1:
|
||||
answer.update(part=part, parts=len(parts))
|
||||
if part < len(parts):
|
||||
answer["more"] = f"read_guide(topic={topic!r}, part={part + 1}) goes on."
|
||||
return answer
|
||||
|
||||
|
||||
GUIDE_TOOLS = dict([
|
||||
_tool("read_guide", "Read Anomalous's guide",
|
||||
"Anomalous's own manual, a topic at a time: how its pages and features work, to guide the user with the "
|
||||
"words on screen (lang='zh': the Chinese ones), Model Check's rules, and the fields of show_panel's blocks "
|
||||
"(topic 'panel'). No topic: the list. A long topic comes in parts.",
|
||||
read_guide, {"topic": {"type": "string"}, "lang": {"type": "string", "enum": ["en", "zh"], "default": "en"},
|
||||
"part": {"type": "integer", "minimum": 1, "default": 1}}),
|
||||
])
|
||||
+2
-1
@@ -75,7 +75,8 @@ LOOK_TOOLS = dict([
|
||||
_tool("zoom_image", "Look closer at a picture",
|
||||
"Part of a picture larger, at full detail (a hand, a face, a seam). folder: output, input or temp. "
|
||||
"compare_with: a second picture, the same region beside it. Use it instead of cropping with code; "
|
||||
"change pictures only by running a workflow, never by editing their files.",
|
||||
"change pictures only by running a workflow, never by editing their files. A private task: the "
|
||||
"user's local helper's checks instead (local_check).",
|
||||
zoom_image, {"image": {"type": "string", "description": "As the other tools name it."},
|
||||
"region": _REGION, "folder": _FOLDER,
|
||||
"compare_with": {"type": "string", "description": "A second picture."},
|
||||
|
||||
+50
-6
@@ -11,13 +11,14 @@ import threading
|
||||
|
||||
import folder_paths
|
||||
|
||||
from . import mcp_canvas_edit, mcp_tools, model_advice, model_placement
|
||||
from . import mcp_canvas_edit, mcp_fit, mcp_tools, model_advice, model_guide, model_placement
|
||||
from .mcp_actions import _action
|
||||
from .mcp_tools import _LIMIT, _QUERY, ToolError
|
||||
from .model_constants import MODEL_EXTENSIONS
|
||||
from .model_import import Refused, import_types
|
||||
|
||||
MAX_LOADERS = 6
|
||||
SEARCH_WORDS = 4 # trigger words a found model shows; get_model has them all
|
||||
|
||||
_lock = threading.Lock()
|
||||
_index = {"of": None, "inputs": [], "by_choice": {}}
|
||||
@@ -96,11 +97,47 @@ def _about(ctx, model):
|
||||
return model
|
||||
|
||||
|
||||
def _compact(found):
|
||||
"""search_models' answer small enough to be read (mcp_fit.BUDGET): what a model_id says already
|
||||
(file, folder, type) left out, loaded_by once for a type when its models share it, a few trigger
|
||||
words, and as many models as fit, the rest by name (more_names), so one answer shows all there is
|
||||
and the AI need not search again to see the rest."""
|
||||
models, kinds = found["models"], {}
|
||||
for model in models:
|
||||
kinds.setdefault(model["type"], []).append(model)
|
||||
shared = {kind: group[0]["loaded_by"] for kind, group in kinds.items()
|
||||
if all("loaded_by" in model and model["loaded_by"] == group[0]["loaded_by"] for model in group)}
|
||||
for model in models:
|
||||
for key in ("file", "folder"):
|
||||
model.pop(key, None)
|
||||
if model.pop("type") in shared:
|
||||
model.pop("loaded_by")
|
||||
words = model.get("trigger_words") or []
|
||||
if len(words) > SEARCH_WORDS:
|
||||
model.update(trigger_words=words[:SEARCH_WORDS], more_trigger_words=len(words) - SEARCH_WORDS)
|
||||
answer = {"total_matches": found["total_matches"], **({"loaded_by": shared} if shared else {}), "models": models}
|
||||
everything = list(models)
|
||||
while mcp_fit.size(answer) > mcp_fit.BUDGET and len(models) > 1:
|
||||
models.pop()
|
||||
answer["more_names"] = [str(model.get("name") or model["model_id"])[:mcp_fit.NAME] for model in everything[len(models):]]
|
||||
answer["left_out"] = (f"{len(answer['more_names'])} more, named in more_names to keep this answer readable: "
|
||||
"get_model or a query with the name gives one in full")
|
||||
return answer
|
||||
|
||||
|
||||
def search_models(ctx, query="", type="", base_model="", source="", limit=20):
|
||||
found = mcp_tools.search_models(query, type, base_model, source, limit)
|
||||
for model in found["models"]:
|
||||
_about(ctx, model)
|
||||
return found
|
||||
if model["type"] == "loras": # its author's weight and its header's words: picking one needs no get_model each
|
||||
path = mcp_tools._resolve_model(model["model_id"])[3]
|
||||
meta = mcp_tools.get_metadata(path) or {}
|
||||
weight = model_advice.author_examples(path, "loras", meta.get("hash", "")).get("lora_weight")
|
||||
if weight:
|
||||
model["author_weight"] = weight
|
||||
if not model.get("trigger_words") and meta.get("headerWords"):
|
||||
model["header_words"] = meta["headerWords"][:SEARCH_WORDS]
|
||||
return _compact(found)
|
||||
|
||||
|
||||
def get_model(ctx, model_id, include_cover=False):
|
||||
@@ -109,6 +146,13 @@ def get_model(ctx, model_id, include_cover=False):
|
||||
examples = model_advice.author_examples(path, folder_type, detail.get("sha256", ""))
|
||||
if examples:
|
||||
detail["author_examples"] = examples
|
||||
meta = mcp_tools.get_metadata(path) or {}
|
||||
guessed = model_guide.words(path, folder_type, meta)
|
||||
if guessed:
|
||||
detail["header_words"] = guessed
|
||||
use = model_guide.usage(folder_type, detail.get("base_model") or meta.get("base_guess") or "")
|
||||
if use:
|
||||
detail["usage"] = use
|
||||
return _about(ctx, detail), extra
|
||||
|
||||
|
||||
@@ -154,10 +198,10 @@ MODEL_TOOLS = dict([
|
||||
"description": "Where the model's information came from."},
|
||||
"limit": _LIMIT}, read_only=True),
|
||||
_action("get_model", "Model details",
|
||||
"Everything known about one model: Civitai name, version and link, trigger words, base model, description, "
|
||||
"the user's own name and notes, author_examples (the sampler, steps, CFG, size and, for a LoRA, the weight "
|
||||
"its author's example pictures used: a good start), and loaded_by (or belongs_in and placement) as in "
|
||||
"search_models. include_cover attaches its cover picture.",
|
||||
"Everything known about one model: Civitai name, version and link, trigger words (or header_words, guessed "
|
||||
"from its training tags), base model, description, the user's notes, author_examples (settings its "
|
||||
"author's pictures used: a good start), usage (files it needs, notes on its family), and loaded_by (or "
|
||||
"belongs_in and placement) as in search_models. include_cover attaches its cover.",
|
||||
get_model, {"model_id": {"type": "string", "description": "From search_models."},
|
||||
"include_cover": {"type": "boolean", "default": False}}, ["model_id"], read_only=True),
|
||||
_action("move_model", "Move a model to its folder",
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
"""add_nodes(plan=…): the big steps an AI lays out for a picture (make it, redraw it with another model, fix
|
||||
faces, enlarge), each a ready-made block (reference_blocks.py), shown to the user on a plan card
|
||||
(ai_plans.py) and built in one go on a new tab (block_chain.py) when they say build: the AI wires nothing
|
||||
and the user sees the route before it is built. A step is {use, want} as for add_nodes, or {block}, and
|
||||
may name a model (the first step's drawing model, else the task's picked one; a later step's for its
|
||||
block's own loader). With "build without asking" on, the card shows what was built at once. A card
|
||||
answered after the AI stopped waiting is built by add_nodes(route=<its panel id>)."""
|
||||
|
||||
from . import ai_answer_wait, ai_panels, ai_plans, ai_tasks, ai_toolbox, block_chain, model_advice, reference_blocks
|
||||
from .mcp_routes import NEXT, ONE_PASS
|
||||
from .mcp_tools import ToolError, _resolve_model, get_metadata, search_models
|
||||
|
||||
MAX_STEPS = 6
|
||||
MAX_CHOICES = 12 # models of the same family offered for a step
|
||||
|
||||
|
||||
def _model(model_id):
|
||||
"""(name, folder type, base model) of `model_id`; ToolError when it is gone."""
|
||||
folder_type, _root, _base, path = _resolve_model(str(model_id))
|
||||
meta = get_metadata(path) or {}
|
||||
name = meta.get("custom_name") or meta.get("name") or path.replace("\\", "/").rsplit("/", 1)[-1]
|
||||
return name, folder_type, model_advice.base_of(path)
|
||||
|
||||
|
||||
def _choices(model_id, folder_type, base):
|
||||
"""Other models of the folder and family of `model_id`, for the user to swap in."""
|
||||
found = search_models("", folder_type, "", "", 50)["models"]
|
||||
return [{"model_id": item["model_id"], "name": item["name"]} for item in found if item["model_id"] != model_id
|
||||
and model_advice.family(item.get("base_model")) == model_advice.family(base)][:MAX_CHOICES]
|
||||
|
||||
|
||||
def _task_id(ctx):
|
||||
return ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
|
||||
|
||||
|
||||
def _model_id(value, at):
|
||||
"""`value` as a model_id: one already, else the one main model whose file or name it is."""
|
||||
value = str(value)
|
||||
if value.count(":") >= 2:
|
||||
return value
|
||||
wanted = value.strip().lower()
|
||||
found = [item for item in search_models(value, "", "", "", 50)["models"] if item["model_id"].split(":", 1)[0] in model_advice.MAIN_TYPES
|
||||
and wanted in (item["name"].lower(), item["model_id"].rsplit("/", 1)[-1].rsplit(".", 1)[0].lower())]
|
||||
if len(found) != 1:
|
||||
raise ToolError(f"Step {at + 1}: model is a model_id from search_models" + (
|
||||
f"; {value!r} is the name of {len(found)}: " + ", ".join(item["model_id"] for item in found[:5]) if found else "."))
|
||||
return found[0]["model_id"]
|
||||
|
||||
|
||||
def _box(ctx):
|
||||
task_id = _task_id(ctx)
|
||||
return ai_toolbox.effective(ai_tasks.get_task(task_id) if task_id else None) or {}
|
||||
|
||||
|
||||
def resolve(ctx, plan):
|
||||
"""The card's steps for the AI's `plan`: each its block (the best of its use this ComfyUI can run, or the
|
||||
way the user picked), title, model and the models to swap in. ToolError for a plan that cannot be built."""
|
||||
from .mcp_canvas_edit import object_info
|
||||
|
||||
if not isinstance(plan, list) or not 1 < len(plan) <= MAX_STEPS:
|
||||
raise ToolError(f"plan is 2 to {MAX_STEPS} steps, the picture first: [{{use: 'picture', model: …}}, {{use: 'refine', model: …}}, …]; "
|
||||
"the picture alone is add_nodes(use='picture', model=…).")
|
||||
info, ways, steps = object_info(ctx), _box(ctx).get("ways") or (), []
|
||||
for at, step in enumerate(plan):
|
||||
step = step if isinstance(step, dict) else {}
|
||||
try:
|
||||
name = str(step["block"]) if step.get("block") in reference_blocks.BLOCKS else \
|
||||
reference_blocks.pick(str(step.get("use")), None if step.get("want") is None else str(step["want"]), info, False, ways)
|
||||
except (reference_blocks.BlockError, KeyError) as error:
|
||||
raise ToolError(f"Step {at + 1}: {error}")
|
||||
if reference_blocks.BLOCKS[name].get("takes") in ("picture", "reference") and not step.get("image") and step.get("use"):
|
||||
# one for a picture the user hands over (fix_hands): in a plan the step before gives the picture
|
||||
chained = reference_blocks.pick(str(step["use"]), None, info, False, ways)
|
||||
if reference_blocks.BLOCKS[chained].get("takes") in ("picture", "reference"):
|
||||
raise ToolError(f"Step {at + 1}: {name} works on a picture the user hands over, not on the step before's.")
|
||||
name = chained
|
||||
block = reference_blocks.BLOCKS[name]
|
||||
if (at == 0) != bool(block.get("standalone")):
|
||||
raise ToolError("A plan starts with the picture (use='picture') and has it once.")
|
||||
model = _model_id(step["model"], at) if step.get("model") else (next((item for item in _box(ctx).get("models") or []
|
||||
if item.split(":", 1)[0] in model_advice.MAIN_TYPES), None) if at == 0 else None)
|
||||
if at == 0 and not model:
|
||||
raise ToolError("Step 1: name the picture's model (model_id from search_models), or the user picks one for the task.")
|
||||
if model and at and not block.get("own_model"):
|
||||
raise ToolError(f"Step {at + 1} ({name}) has no loader of its own for a second model; leave model out.")
|
||||
shown = {"block": name, "title": block["title"], "model": model}
|
||||
if model:
|
||||
model_name, folder_type, base = _model(model)
|
||||
shown.update(model_name=model_name, choices=_choices(model, folder_type, base))
|
||||
steps.append(shown)
|
||||
return steps
|
||||
|
||||
|
||||
def _built(ctx, plan, name):
|
||||
loras = [item for item in _box(ctx).get("models") or [] if item.startswith("loras:")]
|
||||
chain = block_chain.build(ctx, [{**step, "loras": loras} for step in plan], name)
|
||||
ai_plans.mark(_task_id(ctx), ai_plans.PREVIEWS, len(chain["steps"]) > 1)
|
||||
return {"built": [step["block"] for step in chain["steps"]], "nodes": chain["nodes"],
|
||||
"next": NEXT if len(chain["steps"]) > 1 else ONE_PASS}
|
||||
|
||||
|
||||
def propose(ctx, plan, wait_seconds=ai_panels.MAX_WAIT):
|
||||
"""Shows the plan card and waits for the user, building it when they say build (at once with "build without asking")."""
|
||||
steps = resolve(ctx, plan)
|
||||
client, task = ctx.get("client") or "AI", ctx.get("task")
|
||||
try:
|
||||
panel = ai_panels.show(client, "Blueprint", [{"type": "plan", "id": "answer", "steps": steps}],
|
||||
session=ctx.get("session") or "", task_id=task, tool="add_nodes", title_key="aiPlanTitle")
|
||||
if ai_plans.auto():
|
||||
kept = ai_plans.kept(steps, {})
|
||||
ai_panels.answer(_task_id(ctx), _step(ctx, panel["id"])["id"], "answer", ["build"], extra={"plan": kept, "auto": True})
|
||||
return {"panel": panel["id"], **_built(ctx, kept, "blueprint"), "note": "Built without asking: the user's setting."}
|
||||
answer = ai_answer_wait.collect(client, panel["id"], max(30.0, float(wait_seconds or 0)))
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
given = next((item for item in answer.get("answers") or [] if item["question"] == "answer"), None)
|
||||
if not given:
|
||||
return {"answered": False, "panel": panel["id"], **answer,
|
||||
"note": f"When the user says build, add_nodes(route={panel['id']!r}) builds it as they left it; get_answers waits for them."}
|
||||
return _given(ctx, panel["id"], given)
|
||||
|
||||
|
||||
def _given(ctx, panel_id, given):
|
||||
if given.get("plan"):
|
||||
return {"panel": panel_id, **_built(ctx, given["plan"], "blueprint")}
|
||||
return {"answered": True, "panel": panel_id, "text": given.get("text") or "",
|
||||
"note": "Nothing was built: the user wants the plan changed. Change it as they say and call add_nodes(plan=…) again."}
|
||||
|
||||
|
||||
def is_card(ctx, panel_id):
|
||||
return ai_plans.card_of(_step(ctx, panel_id)) is not None
|
||||
|
||||
|
||||
def _step(ctx, panel_id):
|
||||
"""The task's panel step showing panel `panel_id`, or None."""
|
||||
task_id = _task_id(ctx)
|
||||
task = ai_tasks.get_task(task_id) if task_id else None
|
||||
return next((item for item in (task or {}).get("steps") or [] if (item.get("panel") or {}).get("id") == str(panel_id)), None)
|
||||
|
||||
|
||||
def build_answered(ctx, panel_id):
|
||||
"""add_nodes(route=<a plan card's id>): the plan as the user left it, once they said build."""
|
||||
given = (_step(ctx, panel_id) or {}).get("answers", {}).get("answer")
|
||||
if not given:
|
||||
raise ToolError("The user has not answered the plan card yet: get_answers waits for them.")
|
||||
return _given(ctx, panel_id, given)
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Routes: what to do with the models the user picked, chosen by the user, built by Anomalous. A light
|
||||
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) 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, block_chain, model_advice, reference_blocks, workflow_route
|
||||
from .mcp_tools import ToolError, _resolve_model
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
|
||||
with open(os.path.join(os.path.dirname(__file__), "routes.json"), encoding="utf-8") as source:
|
||||
ROUTES = json.load(source)
|
||||
MAX_CARDS = 3
|
||||
QUESTION = "How should the two models you picked be used?"
|
||||
TOOLBOX_NOTE = ("The user picked two main models: ask_user(routes=true) asks them how to use the two, with options Anomalous "
|
||||
"prepared, and its answer says what to call. Skip it only when the user already said how.")
|
||||
NEXT = "run_workflow(prompt=, negative=, base_only=true, batch=3) shows the user bases to pick from; then run_workflow runs the whole route."
|
||||
ONE_PASS = "run_workflow(prompt=, negative=) draws it; there is no second pass, so no base to pick."
|
||||
|
||||
|
||||
def _main(box):
|
||||
return [item for item in (box or {}).get("models") or [] if item.split(":", 1)[0] in model_advice.MAIN_TYPES]
|
||||
|
||||
|
||||
def _loras(box):
|
||||
return [item for item in (box or {}).get("models") or [] if item.startswith("loras:")]
|
||||
|
||||
|
||||
def _facts(model_id):
|
||||
"""(file name without folders, base model) of `model_id`; ToolError when it is gone."""
|
||||
_kind, _index, _root, path = _resolve_model(model_id)
|
||||
return os.path.splitext(os.path.basename(path))[0], model_advice.base_of(path)
|
||||
|
||||
|
||||
def _can_build(route, base, info):
|
||||
if route.get("only") and model_advice.family(base) not in route["only"]:
|
||||
return False
|
||||
if model_advice.family(base) in route.get("not", ()):
|
||||
return False
|
||||
try:
|
||||
reference_blocks.build("picture", info, main_base=lambda: base, model="model")
|
||||
if route.get("way"):
|
||||
name = reference_blocks.pick(*route["way"], info)
|
||||
reference_blocks.build(name, info, main_base=lambda: base, base_of=lambda _name: base)
|
||||
except reference_blocks.BlockError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _cards():
|
||||
"""(name, stages, line) of each saved workflow."""
|
||||
for recipe in _list_recipes(get_recipes_dir(), with_workflow=True):
|
||||
data = recipe.get("data") or {}
|
||||
workflow = data.get("workflow")
|
||||
stages = [stage for stage in workflow_route.route(workflow)[0] if stage["state"] == "run"]
|
||||
yield data.get("name") or recipe["filename"], stages, workflow_route.line(workflow)
|
||||
|
||||
|
||||
def cards_of(model_id):
|
||||
"""The saved workflows drawing with `model_id`, each with its route: the model's examples."""
|
||||
name = _facts(model_id)[0]
|
||||
return [{"card": card, "route": line} for card, stages, line in _cards() if any(stage["model"] == name for stage in stages)][:MAX_CARDS]
|
||||
|
||||
|
||||
def offer(box, info):
|
||||
"""{"options": [ask_user's choices], "calls": {option id: what to call}} for the two main models in `box`,
|
||||
or None: fewer picked, one gone, or of different families (a switch reuses the first's prompts and VAE)."""
|
||||
picked = _main(box)[:2]
|
||||
if len(picked) < 2:
|
||||
return None
|
||||
try:
|
||||
(first, base), (second, other) = _facts(picked[0]), _facts(picked[1])
|
||||
except ToolError:
|
||||
return None
|
||||
if not model_advice.family(base) or model_advice.family(base) != model_advice.family(other):
|
||||
return None
|
||||
names = {"a": first, "b": second}
|
||||
options, calls = [], {}
|
||||
card = next((card for card, stages, _line in _cards() if {first, second} <= {stage["model"] for stage in stages}), None)
|
||||
if card:
|
||||
options.append({"id": "card", "label": f"Your saved card: {card}"[:80], "label_key": "aiRouteCard", "values": {"name": card},
|
||||
"detail": "A route you saved with these two models.", "detail_key": "aiRouteCardDetail", "recommended": True})
|
||||
calls["card"] = f'run_workflow(workflow="{card}", prompt=, negative=, base_only=true, batch=3) shows the user bases to pick from; then run_workflow.'
|
||||
for key, route in ROUTES.items():
|
||||
if _can_build(route, base, info):
|
||||
options.append({"id": key, "label": route["label"].format(**names)[:80], "label_key": f"aiRoute_{key}", "values": names,
|
||||
"detail": route["detail"], "detail_key": f"aiRouteDetail_{key}", "sample": key})
|
||||
calls[key] = f'add_nodes(route="{key}") builds it. Then {NEXT if route.get("way") else ONE_PASS}'
|
||||
if not card and options:
|
||||
options[0]["recommended"] = True
|
||||
return {"options": options, "calls": calls} if len(options) > 1 else None
|
||||
|
||||
|
||||
def waiting(task):
|
||||
"""Whether the task's picks still wait for the user's route: two main models picked, none asked, nothing run."""
|
||||
return len(_main(ai_toolbox.effective(task))) >= 2 and not any(
|
||||
step.get("routes") or step.get("tool") == "run_workflow" for step in task.get("steps") or [])
|
||||
|
||||
|
||||
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_canvas_edit
|
||||
|
||||
route = ROUTES.get(key)
|
||||
if not route:
|
||||
raise ToolError(f"No route {key!r}; there are: {', '.join(ROUTES)}.")
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(ctx["task"]) if ctx.get("task") else None)
|
||||
picked = ([model] if model else []) + [item for item in _main(box) if item != model]
|
||||
if len(picked) < route["models"]:
|
||||
raise ToolError(f"Route {key} draws with {route['models']} main models the user picks for the task. With models of your "
|
||||
"own choosing lay it out instead: add_nodes(plan=[{use: 'picture', model: <first>}, {use: 'upscale', "
|
||||
"want: 'tile', model: <second>}, …]).")
|
||||
ai_bases.forget(ctx.get("task")) # a base picked on another canvas: its sampler is not this route's
|
||||
loras = _loras(box)
|
||||
steps = [{"use": "picture", "model": picked[0], "loras": loras}]
|
||||
if route.get("way"):
|
||||
use, want = route["way"]
|
||||
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)
|
||||
mcp_canvas_edit.set_values(ctx, values={str(before): {"return_with_leftover_noise": "disable"}, str(after): {"add_noise": "enable"}})
|
||||
return answer
|
||||
+108
-47
@@ -19,23 +19,26 @@ outside Anomalous since the AI's previous call shown and told (ai_outside.py). P
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import json
|
||||
import time
|
||||
import urllib.parse
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import ai_callers, ai_gates, ai_outside, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, mcp_apps, mcp_bridge, mcp_directory, mcp_prompts
|
||||
from . import ai_callers, ai_darkroom, ai_starters, ai_gates, ai_outside, ai_privacy, ai_sealed, ai_sent, ai_panels, ai_run_tracking, ai_task_canvas, ai_task_runs, ai_tasks, job_sheets, mcp_apps, mcp_bridge, mcp_directory, mcp_fit, mcp_prompts, mcp_tool_sets
|
||||
from .local_guard import is_local as _is_local, loopback_host as _loopback_host
|
||||
from .mcp_actions import ACTIONS
|
||||
from .mcp_canvas_edit import CANVAS_TOOLS
|
||||
from .mcp_canvas_edit import CANVAS_TOOLS, object_info
|
||||
from .mcp_guide import GUIDE_TOOLS
|
||||
from .mcp_model_use import MODEL_TOOLS
|
||||
from .mcp_look import LOOK_TOOLS
|
||||
from .mcp_situation import SITUATION_TOOLS
|
||||
from .mcp_task_tools import TASK_TOOLS
|
||||
from .mcp_tools import TOOLS as READ_TOOLS, call_tool, retext
|
||||
from .mcp_tools import TOOLS as READ_TOOLS, ToolError, call_tool, retext
|
||||
from .mcp_arguments import prepare_arguments, renaming_note
|
||||
|
||||
TOOLS = {**READ_TOOLS, **MODEL_TOOLS, **LOOK_TOOLS, **ACTIONS, **CANVAS_TOOLS, **TASK_TOOLS, **SITUATION_TOOLS, **mcp_apps.APP_TOOLS}
|
||||
TOOLS = {**READ_TOOLS, **MODEL_TOOLS, **LOOK_TOOLS, **ACTIONS, **CANVAS_TOOLS, **TASK_TOOLS, **SITUATION_TOOLS, **GUIDE_TOOLS,
|
||||
**mcp_apps.APP_TOOLS}
|
||||
CAPABILITIES = {
|
||||
"tools": {"listChanged": False}, "prompts": {"listChanged": False}, "resources": {"listChanged": False},
|
||||
"extensions": {mcp_apps.EXTENSION: {"mimeTypes": [mcp_apps.MIME]}}, # the card in the conversation (mcp_apps.py)
|
||||
@@ -44,42 +47,59 @@ CAPABILITIES = {
|
||||
MODERN_VERSIONS = ("2026-07-28",)
|
||||
LEGACY_VERSIONS = ("2025-11-25", "2025-06-18", "2025-03-26")
|
||||
SERVER_INFO = {"name": "anomalous-model-browser", "title": "Anomalous Model Browser", "version": "1.0.0"}
|
||||
INSTRUCTIONS = (
|
||||
# One part per sentence or clause: a set without some tools drops the parts naming them (mcp_tool_sets.py).
|
||||
INSTRUCTION_PARTS = (
|
||||
"Tools over the user's ComfyUI library as Anomalous Model Browser keeps it: model files with "
|
||||
"their Civitai information, output images with their generation settings, combos (a model, "
|
||||
"LoRAs and a prompt), workflow recipes, saved prompts, generated audio, the scan status and "
|
||||
"the activity log; and tools that act on the workflow open in ComfyUI (get_situation gives flow chains; "
|
||||
"describe_canvas only for details) or build one where the user watches: find_nodes searches brief types; "
|
||||
"the activity log; ",
|
||||
"and tools that act on the workflow open in ComfyUI (get_situation gives flow chains; "
|
||||
"describe_canvas only for details) or build one where the user watches: ",
|
||||
"find_nodes searches brief types; ",
|
||||
"add_node wires in the same call (wire_from/wire_to; wire_to=ID splices it in front of that node, "
|
||||
"insert_after=ID behind it); add_nodes adds a whole group in ComfyUI's API form in one call; "
|
||||
"connect_nodes needs only two IDs unless sockets are ambiguous. Reuse receipts instead of rereading "
|
||||
"the graph. new_workflow opens a tab only when needed. Tools also scan models or have an "
|
||||
"Anomalous TTS voice speak. Canvas changes "
|
||||
"are single Ctrl+Z steps and are logged as the AI's; nothing deletes files. Ids returned by "
|
||||
"one tool (model_id, image, node ids) are what the others take. For what no tool does, tell "
|
||||
"the user where in Anomalous to do it (the rail on the left: Models, Gallery, Workflows, "
|
||||
"Combos, Prompts, Voices). begin_task's answer carries a directory of the tools by what you want to do: "
|
||||
"look there and go straight to the tool. In a new chat on a task, or when unsure where things stand, call get_situation "
|
||||
"first: the task, the canvas in brief (what is still unwired) and the next step in one answer. "
|
||||
"insert_after=ID behind it); ",
|
||||
"add_nodes adds a whole group in ComfyUI's API form in one call; ",
|
||||
"connect_nodes needs only two IDs unless sockets are ambiguous. ",
|
||||
"Reuse receipts instead of rereading the graph. ",
|
||||
"new_workflow opens a tab only when needed. ",
|
||||
"Tools also scan models or have an Anomalous TTS voice speak. ",
|
||||
"Canvas changes are single Ctrl+Z steps and are logged as the AI's; nothing deletes files. ",
|
||||
"Ids returned by one tool (model_id, image, node ids) are what the others take. ",
|
||||
"For what no tool does, or when the user asks how something in Anomalous works, read_guide has its manual: "
|
||||
"tell them the way, with the words on screen. ",
|
||||
"begin_task's answer carries a directory of the tools by what you want to do: "
|
||||
"look there and go straight to the tool. ",
|
||||
"In a new chat on a task, or when unsure where things stand, call get_situation "
|
||||
"first: the task, the canvas in brief (what is still unwired) and the next step in one answer. ",
|
||||
"The user watches your work in Anomalous's AI workbench: call begin_task "
|
||||
"with a short title when you start what they asked for (it continues the waiting or active task; "
|
||||
"new_task=true is only for a separate user request), and report to explain a choice, hand in "
|
||||
"pictures made another way, or end the task (done=true). The user can write to you or answer "
|
||||
"pictures made another way, or end the task (done=true). ",
|
||||
"The user can write to you or answer "
|
||||
"panel questions there: call get_answers to wait for or read them, and any unread user_messages "
|
||||
"or user_answers attached to a tool result must also be honoured. While you work on an Anomalous task, ask "
|
||||
"or user_answers attached to a tool result must also be honoured. ",
|
||||
"While you work on an Anomalous task, ask "
|
||||
"the user every question (a choice, a preference, yes or no) with ask_user, never in your own chat or its "
|
||||
"question form: the user watches and answers in Anomalous. Until they say the task is done, end every round "
|
||||
"question form: the user watches and answers in Anomalous. ",
|
||||
"Until they say the task is done, end every round "
|
||||
"with ask_user (what next, or whether to go ahead), not with a note or panel alone; only a run, download or "
|
||||
"scan going on lets you end your turn without asking. Likewise write your explanations and replies "
|
||||
"there with report (reply_to answers their message or marks), not only in your chat. Show plans, workflow explanations "
|
||||
"scan going on lets you end your turn without asking. ",
|
||||
"Likewise write your explanations and replies "
|
||||
"there with report (reply_to answers their message or marks), not only in your chat. ",
|
||||
"Show plans, workflow explanations "
|
||||
"and results in chronological order with show_panel; update_panel appends new content below what "
|
||||
"the user already saw. Use run_workflow for execution: it returns real state/results after a bounded wait. "
|
||||
"the user already saw. ",
|
||||
"Use run_workflow for execution: it returns real state/results after a bounded wait. ",
|
||||
"If you submit through /prompt or another tool, immediately call track_run with YOUR returned prompt_ids "
|
||||
"and task ID, or the workbench cannot observe it. For running results use run_status(wait_seconds=55); "
|
||||
"do not inspect output directories or resubmit to find out whether it finished. Change pictures only by "
|
||||
"running workflows, never with code of your own; if you do use another tool (a command, a script, files), "
|
||||
"say what you ran with log_outside."
|
||||
"and task ID, or the workbench cannot observe it. ",
|
||||
"For running results use run_status(wait_seconds=55); "
|
||||
"do not inspect output directories or resubmit to find out whether it finished. ",
|
||||
"Change pictures only by "
|
||||
"running workflows, never with code of your own; ",
|
||||
"if you do use another tool (a command, a script, files), "
|
||||
"say what you ran with log_outside.",
|
||||
)
|
||||
INSTRUCTIONS = "".join(INSTRUCTION_PARTS)
|
||||
LIST_TTL_MS = 300000
|
||||
# Requests run tools in threads; a few at a time is plenty for one person's AI app.
|
||||
_slots = asyncio.Semaphore(4)
|
||||
@@ -120,34 +140,39 @@ def _result(id_, result, modern):
|
||||
return web.json_response({"jsonrpc": "2.0", "id": id_, "result": result})
|
||||
|
||||
|
||||
def _tools():
|
||||
"""The tool list AI apps are offered, as tools/list gives it."""
|
||||
return [mcp_apps.with_card(tool["spec"]) for tool in TOOLS.values()]
|
||||
def _tools(names=None):
|
||||
"""The tool list AI apps are offered, as tools/list gives it: every tool, or the set `names` (mcp_tool_sets.py)."""
|
||||
return [mcp_apps.with_card(tool["spec"]) for name, tool in TOOLS.items() if names is None or name in names]
|
||||
|
||||
|
||||
def _tool_list(modern):
|
||||
result = {"tools": _tools()}
|
||||
def _sizes():
|
||||
"""{tool: characters of its spec as listed}, for the Connect an AI window's counts."""
|
||||
return {tool["name"]: len(json.dumps(tool, ensure_ascii=False)) for tool in _tools()}
|
||||
|
||||
|
||||
def _tool_list(modern, names=None):
|
||||
result = {"tools": _tools(names)}
|
||||
if modern:
|
||||
result.update(ttlMs=LIST_TTL_MS, cacheScope="public")
|
||||
return result
|
||||
|
||||
|
||||
def _initialize(params):
|
||||
def _initialize(params, names=None):
|
||||
requested = params.get("protocolVersion")
|
||||
return {
|
||||
"protocolVersion": requested if requested in LEGACY_VERSIONS else LEGACY_VERSIONS[0],
|
||||
"capabilities": CAPABILITIES,
|
||||
"serverInfo": SERVER_INFO,
|
||||
"instructions": INSTRUCTIONS,
|
||||
"instructions": mcp_tool_sets.instructions(INSTRUCTION_PARTS, names, TOOLS),
|
||||
}
|
||||
|
||||
|
||||
def _discover():
|
||||
def _discover(names=None):
|
||||
return {
|
||||
"supportedVersions": list(MODERN_VERSIONS + LEGACY_VERSIONS),
|
||||
"capabilities": CAPABILITIES,
|
||||
"_meta": {"io.modelcontextprotocol/serverInfo": SERVER_INFO},
|
||||
"instructions": INSTRUCTIONS,
|
||||
"instructions": mcp_tool_sets.instructions(INSTRUCTION_PARTS, names, TOOLS),
|
||||
"ttlMs": LIST_TTL_MS,
|
||||
"cacheScope": "public",
|
||||
}
|
||||
@@ -166,7 +191,7 @@ def _modern_header_problem(request, method, params, version):
|
||||
return None
|
||||
|
||||
|
||||
async def _call(params, request):
|
||||
async def _call(params, request, names=None):
|
||||
name = params.get("name")
|
||||
raw_arguments = params.get("arguments") or {}
|
||||
if not isinstance(name, str) or name not in TOOLS or not isinstance(raw_arguments, dict):
|
||||
@@ -191,12 +216,16 @@ async def _call(params, request):
|
||||
if name not in mcp_apps.APP_TOOLS: # the card's own calls do not reach the AI
|
||||
outside = await asyncio.to_thread(ai_outside.check, active_task or ai_tasks.active_task_id(client, session))
|
||||
canvas_call = ai_task_canvas.begin_call(active_task) # the page works on the task's own tab
|
||||
# A private task's tokens become their texts for the tool only; the record keeps the tokens (ai_privacy.py).
|
||||
privacy_task = active_task or ai_tasks.active_task_id(client, session=session)
|
||||
private = await asyncio.to_thread(ai_privacy.applies, privacy_task)
|
||||
call_arguments = await asyncio.to_thread(ai_sealed.expand, privacy_task, arguments) # tokens from a prompt box work in any task
|
||||
try:
|
||||
# The user's gate first (ai_gates.py): it may wait for their OK, or answer for them.
|
||||
result = await asyncio.to_thread(ai_gates.check, name, arguments, step_ref)
|
||||
if result is None:
|
||||
async with _slots:
|
||||
result = await asyncio.to_thread(call_tool, TOOLS, name, arguments, ctx)
|
||||
result = await asyncio.to_thread(call_tool, TOOLS, name, call_arguments, ctx)
|
||||
except BaseException as error:
|
||||
failed_result = {"content": [{"type": "text", "text": f"{name} failed: {error}"}], "isError": True}
|
||||
await asyncio.to_thread(
|
||||
@@ -211,10 +240,16 @@ async def _call(params, request):
|
||||
step_ref=step_ref, session=session, task_id=active_task,
|
||||
)
|
||||
ai_task_canvas.tell(result, canvas_call)
|
||||
if name == "run_workflow" and step and step.get("state") == "running":
|
||||
sheet = (result.get("structuredContent") or {}).get("sheet") if name == "run_workflow" else None
|
||||
if sheet and step and step.get("state") == "running": # the page running it says where it stands (job_sheets.py)
|
||||
await asyncio.to_thread(job_sheets.attach, sheet, step["id"])
|
||||
elif name == "run_workflow" and step and step.get("state") == "running":
|
||||
ai_task_runs.watch(step["id"], result.get("structuredContent") or {})
|
||||
await asyncio.to_thread(ai_run_tracking.wait_for_receipt, result, client, session, active_task,
|
||||
ai_run_tracking.checked_wait(arguments.get("wait_seconds", 55)), step["id"])
|
||||
await asyncio.to_thread(mcp_tool_sets.chat_start, result, name, names, ctx, private) # no begin_task: the ways
|
||||
if name not in mcp_apps.APP_TOOLS:
|
||||
mcp_fit.fit(result, name) # read in the chat, not filed away unread; the user's news below stays whole
|
||||
# The user's news and the run's go to the AI, never to the card's own calls (they do not reach the AI).
|
||||
if name not in mcp_apps.APP_TOOLS and isinstance(result, dict) and not result.get("isError"):
|
||||
resolved_task = active_task or await asyncio.to_thread(ai_tasks.active_task_id, client, session=session)
|
||||
@@ -223,10 +258,29 @@ async def _call(params, request):
|
||||
await asyncio.to_thread(ai_task_runs.show_pictures, result, resolved_task)
|
||||
if name != "get_answers" and name not in mcp_apps.APP_TOOLS and isinstance(result, dict) and not result.get("isError"):
|
||||
await asyncio.to_thread(ai_panels.attach_pending, result, client, task_id=resolved_task or ("" if session else None))
|
||||
mcp_directory.attach(result, name, {key: tool["spec"] for key, tool in TOOLS.items() if key not in mcp_apps.APP_TOOLS})
|
||||
if name not in mcp_apps.APP_TOOLS and mcp_directory.stale(client, _tools()):
|
||||
mcp_directory.attach(result, name, {key: tool["spec"] for key, tool in TOOLS.items()
|
||||
if key not in mcp_apps.APP_TOOLS and (names is None or key in names)})
|
||||
if name not in mcp_apps.APP_TOOLS and mcp_directory.stale(client, _tools(names)):
|
||||
await asyncio.to_thread(mcp_directory.tell_stale, result, active_task)
|
||||
return ai_outside.tell(result, outside)
|
||||
# Last: a private task's pictures and prompts never leave, whatever put them in (ai_privacy.py).
|
||||
final_task = active_task or ai_tasks.active_task_id(client, session=session)
|
||||
if final_task != privacy_task and await asyncio.to_thread(ai_privacy.applies, final_task):
|
||||
private, privacy_task = True, final_task
|
||||
if private and name == "zoom_image": # the user's local helper looks in the AI app's place (ai_darkroom.py)
|
||||
result = await asyncio.to_thread(ai_darkroom.check_picture, result)
|
||||
info = await asyncio.to_thread(_node_list, ctx) if private else None
|
||||
result = await asyncio.to_thread(ai_privacy.guard, ai_outside.tell(result, outside), private, privacy_task, info, name)
|
||||
if private and privacy_task and name not in mcp_apps.APP_TOOLS: # for the user to read what went out (ai_sent.py)
|
||||
await asyncio.to_thread(ai_sent.keep, privacy_task, name, arguments, result)
|
||||
return result
|
||||
|
||||
|
||||
def _node_list(ctx):
|
||||
"""ComfyUI's node list, for telling which node inputs are text a private task keeps; {} when unreadable."""
|
||||
try:
|
||||
return object_info(ctx)
|
||||
except ToolError:
|
||||
return {}
|
||||
|
||||
|
||||
async def api_mcp(request):
|
||||
@@ -245,9 +299,10 @@ async def api_mcp(request):
|
||||
return web.Response(status=202) # notifications (initialized, cancelled) need no answer
|
||||
id_, method = body["id"], body["method"]
|
||||
params = body.get("params") if isinstance(body.get("params"), dict) else {}
|
||||
names = mcp_tool_sets.chosen(request.query.get("tools"), TOOLS) # ?tools=core | mine: a shorter list
|
||||
|
||||
if method == "initialize":
|
||||
return _result(id_, _initialize(params), modern=False)
|
||||
return _result(id_, _initialize(params, names), modern=False)
|
||||
meta = params.get("_meta") if isinstance(params.get("_meta"), dict) else {}
|
||||
version = meta.get("io.modelcontextprotocol/protocolVersion")
|
||||
modern = version is not None
|
||||
@@ -265,11 +320,11 @@ async def api_mcp(request):
|
||||
data={"supported": list(MODERN_VERSIONS + LEGACY_VERSIONS), "requested": header})
|
||||
|
||||
if method == "server/discover" and modern:
|
||||
return _result(id_, _discover(), modern)
|
||||
return _result(id_, _discover(names), modern)
|
||||
if method == "ping":
|
||||
return _result(id_, {}, modern)
|
||||
if method == "tools/list":
|
||||
listing = _tool_list(modern)
|
||||
listing = _tool_list(modern, names)
|
||||
await asyncio.to_thread(mcp_directory.listed, ai_callers.client_of(request.headers, params), listing["tools"])
|
||||
return _result(id_, listing, modern)
|
||||
if method == "prompts/list":
|
||||
@@ -287,7 +342,7 @@ async def api_mcp(request):
|
||||
return _error(id_, INVALID_PARAMS, f"Unknown resource: {params.get('uri')}")
|
||||
return _result(id_, resource, modern)
|
||||
if method == "tools/call":
|
||||
result = await _call(params, request)
|
||||
result = await _call(params, request, names)
|
||||
if result is None:
|
||||
return _error(id_, INVALID_PARAMS, f"Unknown tool or bad arguments: {params.get('name')}")
|
||||
return _result(id_, result, modern)
|
||||
@@ -306,10 +361,16 @@ async def api_mcp_bridge(request):
|
||||
if not isinstance(body, dict) or not isinstance(body.get("id"), str):
|
||||
return web.json_response({"error": "Expected {id, claim} or {id, outcome}"}, status=400)
|
||||
if body.get("claim") is True:
|
||||
return web.json_response({"granted": mcp_bridge.claim(body["id"], body.get("capabilities"))})
|
||||
page = body.get("page") if isinstance(body.get("page"), str) and len(body["page"]) <= 64 else None
|
||||
granted = mcp_bridge.claim(body["id"], body.get("capabilities"), page)
|
||||
if granted == "later": # another page did the last action: ask again when it has had its turn
|
||||
return web.json_response({"granted": False, "retry_ms": int(mcp_bridge.HOLD_SECONDS * 1000) + 300})
|
||||
return web.json_response({"granted": granted})
|
||||
return web.json_response({"accepted": mcp_bridge.resolve(body["id"], body.get("outcome"))})
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_route("*", "/anomalous/mcp", api_mcp)
|
||||
app.router.add_post("/anomalous/mcp/bridge", api_mcp_bridge)
|
||||
mcp_tool_sets.register_routes(app, TOOLS, _sizes)
|
||||
ai_starters.register_routes(app, _tools) # the home page's AI cards
|
||||
|
||||
+11
-4
@@ -9,11 +9,14 @@ and the task's history. Unread messages of the user's come with it as with every
|
||||
(ai_panels.attach_pending).
|
||||
"""
|
||||
|
||||
from . import ai_decisions, ai_task_runs, ai_tasks
|
||||
from . import ai_decisions, ai_task_runs, ai_tasks, mcp_routes
|
||||
from .mcp_bridge import PageRefused, PageUnavailable, ask_page
|
||||
from .mcp_tools import _schema, tool_text
|
||||
|
||||
BUDGET = 3600 # characters: Antigravity files away an answer past about 4,000 and has the AI open it
|
||||
EMPTY_CANVAS = ("The canvas is empty: run_workflow(workflow=<name>) opens a saved workflow and runs it in one call; open_workflow "
|
||||
"opens one to change first, or a gallery picture's own by its file name; list_workflows names them and the examples. "
|
||||
"A picture in several steps (the picture, a redraw on another model, details, an enlargement): add_nodes(plan=[…]).")
|
||||
MAX_TEXT = 200
|
||||
|
||||
|
||||
@@ -34,7 +37,7 @@ def _canvas():
|
||||
return {"unavailable": str(error)}
|
||||
|
||||
|
||||
def _next(summary, run, canvas):
|
||||
def _next(summary, run, canvas, record=None):
|
||||
if summary["asks"]:
|
||||
return f"{summary['asks']} question(s) or gate(s) wait for the user: get_answers waits for their answer."
|
||||
if run.get("state") in ("queued", "running", "asking", "allowed"):
|
||||
@@ -43,11 +46,15 @@ def _next(summary, run, canvas):
|
||||
return ("A model or file the workflow names is not on this computer: check_workflow_models says which and what "
|
||||
"can stand in (fix_workflow_models, download_missing_models); do not guess other choices.")
|
||||
if canvas.get("errors"):
|
||||
return "ComfyUI refused the last run: fix the nodes in canvas.errors (set_values, connect_nodes), then run_workflow."
|
||||
return "ComfyUI shows errors on the canvas: fix the nodes in canvas.errors (set_values, connect_nodes, run_workflow image=), then run_workflow."
|
||||
if canvas.get("unwired"):
|
||||
return "Wire canvas.unwired with connect_nodes (fits lists outputs that fit each), then run_workflow."
|
||||
if run.get("state") == "done" and run.get("pictures"):
|
||||
return "Look at the run's pictures (zoom_image) and tell the user with report what came out."
|
||||
if record and mcp_routes.waiting(record):
|
||||
return mcp_routes.TOOLBOX_NOTE
|
||||
if canvas.get("node_count") == 0:
|
||||
return EMPTY_CANVAS
|
||||
return "Go on with what the user asked (task.asked); run_workflow runs the canvas, report tells the user."
|
||||
|
||||
|
||||
@@ -72,7 +79,7 @@ def get_situation(ctx, task=None):
|
||||
answer["task_note"] = "No task yet: begin_task names what the user asked for."
|
||||
canvas = _canvas()
|
||||
answer.update(run=run, canvas=canvas)
|
||||
answer["next"] = _next(summary, run, canvas)
|
||||
answer["next"] = _next(summary, run, canvas, record)
|
||||
# Trim detail before chains and decisions; preserve actionable missing inputs and errors.
|
||||
while len(tool_text(answer)) > BUDGET and canvas.get("lines"):
|
||||
canvas["lines"].pop()
|
||||
|
||||
+120
-57
@@ -4,7 +4,8 @@ show_panel / update_panel draw a panel from Anomalous's blocks (ai_panels.py), g
|
||||
reads what the user picked or typed on it, log_outside keeps what the AI did with other tools
|
||||
(ai_outside.py)."""
|
||||
|
||||
from . import ai_answer_wait, ai_decisions, ai_marks, ai_message_refs, ai_outside, ai_panels, ai_repaint, ai_run_control, ai_run_tracking, ai_task_links, ai_task_runs, ai_tasks, ai_toolbox
|
||||
from . import ai_answer_wait, ai_darkroom, ai_decisions, ai_marks, ai_message_refs, ai_outside, ai_panels, ai_repaint, ai_run_control, ai_run_tracking, ai_task_links, ai_task_runs, ai_tasks, ai_toolbox, ai_privacy, ai_skills, mcp_routes, reference_blocks
|
||||
from .mcp_actions import downloads as model_downloads
|
||||
from .mcp_tools import MAX_LIMIT, ToolError, _schema
|
||||
|
||||
PACK_TYPES = 150 # node types listed per picked pack
|
||||
@@ -24,17 +25,26 @@ def begin_task(ctx, title, new_task=False):
|
||||
answer = {"task": task["id"], "title": task["title"], "note": (
|
||||
"Continue this task; do not begin another for its steps. Every AMB call is a step the user sees, opens and "
|
||||
"undoes in Anomalous's AI workbench. Ask them with ask_user (they answer there, not in your chat); report "
|
||||
"explains a choice or hands in pictures, done=true at the end. Run with run_workflow; a run queued another "
|
||||
"way needs track_run(prompt_ids) with your own IDs. In the task's own tab, do not switch tabs yourself. "
|
||||
"explains a choice or hands in pictures, done=true at the end. Run with run_workflow. In the task's own tab, "
|
||||
"do not switch tabs yourself. "
|
||||
"Everything is in AMB's tools (models, pictures, node types, the canvas, runs and their previews): do not "
|
||||
"read ComfyUI's folders or source, run preprocessors or call its routes yourself. Change pictures only by "
|
||||
"running workflows, never by editing files with code. If you use another tool anyway, log_outside right "
|
||||
"after. Unsure where things stand: get_situation. The directory below lists the tools by what you want to do.")}
|
||||
"after. The directory below lists the tools by what you want to do.")}
|
||||
answer["next"] = ("get_situation shows where things stand; a daily run is one call: run_workflow(workflow: a saved "
|
||||
"one, with the prompt, seed or size to change).")
|
||||
answer["blocks"] = _blocks(ctx)
|
||||
if ai_privacy.is_private(ai_tasks.get_task(task["id"])):
|
||||
answer["privacy"] = ai_privacy.NOTE # the user writes the prompts and looks at the pictures: no skill for those
|
||||
if ai_darkroom.note():
|
||||
answer["local_helper"] = ai_darkroom.note()
|
||||
elif ai_skills.directory():
|
||||
answer["skills"] = ai_skills.directory()
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(task["id"]))
|
||||
if box:
|
||||
answer["toolbox"] = ai_toolbox.words(box)
|
||||
if _routes(ctx, task["id"]):
|
||||
answer["next"] = mcp_routes.TOOLBOX_NOTE
|
||||
goes_on = ai_task_links.carried(task)
|
||||
if goes_on:
|
||||
answer["continues"] = goes_on # the task this one goes on from, and what was decided there
|
||||
@@ -46,10 +56,29 @@ def begin_task(ctx, title, new_task=False):
|
||||
answer["about"] = task["about"]
|
||||
answer["about_note"] = ("The user started this task from these in Anomalous; work with them: model_id goes to "
|
||||
"set_model / add_lora / get_model, image to get_image_info, a combo's name to place_combo, "
|
||||
"a workflow's name to open_workflow.")
|
||||
"a workflow's name to open_workflow; a canvas is the user's tab your work starts from "
|
||||
"(describe_canvas reads it).")
|
||||
return answer
|
||||
|
||||
|
||||
def _node_list(ctx):
|
||||
"""ComfyUI's node list, to say which blocks can run here; None when it cannot be read."""
|
||||
from .mcp_canvas_edit import object_info
|
||||
try:
|
||||
return object_info(ctx) if ctx.get("base_url") else None
|
||||
except ToolError:
|
||||
return None
|
||||
|
||||
|
||||
def _blocks(ctx):
|
||||
"""The ready-made blocks (reference_blocks.py), each a line, for begin_task's answer: what to reach for first."""
|
||||
info = _node_list(ctx)
|
||||
return {"note": "add_nodes(use=…, want=…) builds the best one this ComfyUI can run, wired in; prefer it. A picture in several "
|
||||
"of these steps (the picture, a redraw on another model, details, an enlargement): add_nodes(plan=[{use: "
|
||||
"'picture', model}, {use: …}, …]) shows the steps to the user and builds them all on a new tab.",
|
||||
**reference_blocks.directory(info)}
|
||||
|
||||
|
||||
def report(ctx, text="", images=None, done=False, reply_to=None, task=None):
|
||||
try:
|
||||
step, missing = ai_tasks.report(
|
||||
@@ -88,13 +117,16 @@ def log_decision(ctx, title, decision, reason="", rejected=None, by="user", rela
|
||||
|
||||
|
||||
def show_panel(ctx, title, blocks, task=None):
|
||||
# A prompt box tells the user what suits the canvas's model (the prompt_pose skill's words for them).
|
||||
boxed = isinstance(blocks, list) and any(isinstance(block, dict) and block.get("type") == "prompts" for block in blocks)
|
||||
advice = ai_skills.for_user(_canvas_model()) if boxed else None
|
||||
try:
|
||||
panel = ai_panels.show(
|
||||
ctx.get("client") or "AI", title, blocks,
|
||||
session=ctx.get("session") or "", task_id=task or ctx.get("task"),
|
||||
session=ctx.get("session") or "", task_id=task or ctx.get("task"), extra={"prompt_advice": advice} if advice else None,
|
||||
)
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
raise ToolError(f"{error} Every block's fields: read_guide(topic='panel').")
|
||||
asks = [block["id"] for block in panel["blocks"] if block["type"] in ai_panels.INTERACTIVE]
|
||||
answer = {"panel": panel["id"], "shown": True,
|
||||
"blocks": [{"id": block["id"], "type": block["type"]} for block in panel["blocks"]]}
|
||||
@@ -107,14 +139,38 @@ def show_panel(ctx, title, blocks, task=None):
|
||||
return answer
|
||||
|
||||
|
||||
def ask_user(ctx, question, options=None, multiple=False, other=True, wait_seconds=ai_panels.MAX_WAIT, task=None):
|
||||
def _routes(ctx, task):
|
||||
"""What the user can do with the two models they picked (mcp_routes.py), or None."""
|
||||
from .mcp_canvas_edit import object_info
|
||||
|
||||
task_id = task or ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(task_id)) if task_id else None
|
||||
return mcp_routes.offer(box, object_info(ctx)) if len(mcp_routes._main(box)) >= 2 else None
|
||||
|
||||
|
||||
TRIAL_QUESTIONS = {"test", "testing", "test question", "hello", "hi", "ping", "测试", "试试", "试一下"}
|
||||
|
||||
|
||||
def ask_user(ctx, question="", options=None, multiple=False, other=True, wait_seconds=ai_panels.MAX_WAIT, task=None, routes=False):
|
||||
"""A question in the AI workbench, where the user watches the work: options to press (one may be
|
||||
recommended), a line of their own; waits for the answer."""
|
||||
recommended), a line of their own; waits for the answer. `routes`: Anomalous's own question and options."""
|
||||
extra, words = None, {}
|
||||
if routes:
|
||||
offered = _routes(ctx, task)
|
||||
if not offered:
|
||||
raise ToolError("No routes to ask about: they need two main models of one family picked for the task. Go on with what the user asked.")
|
||||
question, options, multiple, other = mcp_routes.QUESTION, offered["options"], False, True
|
||||
extra, words = {"routes": offered["calls"]}, {"title_key": "aiRouteTitle"}
|
||||
if not str(question or "").strip():
|
||||
raise ToolError("question is empty.")
|
||||
if str(question).strip().strip("??!!.。 ").lower() in TRIAL_QUESTIONS: # with made-up options too ("方案1", "方案2")
|
||||
raise ToolError("Not shown to the user: a trial question would wait for an answer nobody gives. Ask when you need "
|
||||
"their answer: question (needed), options (the choices to press), multiple, other, wait_seconds.")
|
||||
block = ({"type": "choices", "id": "answer", "options": options, "multiple": bool(multiple), "other": bool(other)}
|
||||
if options else {"type": "input", "id": "answer", "question": str(question or "")[:200]})
|
||||
try:
|
||||
panel = ai_panels.show(ctx.get("client") or "AI", question, [block], session=ctx.get("session") or "",
|
||||
task_id=task or ctx.get("task"), tool="ask_user")
|
||||
task_id=task or ctx.get("task"), tool="ask_user", extra=extra, **words)
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
return _answer_of(ctx, panel["id"], wait_seconds)
|
||||
@@ -169,12 +225,14 @@ def update_panel(ctx, panel, title=None, blocks=None, closed=None, task=None):
|
||||
try:
|
||||
updated = ai_panels.update(panel, title, blocks, closed)
|
||||
except ai_panels.PanelError as error:
|
||||
raise ToolError(str(error))
|
||||
raise ToolError(f"{error} Every block's fields: read_guide(topic='panel').")
|
||||
return {"updated": True, "blocks": [{"id": block.get("id"), "type": block["type"]}
|
||||
for block in updated["blocks"]]}
|
||||
|
||||
|
||||
def run_status(ctx, wait_seconds=0, task=None):
|
||||
def run_status(ctx, wait_seconds=0, task=None, downloads=False):
|
||||
if downloads:
|
||||
return model_downloads(ctx)
|
||||
try:
|
||||
return ai_task_runs.status(ctx.get("client") or "AI", ctx.get("session") or "", task or ctx.get("task"), wait_seconds)
|
||||
except ValueError as error:
|
||||
@@ -212,10 +270,18 @@ def stop_run(ctx, task=None):
|
||||
return {**done, "stopped": True, "note": "Stopping: run_status(wait_seconds=10) confirms it ended."}
|
||||
|
||||
|
||||
def get_toolbox(ctx, task=None):
|
||||
def get_toolbox(ctx, task=None, skill=None):
|
||||
from .mcp_canvas_edit import find_nodes
|
||||
|
||||
task_id = task or ctx.get("task") or ai_tasks.active_task_id(ctx.get("client") or "AI", ctx.get("session") or "")
|
||||
if skill:
|
||||
private = ai_privacy.applies(task_id)
|
||||
try:
|
||||
answer = ai_skills.get(str(skill), _canvas_model() if ai_skills.by_model(str(skill)) else None, private, _node_list(ctx))
|
||||
except ai_skills.SkillError as error:
|
||||
raise ToolError(str(error))
|
||||
ai_skills.note_use(ctx.get("client") or "AI", answer, session=ctx.get("session") or "", task_id=task or ctx.get("task"))
|
||||
return answer
|
||||
box = ai_toolbox.effective(ai_tasks.get_task(task_id)) if task_id else None
|
||||
if not box:
|
||||
return {"picked": False, "note": "The user picked nothing for this task; use what fits."}
|
||||
@@ -239,11 +305,31 @@ def get_toolbox(ctx, task=None):
|
||||
for kind in ("combos", "workflows", "prompts"):
|
||||
if box.get(kind):
|
||||
answer[kind] = [found for name in box[kind] if (found := ai_message_refs.lookup(kind, name)) is not None]
|
||||
if box.get("ways"):
|
||||
answer["ways"] = [reference_blocks.call(name) or name for name in box["ways"]]
|
||||
if box.get("models"):
|
||||
answer["models"] = [ai_message_refs.lookup("models", model_id) or {"model_id": model_id, "missing": True} for model_id in box["models"]]
|
||||
for model in answer["models"]:
|
||||
if not model.get("missing") and model["model_id"].split(":", 1)[0] != "loras":
|
||||
model["in_cards"] = mcp_routes.cards_of(model["model_id"]) # its examples: how the user's own routes use it
|
||||
if _routes(ctx, task_id):
|
||||
answer["routes"] = mcp_routes.TOOLBOX_NOTE
|
||||
return answer
|
||||
|
||||
|
||||
def _canvas_model():
|
||||
"""The main model the canvas's sampler uses {name, params}, for advice that depends on its size; None when
|
||||
the page cannot tell (no page, several samplers, no loader)."""
|
||||
from .mcp_actions import _page
|
||||
from .model_advice import main_params
|
||||
|
||||
try:
|
||||
name = (_page("block_model", {"near_node_id": None}) or {}).get("model") or ""
|
||||
except ToolError:
|
||||
return None
|
||||
return {"name": name, "params": main_params(name)} if name else None
|
||||
|
||||
|
||||
def get_answers(ctx, panel=None, wait_seconds=0, task=None):
|
||||
try:
|
||||
client = ctx.get("client") or "AI"
|
||||
@@ -256,26 +342,6 @@ def get_answers(ctx, panel=None, wait_seconds=0, task=None):
|
||||
raise ToolError(str(error))
|
||||
|
||||
|
||||
BLOCKS_HELP = (
|
||||
"blocks, top to bottom, each {type, ...}: heading {text}; text {text, tone: normal|muted|tip|warning}; "
|
||||
"images {items: [{image: 'subfolder/name.png' from search_images, caption, marks: [{x, y, r | w, h, label, "
|
||||
"color: red|green|blue|yellow|purple}]}], layout: row|grid|compare} (marks: circles or boxes with a label, x/y/w/h/r "
|
||||
"as fractions 0-1 of the picture, x/y the centre; a click on the label zooms there); "
|
||||
"slider {before: {image}, after: {image}, before_label, after_label} (one picture over the other, a line to drag); "
|
||||
"picks {id, question, items: [{id, image, label, reason, recommended}] (2-12), multiple} (pictures to choose from, "
|
||||
"each with 'take this one'; the user can also put a picture's workflow back on the canvas); "
|
||||
"diagram {nodes: [{id, label, detail, node_id, color}], edges: [{from, to, label}], direction: right|down} (boxes and "
|
||||
"arrows Anomalous lays out; node_id from describe_canvas makes a box find that node); "
|
||||
"models {items: [{model_id from search_models, note}]} (cards with their covers, a click opens them); "
|
||||
"table {columns: [...], rows: [[...]]}; list {items: [...], ordered}; params {items: [{label, value}]}; "
|
||||
"choices {id, question, options: [{id, label, detail}] (2-8), multiple} (buttons the user presses); "
|
||||
"input {id, question, placeholder} (a line the user types); progress {label, value, max} (your own "
|
||||
"numbers: they never move by themselves, so keep them current with update_panel; a run's real progress shows "
|
||||
"in the workbench already, draw none for it); divider {}. "
|
||||
"Each block may have a stable id; the tool returns block ids in display order. "
|
||||
"Plain text only (no markup, no links); up to 30 blocks, 12 pictures or models a block."
|
||||
)
|
||||
|
||||
_TASK_PROP = {"type": "string", "description": "begin_task's id (optional)."}
|
||||
|
||||
|
||||
@@ -298,11 +364,9 @@ def _question_with_images(run):
|
||||
|
||||
TASK_TOOLS = dict([
|
||||
_task_tool("track_run", "Track an externally submitted run",
|
||||
"Immediately after YOU submit to this ComfyUI server via /prompt or another tool, attach the exact "
|
||||
"prompt_ids returned by your submissions to your task. This does NOT submit or rerun anything. "
|
||||
"The workbench then shows real queue/node progress and results. Never use another user's IDs or "
|
||||
"guess from queue differences. Repeating the same IDs is safe. Waits up to wait_seconds and returns "
|
||||
"state, summary, pictures and errors; if still running, keep waiting with run_status(wait_seconds=55).",
|
||||
"Only right after YOU queued a prompt another way (/prompt, a script): attaches the prompt_ids it "
|
||||
"returned to your task, so the workbench shows its progress and pictures. Runs nothing; only your own "
|
||||
"ids, never guessed; repeating them is safe. Waits up to wait_seconds, then run_status(wait_seconds=55).",
|
||||
ai_run_tracking.track_run, {"prompt_ids": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 8},
|
||||
"task": _TASK_PROP, "wait_seconds": {"type": "number", "minimum": 0, "maximum": 55, "default": 15}}, ["prompt_ids"]),
|
||||
_task_tool("begin_task", "Begin a task",
|
||||
@@ -342,7 +406,9 @@ TASK_TOOLS = dict([
|
||||
"Draws a panel in the AI workbench, in Anomalous's own style, from blocks: to compare pictures side by "
|
||||
"side, present models with their covers, lay out settings, a table, and to ask (buttons or a line of "
|
||||
"text). Use a new panel for each new phase (plan, workflow, then results), in the order they happen. "
|
||||
"Use it when a choice is the user's, or to show results better than words do. " + BLOCKS_HELP,
|
||||
"Use it when a choice is the user's, or to show results better than words do. Blocks: heading, text, "
|
||||
"images, slider, picks, diagram, models, table, list, params, choices, input; "
|
||||
"read_guide(topic='panel') gives their fields before your first panel.",
|
||||
show_panel, {"title": {"type": "string"},
|
||||
"blocks": {"type": "array", "items": {"type": "object"}, "maxItems": ai_panels.MAX_BLOCKS},
|
||||
"task": _TASK_PROP},
|
||||
@@ -350,7 +416,8 @@ TASK_TOOLS = dict([
|
||||
_task_tool("ask_user", "Ask the user",
|
||||
"Asks the user in the AI workbench and waits: a question with 2-8 options ({label, detail, "
|
||||
"recommended}; mark your suggestion recommended=true), and a line for their own answer unless "
|
||||
"other=false; no options: a free answer. Use it for every choice that is the user's, not your chat's "
|
||||
"other=false; no options: a free answer. routes=true: Anomalous's own question on the two models picked. "
|
||||
"Use it for every choice that is the user's, not your chat's "
|
||||
f"question form. Waits at least {ASK_WAIT_MIN} s, up to wait_seconds; unanswered and keep_waiting: wait on "
|
||||
"with get_answers. End every round of work with it.",
|
||||
_question_with_images(ask_user), {"question": {"type": "string"},
|
||||
@@ -358,15 +425,15 @@ TASK_TOOLS = dict([
|
||||
"label": {"type": "string"}, "detail": {"type": "string"}, "recommended": {"type": "boolean"}}}},
|
||||
"multiple": {"type": "boolean", "default": False}, "other": {"type": "boolean", "default": True},
|
||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_panels.MAX_WAIT, "default": ai_panels.MAX_WAIT},
|
||||
"task": _TASK_PROP},
|
||||
["question"]),
|
||||
"routes": {"type": "boolean"}, "task": _TASK_PROP}),
|
||||
_task_tool("propose_repaint_area", "Agree on the area to repaint",
|
||||
"Before repainting only the marked areas: the user's marks are rough, so show them the area you would "
|
||||
"repaint. marks: the marks' message id. areas: boxes or ellipses {shape, x, y, w, h} as fractions of the "
|
||||
"picture (x, y its top left), or none to keep the marks; grow: pixels added around; feather: pixels of "
|
||||
"soft edge. The user sees theirs and yours side by side and picks one, or marks again; with an area "
|
||||
"picked the answer has inpaint_image (the picture with that area as its alpha, in ComfyUI's input "
|
||||
"folder) and inpaint_note. Build the repaint yourself. Waits like ask_user.",
|
||||
"folder) and inpaint_note: add_nodes(use=\"repaint\", image=inpaint_image) builds the repaint. "
|
||||
"Waits like ask_user.",
|
||||
_question_with_images(propose_repaint_area), {"title": {"type": "string", "description": "A few words, in the user's language."},
|
||||
"marks": {"type": "string"},
|
||||
"areas": {"type": "array", "maxItems": ai_repaint.MAX_AREAS, "items": {"type": "object", "properties": {
|
||||
@@ -378,12 +445,9 @@ TASK_TOOLS = dict([
|
||||
"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_panels.MAX_WAIT, "default": ai_panels.MAX_WAIT}},
|
||||
["title", "marks"]),
|
||||
_task_tool("update_panel", "Change a panel",
|
||||
"Adds new output at the BOTTOM of an existing panel, preserving earlier content and its order. "
|
||||
"Send only the new blocks; repeated unchanged blocks are kept once. To deliberately correct an "
|
||||
"existing block or update a progress bar/question in place, include its returned block id and "
|
||||
"the same type. Omitted blocks are retained, not deleted. Never reuse an old block id for a new "
|
||||
"result: append it or use show_panel for the next phase. Up to 30 total blocks per panel. "
|
||||
"Can also change title, or set closed=true to stop answers; existing answers stay.",
|
||||
"Adds blocks at the BOTTOM of a panel, keeping what the user saw: send only the new ones. A block "
|
||||
"sent with its returned id and type is corrected in place (a table, a question). title changes "
|
||||
"it; closed=true stops answers. read_guide(topic='panel') has the rest.",
|
||||
update_panel, {"panel": {"type": "string"}, "title": {"type": "string"},
|
||||
"blocks": {"type": "array", "items": {"type": "object"}, "maxItems": ai_panels.MAX_BLOCKS},
|
||||
"closed": {"type": "boolean"}, "task": _TASK_PROP}, ["panel"]),
|
||||
@@ -391,9 +455,9 @@ TASK_TOOLS = dict([
|
||||
"Your task's latest run: none, queued (runs ahead), running (node, steps, percent), done (its "
|
||||
"pictures; the first answer telling you it ended attaches up to 4) or failed; summary says it in a "
|
||||
"sentence. wait_seconds (up to 55) waits for the end. Call it instead of guessing or looking in "
|
||||
"folders.",
|
||||
"folders. downloads=true: how the model downloads are going instead.",
|
||||
run_status, {"wait_seconds": {"type": "number", "minimum": 0, "maximum": ai_task_runs.MAX_STATUS_WAIT, "default": 0},
|
||||
"task": _TASK_PROP}),
|
||||
"task": _TASK_PROP, "downloads": {"type": "boolean"}}),
|
||||
_task_tool("show_run_map", "Show what the run really was",
|
||||
"Puts the shape of your task's latest run (or `run`, a run step id) on the user's stage: the nodes it "
|
||||
"really ran as boxes (models, LoRAs with weights, prompts, size, sampler settings, saving), linked, "
|
||||
@@ -406,12 +470,11 @@ TASK_TOOLS = dict([
|
||||
"anyone else's). When run_status warns it is far too big and the user agrees, or when the user asks.",
|
||||
stop_run, {"task": _TASK_PROP}),
|
||||
_task_tool("get_toolbox", "What the user picked for you",
|
||||
"The node types, whole custom node packs, combos, saved workflows, prompts and models the user picked in "
|
||||
"the AI workbench for this task, in full (inputs and outputs of the nodes, each pack's node types, the "
|
||||
"combos' models and prompts…), and how strictly: prefer (use them first) or only (add_node and "
|
||||
"new_workflow accept only those node types and the packs' besides ComfyUI's built-in ones). "
|
||||
"begin_task says when there are any.",
|
||||
get_toolbox, {"task": _TASK_PROP}),
|
||||
"The node types, node packs, combos, workflows, prompts and models the user picked for this task, in "
|
||||
"full, and how strictly: prefer (use them first) or only (add_node and new_workflow accept only those "
|
||||
"node types, the packs' and ComfyUI's built-in ones). begin_task says when there are any. "
|
||||
"skill: a skill begin_task lists, instead.",
|
||||
get_toolbox, {"task": _TASK_PROP, "skill": {"type": "string"}}),
|
||||
_task_tool("get_answers", "Read the user's answers and messages",
|
||||
"What the user picked or typed on your panels and wrote to you in the AI workbench since you last "
|
||||
"asked, each once (kind=change: something they changed themselves; keep it). panel: one panel. "
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
"""Which tools an AI app is offered. The address as it is offers every tool; `?tools=core` offers
|
||||
Anomalous's short set for working in the AI app's own chat, `?tools=mine` the user's own choice (kept in
|
||||
anomalous/ai_tool_sets.json, picked in the Connect an AI window). An app keeps the whole list in the AI's
|
||||
context for the whole chat, so a shorter one leaves the AI more room (issue #27). It only shortens the
|
||||
list: tools/call still takes every tool, and an unknown set name, or "mine" never chosen, offers all.
|
||||
|
||||
The short set leaves out the AI workbench (begin_task, report, ask_user…), the tools that move, scan or
|
||||
download files, and the voice; it keeps the ways and skills (get_toolbox, read_guide), the closer look
|
||||
(zoom_image) and stop_run. The instructions an app is given name only listed tools (instructions()):
|
||||
a part naming a tool the set leaves out is dropped, and without the workbench the AI is told to ask and
|
||||
explain in its own chat. The card's own tools (mcp_apps.APP_TOOLS) come along whenever a listed tool
|
||||
shows the card. Without begin_task, nothing told the AI of the ready-made ways and the skills (a Flash
|
||||
exam wired an enlargement node by node): get_situation carries them (chat_start()), every time, since a new
|
||||
chat of the same app within 20 minutes goes on in the same task and cannot be told from the old one.
|
||||
|
||||
GET /anomalous/ai_tool_sets: {core, mine, needed, tools: [{name, stage, size}], all_size};
|
||||
POST {mine: [names]} keeps the user's choice (a write: this computer only, local_guard.py).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import action_catalog, ai_skills, ai_tasks, mcp_apps, reference_blocks
|
||||
from .mcp_canvas_edit import object_info
|
||||
from .mcp_tools import ToolError, retext
|
||||
from .path_utils import atomic_write_json
|
||||
|
||||
CORE = (
|
||||
"get_situation", "describe_canvas", "find_nodes", "check_workflow_models", "run_status",
|
||||
"library_overview", "scan_report", "search_images", "get_image_info", "zoom_image", "list_combos",
|
||||
"list_workflows", "list_saved_prompts", "search_audio", "recent_activity", "search_models", "get_model",
|
||||
"set_prompt", "set_model", "add_lora", "place_combo", "download_missing_models", "run_workflow", "stop_run",
|
||||
"open_in_anomalous", "add_node", "add_nodes", "connect_nodes", "set_values", "remove_nodes", "new_workflow",
|
||||
"open_workflow", "get_toolbox", "read_guide",
|
||||
)
|
||||
NEEDED = ("begin_task", "report", "ask_user", "get_answers") # what a task handed over in Anomalous needs
|
||||
CHAT_NOTE = ("This connection has no workbench tools: ask the user and explain in your own chat, also where an "
|
||||
"answer or a skill says ask_user or report.")
|
||||
WAYS_NOTE = ("add_nodes(use=…, want=…) builds the best one this ComfyUI can run, wired in: prefer it to wiring "
|
||||
"nodes yourself. A plan of several steps waits for the user in Anomalous, so here build them one at a time.")
|
||||
_mine = None # the user's choice, read once
|
||||
|
||||
|
||||
def _path():
|
||||
return os.path.join(os.path.dirname(ai_tasks.tasks_dir()), "ai_tool_sets.json")
|
||||
|
||||
|
||||
def mine():
|
||||
"""The tools the user picked, in the registry's order; () before they ever picked."""
|
||||
global _mine
|
||||
if _mine is None:
|
||||
try:
|
||||
with open(_path(), encoding="utf-8") as source:
|
||||
stored = json.load(source).get("mine")
|
||||
except (OSError, ValueError, AttributeError):
|
||||
stored = None
|
||||
_mine = tuple(name for name in stored if isinstance(name, str)) if isinstance(stored, list) else ()
|
||||
return _mine
|
||||
|
||||
|
||||
def save_mine(names, tools):
|
||||
"""Keeps the user's choice: names of offered tools (the card's own come along by themselves)."""
|
||||
global _mine
|
||||
if not isinstance(names, list):
|
||||
raise ValueError("Expected {mine: [tool names]}")
|
||||
picked = tuple(name for name in tools if name in names and name not in mcp_apps.APP_TOOLS)
|
||||
if not picked:
|
||||
raise ValueError("Pick at least one tool")
|
||||
atomic_write_json(_path(), {"mine": list(picked)})
|
||||
_mine = picked
|
||||
return picked
|
||||
|
||||
|
||||
def chosen(value, tools):
|
||||
"""The names the address's ?tools= offers, or None for every tool."""
|
||||
picked = CORE if value == "core" else mine() if value == "mine" else ()
|
||||
names = {name for name in picked if name in tools}
|
||||
if not names:
|
||||
return None
|
||||
if names & set(mcp_apps.CARD_TOOLS):
|
||||
names |= set(mcp_apps.APP_TOOLS)
|
||||
return frozenset(names)
|
||||
|
||||
|
||||
def instructions(parts, names, tools):
|
||||
"""The server's instructions for the set `names` (None: all of them, as written)."""
|
||||
if names is None:
|
||||
return "".join(parts)
|
||||
pattern = re.compile(r"(?<![\w/])(" + "|".join(sorted(map(re.escape, tools), key=len, reverse=True)) + r")(?!\w)")
|
||||
kept = [part for part in parts if set(pattern.findall(part)) <= names]
|
||||
text = "".join(kept).rstrip()
|
||||
if text.endswith(";"):
|
||||
text = text[:-1] + "."
|
||||
if not set(NEEDED) <= names:
|
||||
text = f"{text} {CHAT_NOTE}"
|
||||
return text
|
||||
|
||||
|
||||
def chat_start(result, name, names, ctx, private):
|
||||
"""In a set without begin_task, get_situation also says what begin_task would: the
|
||||
ready-made ways this ComfyUI can run (a plan of steps is left to Anomalous's own tasks) and the skills
|
||||
(not in a private task, as begin_task)."""
|
||||
data = result.get("structuredContent") if isinstance(result, dict) and not result.get("isError") else None
|
||||
if names is None or "begin_task" in names or name != "get_situation" or not isinstance(data, dict):
|
||||
return result
|
||||
try:
|
||||
info = object_info(ctx)
|
||||
except ToolError:
|
||||
info = None
|
||||
extra = {"blocks": {"note": WAYS_NOTE, **reference_blocks.directory(info)}}
|
||||
if not private and ai_skills.directory():
|
||||
extra["skills"] = ai_skills.directory()
|
||||
retext(result, {**data, **extra})
|
||||
return result
|
||||
|
||||
|
||||
def register_routes(app, tools, sizes):
|
||||
"""GET/POST /anomalous/ai_tool_sets; `sizes()` gives {name: characters of its listed spec}."""
|
||||
def answer():
|
||||
size = sizes()
|
||||
offered = [name for name in tools if name not in mcp_apps.APP_TOOLS]
|
||||
return web.json_response({
|
||||
"core": list(CORE), "mine": list(mine()), "needed": list(NEEDED), "all_size": sum(size.values()),
|
||||
"card": {"tools": list(mcp_apps.CARD_TOOLS), "size": sum(size[name] for name in mcp_apps.APP_TOOLS)},
|
||||
"tools": [{"name": name, "stage": action_catalog.TOOLS[name].stage if name in action_catalog.TOOLS else None,
|
||||
"size": size[name]} for name in offered],
|
||||
})
|
||||
|
||||
async def api_tool_sets(request):
|
||||
if request.method == "POST":
|
||||
try:
|
||||
body = await request.json()
|
||||
except (ValueError, UnicodeDecodeError):
|
||||
body = None
|
||||
try:
|
||||
save_mine((body or {}).get("mine") if isinstance(body, dict) else None, tools)
|
||||
except ValueError as error:
|
||||
return web.json_response({"error": str(error)}, status=400)
|
||||
return answer()
|
||||
|
||||
app.router.add_get("/anomalous/ai_tool_sets", api_tool_sets)
|
||||
app.router.add_post("/anomalous/ai_tool_sets", api_tool_sets)
|
||||
+36
-34
@@ -20,7 +20,6 @@ import os
|
||||
import re
|
||||
|
||||
import folder_paths
|
||||
from PIL import Image
|
||||
|
||||
from .activity_log import list_entries
|
||||
from .audio_catalog import _gallery_page as _audio_page
|
||||
@@ -30,18 +29,22 @@ from .kept_images import _kept_images
|
||||
from .material_store import _list_materials, _read_material, get_materials_dir
|
||||
from .media_routes import _build_card_thumbnail
|
||||
from .metadata import get_metadata
|
||||
from .picture_mask import input_facts
|
||||
from .mcp_arguments import prepare_arguments, renaming_note
|
||||
from .model_catalog import _iter_search_models
|
||||
from .model_constants import MEDIA_EXTENSIONS, MODEL_EXTENSIONS, PREVIEW_SUFFIXES
|
||||
from .notebooks import _list_notebooks
|
||||
from .parameters import _read_parameter_notebooks, get_parameters_dir
|
||||
from .path_utils import model_file_within, resolve_folder_subdir, resolve_within
|
||||
from .recipe_store import _list_recipes, get_recipes_dir
|
||||
from .scan_report import last_scan_path
|
||||
from .scan_summary import _summarize
|
||||
from . import example_workflows, workflow_route
|
||||
|
||||
MAX_LIMIT = 50
|
||||
MAX_SEARCH_PICTURES = 6 # search_images(with_pictures): the first few attached
|
||||
MAX_TEXT = 1500
|
||||
MAX_PLANS = 12 # 方案 listed for one workflow
|
||||
MAX_IMAGE_BYTES = 1_500_000
|
||||
IMAGE_TYPES = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp"}
|
||||
SOURCES = {"civitai": "civitai", "local": "inferred_from_file", "": "not_scanned"}
|
||||
@@ -136,32 +139,12 @@ def _resolve_folder_image(image, folder):
|
||||
return rel, path
|
||||
|
||||
|
||||
def _input_facts(path):
|
||||
"""Size of an input or preview picture and, when it has an alpha, the area LoadImage gives as MASK (1 - alpha)."""
|
||||
try:
|
||||
with Image.open(path) as picture:
|
||||
facts = {"width": picture.width, "height": picture.height}
|
||||
if "A" not in picture.getbands():
|
||||
facts["mask"] = "none: no alpha, so LoadImage's MASK is empty"
|
||||
return facts
|
||||
area = picture.getchannel("A").point(lambda value: 255 if value < 128 else 0)
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
return {"note": "The picture cannot be read."}
|
||||
box = area.getbbox()
|
||||
if not box:
|
||||
facts["mask"] = "none: fully opaque, so LoadImage's MASK is empty"
|
||||
return facts
|
||||
share = area.histogram()[255] / (facts["width"] * facts["height"])
|
||||
left, top, right, bottom = (round(100 * value / size) for value, size in zip(box, (facts["width"], facts["height"]) * 2))
|
||||
facts["mask"] = {"share": f"{round(100 * share, 1)}%", "area": f"x {left}-{right}%, y {top}-{bottom}% of the picture",
|
||||
"meaning": "LoadImage gives this area as MASK: what to repaint"}
|
||||
return facts
|
||||
|
||||
|
||||
def _image_content(path):
|
||||
"""An MCP image item: the 512 px thumbnail the gallery uses, or None when too big or unreadable."""
|
||||
thumb = _build_card_thumbnail(path)
|
||||
if not thumb:
|
||||
return None
|
||||
try:
|
||||
thumb = _build_card_thumbnail(path)
|
||||
with open(thumb, "rb") as source:
|
||||
data = source.read(MAX_IMAGE_BYTES + 1)
|
||||
except OSError:
|
||||
@@ -185,6 +168,7 @@ def _model_brief(folder_type, path_idx, base_dir, file_path, meta):
|
||||
except OSError:
|
||||
size_mb = None
|
||||
return {
|
||||
**({"note_for_ai": meta["ai_note"]} if meta.get("ai_note") else {}), # the user's words on what it is for
|
||||
"model_id": _model_id(folder_type, path_idx, base_dir, file_path),
|
||||
"name": meta.get("custom_name") or meta.get("name") or os.path.basename(file_path),
|
||||
"file": os.path.basename(file_path),
|
||||
@@ -226,7 +210,7 @@ def search_models(query="", type="", base_model="", source="", limit=20):
|
||||
if source and SOURCES.get(meta.get("info_source", ""), "not_scanned") != source:
|
||||
continue
|
||||
if not _matches(terms, os.path.relpath(file_path, base_dir), meta.get("name"), meta.get("custom_name"),
|
||||
meta.get("baseModel"), " ".join(meta.get("trainedWords") or [])):
|
||||
meta.get("baseModel"), meta.get("ai_note"), " ".join((meta.get("trainedWords") or []) + (meta.get("headerWords") or []))):
|
||||
continue
|
||||
total += 1
|
||||
if len(found) < _limit(limit):
|
||||
@@ -333,7 +317,7 @@ def search_images(query="", limit=20, page=1, with_pictures=False):
|
||||
def get_image_info(image, include_image=False, folder="output"):
|
||||
if folder != "output":
|
||||
rel, path = _resolve_folder_image(image, folder)
|
||||
info = {"image": rel, "folder": folder, "created": _when(os.path.getmtime(path)), **_input_facts(path)}
|
||||
info = {"image": rel, "folder": folder, "created": _when(os.path.getmtime(path)), **input_facts(path)}
|
||||
extra = [item for item in [_image_content(path)] if include_image and item]
|
||||
if include_image:
|
||||
info["image_attached"] = bool(extra)
|
||||
@@ -360,6 +344,8 @@ def get_image_info(image, include_image=False, folder="output"):
|
||||
inputs[key] = value
|
||||
nodes.append({"id": node_id, "type": node.get("class_type"), "inputs": inputs})
|
||||
info.update(models_used=sorted(models), nodes=nodes)
|
||||
if chunks.get("workflow"):
|
||||
info["to_change_it"] = f"open_workflow(workflow='{rel}') opens the workflow that made it, as it was"
|
||||
elif chunks.get("parameters"):
|
||||
info["parameters"] = _text(chunks["parameters"], 4000)
|
||||
else:
|
||||
@@ -427,19 +413,35 @@ def list_combos(query="", limit=20, *, exact_name=False):
|
||||
return {"total_matches": len(found), "combos": found[:_limit(limit)]}
|
||||
|
||||
|
||||
def plans():
|
||||
"""Each saved workflow's 方案: the parameter sets saved under it with values unlike its own (saving a workflow
|
||||
keeps one equal to them, which the Workflows page leaves out too): {workflow file name: [(set file name, set name)]}."""
|
||||
own = {recipe["filename"]: (recipe["data"].get("parameter_signature") or {}).get("value") for recipe in _list_recipes(get_recipes_dir())}
|
||||
out = {}
|
||||
for notebook in _read_parameter_notebooks(get_parameters_dir()):
|
||||
workflow = notebook["data"].get("recipe_filename")
|
||||
if workflow in own and not (own[workflow] and (notebook["data"].get("parameter_signature") or {}).get("value") == own[workflow]):
|
||||
out.setdefault(workflow, []).append((notebook["filename"], notebook["name"]))
|
||||
return out
|
||||
|
||||
|
||||
def list_workflows(query="", limit=20, *, exact_name=False):
|
||||
terms, found = _terms(query), []
|
||||
for recipe in _list_recipes(get_recipes_dir()):
|
||||
terms, found, saved_plans = _terms(query), [], plans()
|
||||
for recipe in _list_recipes(get_recipes_dir(), with_workflow=True):
|
||||
data = recipe.get("data") or {}
|
||||
name = data.get("name") or recipe["filename"]
|
||||
params = json.dumps(data.get("params") or {}, ensure_ascii=False)
|
||||
if exact_name and (data.get("name") or recipe["filename"]) != query:
|
||||
own = [f"{name} / {plan}" for _file, plan in saved_plans.get(recipe["filename"], [])][:MAX_PLANS]
|
||||
if exact_name and name != query:
|
||||
continue
|
||||
if not exact_name and not _matches(terms, data.get("name"), " ".join(data.get("tags") or []), data.get("notes"), params):
|
||||
if not exact_name and not _matches(terms, name, " ".join(data.get("tags") or []), data.get("notes"), params, " ".join(own)):
|
||||
continue
|
||||
found.append({"name": data.get("name") or recipe["filename"], "tags": data.get("tags") or [],
|
||||
found.append({"name": name, "route": workflow_route.line(data.get("workflow")), "tags": data.get("tags") or [],
|
||||
"notes": _text(data.get("notes"), 500), "saved": _when((data.get("timestamp") or 0) / 1000),
|
||||
"settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…"})
|
||||
return {"total_matches": len(found), "workflows": found[:_limit(limit)]}
|
||||
"settings": params if len(params) <= MAX_TEXT else params[:MAX_TEXT] + "…",
|
||||
**({"plans": own} if own else {})}) # run_workflow(workflow=one of them) runs it with that set
|
||||
fits = (lambda item: item["name"] == query) if exact_name else (lambda item: _matches(terms, item["name"], item["title"], item["notes"]))
|
||||
return example_workflows.answer(found, fits, _limit(limit))
|
||||
|
||||
|
||||
def saved_prompts():
|
||||
@@ -515,7 +517,7 @@ TOOLS = dict([
|
||||
"structure\") model nodes and text nodes saved from the canvas with their links, listed by slot.", list_combos,
|
||||
{"query": _QUERY, "limit": _LIMIT}),
|
||||
_tool("list_workflows", "Workflow recipes",
|
||||
"The user's saved workflow recipes with tags, notes and their main settings.", list_workflows,
|
||||
"The user's saved workflow recipes: route, tags, notes, plans; settings when few match. `examples`: official ones by name, opened and run like saved ones.", list_workflows,
|
||||
{"query": _QUERY, "limit": _LIMIT}),
|
||||
_tool("list_saved_prompts", "Saved prompts",
|
||||
"Prompts the user saved in the Prompt Studio, with positive and negative text.", list_saved_prompts,
|
||||
|
||||
+100
-4
@@ -3,15 +3,21 @@
|
||||
Card images are 512 px WebP copies of the covers (a video cover gets one of its first
|
||||
frame, the poster), kept in the user folder so they survive ComfyUI restarts (ComfyUI
|
||||
empties its temp folder on every start). Listing a folder queues the covers not made yet
|
||||
for one background worker, so scrolling and the next visit find them ready.
|
||||
for one background worker, so scrolling and the next visit find them ready. A picture past
|
||||
Pillow's pixel limit (a 4x upscale of a big one) is shrunk once in a child process
|
||||
(huge_picture.py), so ComfyUI's own memory never holds it; a card never falls back to a large
|
||||
original, it gets a placeholder with the picture's size instead.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import struct
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
@@ -28,6 +34,14 @@ CARD_THUMBNAIL_CACHE_LIMIT = 256 * 1024 * 1024
|
||||
CARD_THUMBNAIL_STATIC_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.webp', '.avif'}
|
||||
CARD_POSTER_VIDEO_EXTENSIONS = {'.mp4', '.webm', '.mov', '.avi', '.mkv'}
|
||||
CARD_WARM_QUEUE_LIMIT = 512
|
||||
ORIGINAL_CARD_BYTES = 8 * 1024 * 1024 # an unreadable picture this small is shown as it is
|
||||
HUGE_PICTURE_PIXELS = 400_000_000 # the most a child process decodes (about 1.2 GB as RGB)
|
||||
HUGE_DECODE_SECONDS = 180
|
||||
_HUGE_SCRIPT = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'huge_picture.py')
|
||||
_TOO_LARGE = object() # _build_card_thumbnail_impl: past Pillow's limit, for the child process
|
||||
_huge_lock = threading.Lock() # one child decode at a time
|
||||
_huge_failed = set() # cache paths a child could not make: not tried again for that file
|
||||
log = logging.getLogger(__name__)
|
||||
_thumbnail_cleanup_lock = threading.Lock()
|
||||
_thumbnail_generation_slots = threading.BoundedSemaphore(2)
|
||||
_thumbnail_last_cleanup = 0.0
|
||||
@@ -173,14 +187,34 @@ def _save_card_image(image, cache_dir, cached_path):
|
||||
_prune_thumbnail_cache(cache_dir, created=True)
|
||||
|
||||
|
||||
def picture_size(path):
|
||||
"""(width, height) of a picture from its header without decoding it (past Pillow's pixel
|
||||
limit too, for PNG); None when unknown."""
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
with open(path, 'rb') as file:
|
||||
head = file.read(24)
|
||||
if head[:8] == b'\x89PNG\r\n\x1a\n' and head[12:16] == b'IHDR':
|
||||
return struct.unpack('>II', head[16:24])
|
||||
with Image.open(path) as image:
|
||||
return image.size
|
||||
except (OSError, ValueError, Image.DecompressionBombError):
|
||||
return None
|
||||
|
||||
|
||||
def _build_card_thumbnail_impl(source_path):
|
||||
"""Return a cached 512px WebP card image, or the original on any safe fallback."""
|
||||
"""A cached 512px WebP card image; the original when it is small already (or an animation);
|
||||
_TOO_LARGE past Pillow's pixel limit; None when it cannot be read and is not small."""
|
||||
try:
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
cache_dir, cached_path = _cached_card_path(source_path)
|
||||
if _reuse_cached(cache_dir, cached_path):
|
||||
return cached_path
|
||||
size = picture_size(source_path)
|
||||
if size and size[0] * size[1] > Image.MAX_IMAGE_PIXELS:
|
||||
return _TOO_LARGE
|
||||
|
||||
with Image.open(source_path) as image:
|
||||
if getattr(image, 'is_animated', False):
|
||||
@@ -191,13 +225,73 @@ def _build_card_thumbnail_impl(source_path):
|
||||
_save_card_image(image, cache_dir, cached_path)
|
||||
return cached_path
|
||||
except Exception:
|
||||
return source_path
|
||||
try:
|
||||
return source_path if os.path.getsize(source_path) <= ORIGINAL_CARD_BYTES else None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _build_card_thumbnail(source_path):
|
||||
"""The card image of a picture (_build_card_thumbnail_impl), one past Pillow's pixel limit
|
||||
made by a child process (shrunk_in_child). None: show picture_placeholder instead."""
|
||||
# Bound simultaneous decodes so opening a large folder cannot monopolize CPU/RAM.
|
||||
with _thumbnail_generation_slots:
|
||||
return _build_card_thumbnail_impl(source_path)
|
||||
made = _build_card_thumbnail_impl(source_path)
|
||||
# Outside the slots: a huge decode takes long and must not hold the other cards up.
|
||||
return shrunk_in_child(source_path, CARD_THUMBNAIL_EDGE) if made is _TOO_LARGE else made
|
||||
|
||||
|
||||
def shrunk_in_child(source_path, edge, kind=''):
|
||||
"""A cached WebP of a very large picture, at most `edge` px, made once by a child process at
|
||||
low priority (its memory goes back when it ends), one at a time. None past
|
||||
HUGE_PICTURE_PIXELS, or when it failed for this file before."""
|
||||
size = picture_size(source_path)
|
||||
if not size or size[0] * size[1] > HUGE_PICTURE_PIXELS:
|
||||
return None
|
||||
try:
|
||||
cache_dir, cached_path = _cached_card_path(source_path, kind)
|
||||
except OSError:
|
||||
return None
|
||||
with _huge_lock:
|
||||
if _reuse_cached(cache_dir, cached_path):
|
||||
return cached_path
|
||||
if cached_path in _huge_failed:
|
||||
return None
|
||||
temp_path = f"{cached_path}.{os.getpid()}.huge.tmp"
|
||||
flags = getattr(subprocess, 'BELOW_NORMAL_PRIORITY_CLASS', 0) | getattr(subprocess, 'CREATE_NO_WINDOW', 0)
|
||||
try:
|
||||
subprocess.run([sys.executable, '-I', _HUGE_SCRIPT, source_path, temp_path, str(edge), str(HUGE_PICTURE_PIXELS)],
|
||||
check=True, capture_output=True, timeout=HUGE_DECODE_SECONDS, creationflags=flags)
|
||||
os.replace(temp_path, cached_path)
|
||||
except (OSError, subprocess.SubprocessError) as error:
|
||||
_huge_failed.add(cached_path)
|
||||
log.warning("Anomalous: could not shrink the %dx%d picture %s: %s", size[0], size[1],
|
||||
os.path.basename(source_path), (getattr(error, 'stderr', None) or b'')[-300:] or error)
|
||||
return None
|
||||
finally:
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
_prune_thumbnail_cache(cache_dir, created=True)
|
||||
return cached_path
|
||||
|
||||
|
||||
def picture_placeholder(source_path):
|
||||
"""The card of a picture with no card image (too large, unreadable): its pixel size and file
|
||||
size on a plain tile, numbers only (nothing to translate)."""
|
||||
size = picture_size(source_path)
|
||||
try:
|
||||
megabytes = f"{os.path.getsize(source_path) / 1048576:.0f} MB"
|
||||
except OSError:
|
||||
megabytes = ""
|
||||
pixels = f"{size[0]} × {size[1]}" if size else ""
|
||||
svg = ('<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">'
|
||||
'<rect width="512" height="512" fill="#2b2f36"/>'
|
||||
'<g fill="none" stroke="#8b93a1" stroke-width="10" stroke-linejoin="round">'
|
||||
'<rect x="176" y="150" width="160" height="130" rx="14"/><path d="m186 262 46-44 34 30 28-22 42 36"/></g>'
|
||||
'<circle cx="226" cy="196" r="14" fill="#8b93a1"/>'
|
||||
f'<text x="256" y="340" fill="#d5d9e0" font-family="sans-serif" font-size="30" text-anchor="middle">{pixels}</text>'
|
||||
f'<text x="256" y="382" fill="#8b93a1" font-family="sans-serif" font-size="26" text-anchor="middle">{megabytes}</text></svg>')
|
||||
return web.Response(body=svg.encode('utf-8'), content_type='image/svg+xml', headers={'Cache-Control': 'no-cache'})
|
||||
|
||||
|
||||
def _build_video_poster_impl(source_path):
|
||||
@@ -313,6 +407,8 @@ async def api_serve_image(request):
|
||||
variant = request.query.get('variant')
|
||||
if variant == 'card' and ext in CARD_THUMBNAIL_STATIC_EXTENSIONS:
|
||||
served_path = await asyncio.to_thread(_build_card_thumbnail, file_path)
|
||||
if served_path is None:
|
||||
return picture_placeholder(file_path)
|
||||
if served_path != file_path:
|
||||
content_type = 'image/webp'
|
||||
elif variant == 'poster':
|
||||
|
||||
+6
-1
@@ -19,7 +19,7 @@ except ImportError:
|
||||
from .model_heads import inspect as inspect_file # the header, read once per file version
|
||||
|
||||
# Fields the model editor sets: <model>.anomalous.json key -> metadata key.
|
||||
USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url"}
|
||||
USER_FIELDS = {"custom_name": "custom_name", "custom_notes": "custom_notes", "source_url": "source_url", "ai_note": "ai_note"}
|
||||
|
||||
def _select_info_file(data, file_path):
|
||||
"""Select the Civitai file entry that actually describes ``file_path``.
|
||||
@@ -105,6 +105,7 @@ def _read_metadata(file_path):
|
||||
"description": "",
|
||||
"notes": "",
|
||||
"trainedWords": [],
|
||||
"headerWords": [], # no trigger words from Civitai: what the LoRA's header suggests (header_words.py), a guess
|
||||
"baseModel": "",
|
||||
"civitai_url": "",
|
||||
"source_url": "",
|
||||
@@ -114,6 +115,7 @@ def _read_metadata(file_path):
|
||||
"hash_source": "",
|
||||
"custom_name": "",
|
||||
"custom_notes": "",
|
||||
"ai_note": "", # the user's words for an AI on what it is for; a private task hiding model names gives it too
|
||||
"version_name": "", # the Civitai version ("v2.0", "Illustrious"…), told apart on the card
|
||||
"base_guess": "", # no base model known yet (not scanned): what the file's header tells
|
||||
# "civitai" (matched), "local" (only what the file tells: no match, or offline) or "" (not scanned)
|
||||
@@ -169,6 +171,9 @@ def _read_metadata(file_path):
|
||||
if description: metadata["description"] = description
|
||||
if notes: metadata["notes"] = notes
|
||||
if trained_words: metadata["trainedWords"] = trained_words
|
||||
kept_words = data.get("anomalous_header_words")
|
||||
if not trained_words and isinstance(kept_words, list):
|
||||
metadata["headerWords"] = [str(word) for word in kept_words if str(word).strip()]
|
||||
if base_model: metadata["baseModel"] = base_model
|
||||
if civitai_url: metadata["civitai_url"] = civitai_url
|
||||
if model_id is not None: metadata["model_id"] = model_id
|
||||
|
||||
+42
-13
@@ -1,14 +1,16 @@
|
||||
"""What Anomalous knows about using a model well, told to the AI so it need not guess:
|
||||
|
||||
- the settings its author's example pictures were made with (the Civitai record a scan keeps
|
||||
beside the file: images[].meta), summed up for get_model: how many pictures, the sampler and
|
||||
beside the file: images[].meta), summed up for get_model and the model details
|
||||
(example_settings; author_examples says it in words): how many pictures, the sampler and
|
||||
scheduler most used, the range of steps and CFG, the most used size and, for a LoRA, the
|
||||
weight it was used at: by its file hash, name or Civitai version, else the LoRA most of the
|
||||
pictures name (their names are the author's own files, and often an older version's);
|
||||
- whether a LoRA suits the main model it is put on: add_lora and set_model compare their base
|
||||
models (the scan's, else what the file's header tells) and say so when the families differ
|
||||
(an SD 1.5 LoRA on an SDXL checkpoint does nothing useful) or, more softly, when two bases of one
|
||||
family differ (a Pony LoRA on an Illustrious checkpoint). A note only: the call is made.
|
||||
family differ (a Pony LoRA on an Illustrious checkpoint). A note only: the call is made;
|
||||
- how large the main model is (main_params), for advice that depends on it (ai_skills.py).
|
||||
"""
|
||||
|
||||
import json
|
||||
@@ -19,10 +21,16 @@ import folder_paths
|
||||
|
||||
from . import metadata
|
||||
|
||||
try:
|
||||
from ..model_kind import diffusion_params
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_kind import diffusion_params
|
||||
|
||||
# Base model names (Civitai's, or what model_kind.py infers) by the architecture their files share.
|
||||
FAMILIES = (
|
||||
("SDXL", ("sdxl", "pony", "illustrious", "noob")), ("SD 1.x", ("sd 1",)), ("SD 2.x", ("sd 2",)),
|
||||
("SD 3", ("sd 3", "sd3")), ("Flux.1", ("flux.1", "flux 1", "chroma")), ("Flux.2", ("flux.2", "flux 2")),
|
||||
("Z-Image", ("z-image", "zimage", "z image")),
|
||||
)
|
||||
# Bases trained far from their family's: a LoRA of one on another of the family loads but may look off.
|
||||
OWN_WAYS = ("pony", "illustrious", "noob", "chroma")
|
||||
@@ -59,6 +67,19 @@ def _main_base(value):
|
||||
return ""
|
||||
|
||||
|
||||
def main_params(value):
|
||||
"""How many parameters the drawing model of a main loader's choice (its drop-down value) has, from
|
||||
its header; 0 when it is not found or its header cannot tell."""
|
||||
for folder_type in MAIN_TYPES:
|
||||
try:
|
||||
path = folder_paths.get_full_path(folder_type, value)
|
||||
except (KeyError, TypeError):
|
||||
path = None
|
||||
if path:
|
||||
return diffusion_params(path)
|
||||
return 0
|
||||
|
||||
|
||||
def lora_paths(values):
|
||||
"""The files of LoRA drop-down values that ComfyUI can find."""
|
||||
paths = []
|
||||
@@ -105,13 +126,10 @@ def _span(numbers):
|
||||
return say(low) if low == high else f"{say(low)}–{say(high)}"
|
||||
|
||||
|
||||
def _common(values, total):
|
||||
"""'euler (6 of 10)': the most used value and in how many of the `total` pictures."""
|
||||
def _most(values):
|
||||
"""[the most used value, how many times], or None when there is none."""
|
||||
counted = Counter(value for value in values if value)
|
||||
if not counted:
|
||||
return ""
|
||||
value, times = counted.most_common(1)[0]
|
||||
return str(value) if times == total else f"{value} ({times} of {total})"
|
||||
return list(counted.most_common(1)[0]) if counted else None
|
||||
|
||||
|
||||
def _loras(meta):
|
||||
@@ -158,8 +176,10 @@ def _record(file_path):
|
||||
return None
|
||||
|
||||
|
||||
def author_examples(file_path, folder_type, sha256=""):
|
||||
"""What the author's example pictures were made with, summed up; {} when nothing is kept."""
|
||||
def example_settings(file_path, folder_type, sha256=""):
|
||||
"""What the author's example pictures were made with: {"pictures": n, "sampler" / "scheduler" /
|
||||
"size": [the most used, in how many], "steps" / "cfg" / "clip_skip" / "lora_weight": "25" or
|
||||
"20–30"}, unknown ones left out; {} when nothing is kept."""
|
||||
record = _record(file_path)
|
||||
metas = [image.get("meta") for image in (record or {}).get("images") or []
|
||||
if isinstance(image, dict) and isinstance(image.get("meta"), dict)][:MAX_EXAMPLES]
|
||||
@@ -169,14 +189,23 @@ def author_examples(file_path, folder_type, sha256=""):
|
||||
for meta in metas]
|
||||
found = {
|
||||
"pictures": len(metas),
|
||||
"sampler": _common([str(meta.get("sampler") or "") for meta in metas if str(meta.get("sampler")).lower() != "undefined"], len(metas)),
|
||||
"scheduler": _common([str(meta.get("scheduler") or meta.get("Schedule type") or "") for meta in metas], len(metas)),
|
||||
"sampler": _most([str(meta.get("sampler") or "") for meta in metas if str(meta.get("sampler")).lower() != "undefined"]),
|
||||
"scheduler": _most([str(meta.get("scheduler") or meta.get("Schedule type") or "") for meta in metas]),
|
||||
"steps": _span([n for n in (_number(meta.get("steps")) for meta in metas) if n is not None]),
|
||||
"cfg": _span([n for n in (_number(meta.get("cfgScale")) for meta in metas) if n is not None]),
|
||||
"size": _common([str(size) for size in sizes], len(metas)),
|
||||
"size": _most([str(size) for size in sizes]),
|
||||
"clip_skip": _span([n for n in (_number(meta.get("clipSkip") or meta.get("Clip skip")) for meta in metas) if n is not None]),
|
||||
}
|
||||
if folder_type == "loras":
|
||||
own = {(sha256 or "")[:10].lower(), os.path.splitext(os.path.basename(file_path))[0].lower(), f"v{record.get('id')}"} - {""}
|
||||
found["lora_weight"] = _span(_lora_weights(metas, own))
|
||||
return {key: value for key, value in found.items() if value}
|
||||
|
||||
|
||||
def author_examples(file_path, folder_type, sha256=""):
|
||||
"""example_settings in words for the AI: "euler (6 of 10)" for a value not all pictures share."""
|
||||
found = example_settings(file_path, folder_type, sha256)
|
||||
for key, value in found.items():
|
||||
if isinstance(value, list):
|
||||
found[key] = str(value[0]) if value[1] == found["pictures"] else f"{value[0]} ({value[1]} of {found['pictures']})"
|
||||
return found
|
||||
|
||||
+18
-1
@@ -6,6 +6,8 @@ POST /anomalous/download/lookup {items: [{key, hash, url, value, types}]} -> wh
|
||||
POST /anomalous/download/start {download_url, type, root, rel, sha256, size, ...} -> {id}
|
||||
GET /anomalous/download/status -> {jobs}
|
||||
POST /anomalous/download/cancel {id}
|
||||
GET /anomalous/download/versions?model_id= -> a Civitai model page's versions
|
||||
(download_sources.civitai_versions), for the versions not downloaded yet; asks Civitai, so this computer only.
|
||||
GET/POST /anomalous/download/settings -> where downloads go ({place, folder}), kept in
|
||||
user/anomalous/download_settings.json; imported models (model_import.py) follow the same folder.
|
||||
|
||||
@@ -32,7 +34,8 @@ import folder_paths
|
||||
from . import hf_card, manager_catalog
|
||||
from .activity_log import add_entry, log_path
|
||||
from .download_sources import (
|
||||
SHA256, USER_AGENT, allowed_download_url, find_source, host_of, is_civitai, same_file, _load_api_key,
|
||||
SHA256, USER_AGENT, SourceUnreachable, allowed_download_url, civitai_versions, find_source, host_of, is_civitai,
|
||||
same_file, _load_api_key,
|
||||
)
|
||||
from .path_utils import atomic_write_json, resolve_within
|
||||
|
||||
@@ -435,10 +438,24 @@ async def api_cancel(request):
|
||||
return web.json_response(_public(job))
|
||||
|
||||
|
||||
async def api_versions(request):
|
||||
model_id = request.query.get("model_id", "")
|
||||
if not model_id.isdigit() or int(model_id) <= 0:
|
||||
return web.json_response({"error": "bad_request"}, status=400)
|
||||
try:
|
||||
found = await asyncio.to_thread(civitai_versions, int(model_id))
|
||||
except SourceUnreachable as error:
|
||||
return web.json_response({"error": "unreachable", "message": str(error)}, status=502)
|
||||
if found is None:
|
||||
return web.json_response({"error": "not_found"}, status=404)
|
||||
return web.json_response(found)
|
||||
|
||||
|
||||
def register_routes(app):
|
||||
app.router.add_post("/anomalous/download/lookup", api_lookup)
|
||||
app.router.add_post("/anomalous/download/start", api_start)
|
||||
app.router.add_get("/anomalous/download/status", api_status)
|
||||
app.router.add_post("/anomalous/download/cancel", api_cancel)
|
||||
app.router.add_get("/anomalous/download/versions", api_versions)
|
||||
app.router.add_get("/anomalous/download/settings", api_settings)
|
||||
app.router.add_post("/anomalous/download/settings", api_settings)
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
"""What using a model takes besides its file, for the model details (GET /anomalous/model_guide)
|
||||
and the AI's get_model: the settings of its author's example pictures (model_advice), the words
|
||||
its own header suggests when Civitai gave no trigger words (header_words.py: kept by the scan,
|
||||
else read now), and, by its base model family (model_profiles.json), the files a drawing model
|
||||
loaded in parts needs beside it, with the one this ComfyUI has by that name, and notes for the AI.
|
||||
The page names a model by type, folder index, subfolder and file name, never by a path."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from . import model_advice
|
||||
from .metadata import get_metadata
|
||||
from .model_constants import MODEL_EXTENSIONS
|
||||
from .path_utils import model_file_within, resolve_folder_subdir
|
||||
|
||||
try:
|
||||
from ..header_words import of_file as header_words_of
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from header_words import of_file as header_words_of
|
||||
|
||||
with open(os.path.join(os.path.dirname(os.path.abspath(__file__)), "model_profiles.json"), encoding="utf-8") as _source:
|
||||
FAMILIES = json.load(_source)["families"]
|
||||
PART_FOLDERS = ("diffusion_models", "unet") # a checkpoint carries its text encoders and VAE
|
||||
|
||||
|
||||
def profile(base):
|
||||
"""The family whose marks start `base` (a base model name), else None."""
|
||||
lowered = str(base or "").strip().lower()
|
||||
return next((family for family in FAMILIES if lowered and lowered.startswith(tuple(family["marks"]))), None)
|
||||
|
||||
|
||||
def _plain(name):
|
||||
return re.sub(r"[^a-z0-9]", "", os.path.splitext(os.path.basename(name))[0].lower())
|
||||
|
||||
|
||||
def _found(part):
|
||||
"""The first file in the part's folder with one of its names in its own, "" when none."""
|
||||
try:
|
||||
listed = folder_paths.get_filename_list(part["folder"])
|
||||
except KeyError:
|
||||
return ""
|
||||
for name in listed:
|
||||
plain = _plain(name)
|
||||
if any(plain == wanted or (len(wanted) > 3 and wanted in plain) for wanted in part["names"]):
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
def parts(folder_type, base):
|
||||
"""[{"folder", "file", "found"}]: what a drawing model loaded in parts needs beside it; [] for others."""
|
||||
family = profile(base)
|
||||
if folder_type not in PART_FOLDERS or not family:
|
||||
return []
|
||||
return [{"folder": part["folder"], "file": part["file"], "found": _found(part)} for part in family.get("parts", [])]
|
||||
|
||||
|
||||
def words(path, folder_type, meta):
|
||||
"""What a LoRA's header suggests when Civitai gave no trigger words: kept by the scan, else read now."""
|
||||
if folder_type != "loras" or meta.get("trainedWords"):
|
||||
return []
|
||||
return list(meta.get("headerWords") or []) or header_words_of(path)
|
||||
|
||||
|
||||
def usage(folder_type, base):
|
||||
"""For the AI: the family's notes and the files it needs here, {} when nothing is known."""
|
||||
family = profile(base)
|
||||
if not family:
|
||||
return {}
|
||||
found = {"family": family["family"]}
|
||||
needed = parts(folder_type, base)
|
||||
if needed:
|
||||
found["needs"] = [f"{part['folder']}: {part['file']}" + (f" (here: {part['found']})" if part["found"] else " (none here by that name)")
|
||||
for part in needed]
|
||||
if family.get("notes"):
|
||||
found["notes"] = family["notes"]
|
||||
return found
|
||||
|
||||
|
||||
def guide(path, folder_type):
|
||||
meta = get_metadata(path)
|
||||
base = meta.get("baseModel") or meta.get("base_guess") or ""
|
||||
return {"examples": model_advice.example_settings(path, folder_type, meta.get("hash", "")),
|
||||
"words": words(path, folder_type, meta), "parts": parts(folder_type, base)}
|
||||
|
||||
|
||||
async def api_model_guide(request):
|
||||
query = request.query
|
||||
folder_type = query.get("type", "")
|
||||
try:
|
||||
_base, folder = resolve_folder_subdir(folder_type, int(query.get("path_idx", 0)), query.get("subfolder", ""))
|
||||
path = model_file_within(folder, query.get("filename", ""))
|
||||
except (ValueError, KeyError):
|
||||
return web.json_response({"error": "bad request"}, status=400)
|
||||
if not path.lower().endswith(MODEL_EXTENSIONS) or not os.path.isfile(path):
|
||||
return web.json_response({"error": "not found"}, status=404)
|
||||
return web.json_response(await asyncio.to_thread(guide, path, folder_type))
|
||||
+7
-5
@@ -18,18 +18,18 @@ from .path_utils import atomic_write_json
|
||||
|
||||
try:
|
||||
from ..model_identity import read_json
|
||||
from ..model_kind import inspect_file
|
||||
from ..model_kind import RULES, inspect_file
|
||||
except ImportError: # loaded outside the package (tests)
|
||||
from model_identity import read_json
|
||||
from model_kind import inspect_file
|
||||
from model_kind import RULES, inspect_file
|
||||
|
||||
MAX_ENTRIES = 50000 # about 6 MB on disk; a library has far fewer models
|
||||
MAX_BYTES = 64 * 1024 * 1024 # a cache file larger than this is not read (nor written)
|
||||
SAVE_AFTER_S = 5
|
||||
FIELDS = ("kind", "base", "sure", "format")
|
||||
FIELDS = ("kind", "base", "sure", "format", "form") # a cache of other fields is read again
|
||||
|
||||
_lock = threading.Lock()
|
||||
_heads = None # {real path: [size, mtime_ns, last used (days), kind, base, sure, format]}
|
||||
_heads = None # {real path: [size, mtime_ns, last used (days), kind, base, sure, format, form]}
|
||||
_timer = None
|
||||
_on_disk = False
|
||||
|
||||
@@ -51,6 +51,8 @@ def _load():
|
||||
data = read_json(cache_path()) if _on_disk and os.path.getsize(cache_path()) <= MAX_BYTES else None
|
||||
except OSError:
|
||||
data = None
|
||||
if not isinstance(data, dict) or data.get("rules") != RULES: # answers of older rules: read again
|
||||
data = None
|
||||
_heads = {key: value for key, value in (data or {}).items()
|
||||
if isinstance(key, str) and isinstance(value, list) and len(value) == 3 + len(FIELDS)}
|
||||
return _heads
|
||||
@@ -65,7 +67,7 @@ def save():
|
||||
if len(heads) > MAX_ENTRIES:
|
||||
for key in sorted(heads, key=lambda name: heads[name][2])[:len(heads) - MAX_ENTRIES]:
|
||||
del heads[key]
|
||||
snapshot = dict(heads)
|
||||
snapshot = {"rules": RULES, **heads}
|
||||
try:
|
||||
atomic_write_json(cache_path(), snapshot, max_bytes=MAX_BYTES)
|
||||
except (OSError, ValueError) as error:
|
||||
|
||||
@@ -221,6 +221,8 @@ async def api_update_metadata(request):
|
||||
user_data["custom_notes"] = str(custom_notes)
|
||||
if custom_source_url is not None:
|
||||
user_data["source_url"] = str(custom_source_url).strip()
|
||||
if 'ai_note' in data:
|
||||
user_data["ai_note"] = str(data['ai_note']).strip()[:300]
|
||||
|
||||
reset_cover = data.get('reset_cover', False)
|
||||
cover_reset = None
|
||||
@@ -250,7 +252,7 @@ async def api_update_metadata(request):
|
||||
elif os.path.exists(new_file_path) and new_file_path != file_path:
|
||||
return web.json_response({"status": "error", "message": "A file with the target physical name already exists."})
|
||||
|
||||
sent = [key for key in ("custom_name", "custom_notes") if key in data]
|
||||
sent = [key for key in ("custom_name", "custom_notes", "ai_note") if key in data]
|
||||
sent += ["source_url"] if custom_source_url is not None else []
|
||||
fields = [{"field": key, "before": before.get(key) or "", "after": user_data[key]}
|
||||
for key in sent if user_data[key] != (before.get(key) or "")]
|
||||
|
||||
+43
-14
@@ -1,13 +1,16 @@
|
||||
"""Models in the wrong models folder, and identical copies (the models page's Tidy view,
|
||||
ui_model_tidy.js).
|
||||
|
||||
GET /anomalous/placement/check[?deep=1] -> {misplaced, duplicates, unchecked}
|
||||
GET /anomalous/placement/check[?deep=1] -> {misplaced, duplicates, shared, unchecked}
|
||||
misplaced: models whose header (model_kind.py, only when sure) names another type than
|
||||
the folder they are in, with where they belong (the same subfolder in a folder of that
|
||||
type, on the same drive when there is one) and whether they still load where they are.
|
||||
A file a node pack here reads from its folder (PACK_READERS) is not misplaced.
|
||||
duplicates: files with the same SHA-256 — known from scans, else read now for files of
|
||||
the same size (small ones always, large ones with deep). unchecked: the same-size large
|
||||
files a deep check would still read.
|
||||
the same size (small ones always, large ones with deep). shared: identical files each in
|
||||
a folder of its own type where it loads (one per loader, e.g. an Anima LLLite in
|
||||
model_patches and in controlnet): both are used, none is extra. unchecked: the same-size
|
||||
large files a deep check would still read.
|
||||
GET /anomalous/placement/look?type&root&rel -> {kind, sure, base}: what one model's header says
|
||||
it is, even when unsure (the models page's "Change type", ui_model_retype.js).
|
||||
POST /anomalous/placement/move {type, root, rel, to_type, to_root, to_rel} -> moves a model
|
||||
@@ -43,8 +46,13 @@ from .model_download import MODEL_EXTENSIONS, clean_folder, forget_file_lists
|
||||
from .model_import import Refused, destination, import_types, move_into
|
||||
from .path_utils import model_reference_within
|
||||
|
||||
# (folder type, what the file is): it loads there all the same.
|
||||
WORKS_ANYWAY = {("diffusion_models", "checkpoints")}
|
||||
# (folder type, what the file is): it loads there all the same. The checkpoint loader reads a
|
||||
# file of only the drawing model too (as a diffusion model, without VAE and text encoders).
|
||||
WORKS_ANYWAY = {("diffusion_models", "checkpoints"), ("checkpoints", "diffusion_models")}
|
||||
# Node packs that read a kind of file from another folder than ComfyUI's own loader:
|
||||
# (folder type, kind, form) -> the pack's node. Anima LLLite: ComfyUI's "Load Model Patch"
|
||||
# reads model_patches, the ComfyUI-Anima-LLLite pack reads controlnet.
|
||||
PACK_READERS = {("controlnet", "model_patches", "lllite"): "AnimaLLLiteApply_sdscripts"}
|
||||
# Same-size files up to this are read at once (LoRAs, embeddings); larger ones on "Compare".
|
||||
AUTO_HASH = 256 * 1024 * 1024
|
||||
CACHE_LIMIT = 5000
|
||||
@@ -132,12 +140,27 @@ def home_for(item, kind):
|
||||
return {"type": kind, "root": index, "rel": item["rel"]}
|
||||
|
||||
|
||||
def _node_here(name):
|
||||
import nodes # ComfyUI's registry, filled once ComfyUI has loaded the packs
|
||||
return name in getattr(nodes, "NODE_CLASS_MAPPINGS", {})
|
||||
|
||||
|
||||
def loads_here(folder_type, found):
|
||||
"""Whether a file whose header says `found` loads from a `folder_type` folder: it is of
|
||||
that type (or the header cannot tell), or a node pack here reads it from there."""
|
||||
kind = found.get("kind")
|
||||
if not kind or kind == folder_type:
|
||||
return True
|
||||
node = PACK_READERS.get((folder_type, kind, found.get("form")))
|
||||
return bool(node) and _node_here(node)
|
||||
|
||||
|
||||
def misplaced(files):
|
||||
found = []
|
||||
for item in files:
|
||||
result = _cached(_heads, item["path"], item["stat"], inspect_file)
|
||||
kind = result.get("kind")
|
||||
if not kind or not result.get("sure") or kind == item["type"] or kind not in import_types():
|
||||
if not result.get("sure") or kind not in import_types() or loads_here(item["type"], result):
|
||||
continue
|
||||
home = home_for(item, kind)
|
||||
if home:
|
||||
@@ -174,13 +197,14 @@ def _known_hash(item):
|
||||
def _copy(item):
|
||||
"""One of identical files: where it is, how it shows, whether its folder fits what it is
|
||||
(the copy to keep, when only one does)."""
|
||||
kind = _cached(_heads, item["path"], item["stat"], inspect_file).get("kind")
|
||||
found = _cached(_heads, item["path"], item["stat"], inspect_file)
|
||||
return {"type": item["type"], "root": item["root"], "rel": item["rel"],
|
||||
"fits": not kind or kind == item["type"], **_card(item)}
|
||||
"fits": loads_here(item["type"], found), **_card(item)}
|
||||
|
||||
|
||||
def duplicates(files, deep=False):
|
||||
"""(groups of identical files, {groups, bytes} still to read for a deep check)."""
|
||||
"""(groups of identical files, groups whose every copy loads in a folder of its own type,
|
||||
{groups, bytes} still to read for a deep check)."""
|
||||
by_size = {}
|
||||
for item in files:
|
||||
if item["size"]:
|
||||
@@ -200,15 +224,20 @@ def duplicates(files, deep=False):
|
||||
sha = _cached(_hashes, item["path"], item["stat"], file_sha256)
|
||||
if sha:
|
||||
groups.setdefault(sha, []).append(item)
|
||||
found = [{"sha256": sha, "size": items[0]["size"], "files": [_copy(item) for item in items]}
|
||||
for sha, items in groups.items() if len(items) > 1]
|
||||
return found, unchecked
|
||||
found, shared = [], []
|
||||
for sha, items in groups.items():
|
||||
if len(items) < 2:
|
||||
continue
|
||||
group = {"sha256": sha, "size": items[0]["size"], "files": [_copy(item) for item in items]}
|
||||
one_each = len({item["type"] for item in items}) == len(items)
|
||||
(shared if one_each and all(file["fits"] for file in group["files"]) else found).append(group)
|
||||
return found, shared, unchecked
|
||||
|
||||
|
||||
def check(deep=False):
|
||||
files = library()
|
||||
found, unchecked = duplicates(files, deep)
|
||||
return {"misplaced": misplaced(files), "duplicates": found, "unchecked": unchecked, "checked": len(files)}
|
||||
found, shared, unchecked = duplicates(files, deep)
|
||||
return {"misplaced": misplaced(files), "duplicates": found, "shared": shared, "unchecked": unchecked, "checked": len(files)}
|
||||
|
||||
|
||||
def _sidecars(path):
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user