feat: add batch artist/XY testing utilities

Add editable Anima prompt router with lock+latch overrides, JSON->vars helper, 16-way JSON parse, and batch/XY plotting helpers (text list, image grid, XY plot) plus example workflows and docs updates.
This commit is contained in:
Nakano Kenji
2026-02-02 16:52:39 +08:00
parent a8b885509e
commit b2a63e44f1
13 changed files with 1854 additions and 1 deletions
+57 -1
View File
@@ -7,6 +7,9 @@ ComfyUI 的极简 LLM 聊天节点。轻松连接 OpenAI, Claude, Gemini 以及
![工作流示例](assets/example-workflow.webp)
[查看示例工作流 JSON](assets/SimpleChatExample.json)
- **画师批量测试(网格)示例工作流**:`assets/ArtistBatchTest_Grid.json`
- **画师×背景 XY Plot(带标签)示例工作流**:`assets/ArtistXYPlot_Test.json`
## 特性 (Features)
@@ -18,6 +21,11 @@ ComfyUI 的极简 LLM 聊天节点。轻松连接 OpenAI, Claude, Gemini 以及
- **Gemini 原生功能**: 专为 Gemini 设计的文生图和图片编辑节点。
- **模板变量 (Mustache)**: 在 prompt/system 等任意文本里写 `{{变量名}}`,通过节点统一注入替换。
- **JSON 解析拆字段**: 将 JSON 自动解析并按 path 输出到多个端口(固定 8 路)。
- **JSON Parse (16)**: 同上,但固定 16 路输出端口(更适合你“分段多、端口多”的场景)。
- **JSON -> Vars (Editable)**: 任意 JSON 一键转胡子变量(可选扁平化 `a.b.c`),支持 `vars_in`/手动 overrides 覆盖。
- **Anima Prompt Router(可编辑 + 锁定)**: LLM 输出一个 JSON → 自动拆成分段字段(画师/风格/背景等)→ 支持手动覆盖输入 + 锁定锁存(新 JSON 来了也不改)。
- **批量测试小工具**: `Text List (Batch)` 生成字符串列表驱动批次跑图;`Image Grid (Batch)` 把批次图片拼成表格网格图。
- **XY Plot(完整表格)**: `Anima XY Matrix (JSON List)` 生成交叉组合 → `XY Plot (Labels)` 输出带 X/Y 标签的“完整 XY 表格图”。
- **Markdown 渲染预览**: 将文本以 Markdown 方式弹窗渲染展示(已做净化,避免脚本注入)。
## Wiki(GitHub 原生)
@@ -108,6 +116,12 @@ git clone https://github.com/Moeblack/ComfyUI-SimpleChat.git
* `path1..path8`: 取值路径(支持 `a.b.c` 与 `items[0].name`)
* **输出**: `out1..out8` + `obj`(完整解析后的对象)
### 7.0 JSON Parse (16)
如果你希望“默认更多端口”(例如 16 段),用这个节点。
* **节点**: `JSON Parse (16)`
* **输入/输出**:和 `JSON Parse` 一样,只是 `path1..path16` / `out1..out16`
### 7.1 Prompt JSON 拆包(强烈推荐)
如果你的 LLM 输出遵循固定 JSON 格式(positive/negative/width/height/steps/cfg/sampler/seed/notes),推荐用这个节点“一键拆包”,并且 **width/height/steps/cfg/seed/sampler 都是带类型输出**,可直接接到 ComfyUI 的对应输入口(不用手动填 path,也不用字符串转数字)。
@@ -121,13 +135,55 @@ git clone https://github.com/Moeblack/ComfyUI-SimpleChat.git
* `notes`(STRING)
* `vars`(SIMPLECHAT_VARS:自动把这些字段映射成 `{{positive}}`/`{{width}}` 等胡子变量,另含中文别名)
### 7.2 Anima Prompt Router(可编辑 + 锁定)
如果你希望把提示词拆成「画师 / 风格 / 背景 / 通用标签 / 负面」等分段来管理,但又不想每次都复制粘贴到一堆节点,这个节点就是用来**自动拆分 + 可手动改 + 可锁定**的。
* **节点**: `Anima Prompt Router (Editable)`
* **输入**:
* `json_text`: LLM 输出的 JSON(支持 ```json ... ``` 代码块)
* `override_*`: 你想主动修改的字段(填了就覆盖 JSON)
* `lock_*`: 锁定某字段(锁定后,新 JSON 来了也不会改,会保持上一次值;你仍可用 override 主动改)
* `reset_latches`: 清空所有锁存值
* **输出**:
* 分段字段:`quality_meta_year_safe / count / character / series / artist / style / environment / tags / neg`
* 自动重组:`positive / negative`
* `vars`: 可直接用于 Mustache(含 `{{anima.artist}}`、`{{画师}}` 等别名)
* `json_text`: 输出“修改后的 JSON”(可再接到 `Prompt JSON Unpack` 继续拆参数)
### 7.3 JSON -> Vars (Editable)(任意 JSON 变胡子变量)
当你已经有一个 JSON(不一定是 Anima schema),但想直接在别的节点里用 `{{key}}` 引用它:
* **节点**: `JSON -> Vars (Editable)`
* **输入**:
* `json_text`: 任意 JSON
* `flatten_keys`: 是否把嵌套字段展开为 `a.b.c` / `arr[0]` 这种键
* `prefix`: 可选前缀,例如填 `anima` 会同时生成 `anima.xxx`
* `overrides`: 允许写 `key=value` 覆盖(支持多行)
* `vars_in`: 可选,允许你把外部 vars 合并进来
* **输出**: `vars`(可直接接到 SimpleChat 节点的 `vars` 输入)
### 9.0 XY Plot:画师 × 背景(完整表格)
如果你要“真正的 XY Plot(有行列标签)”,推荐:
1) `Anima XY Matrix (JSON List)`:输入 base JSON + X 列表(画师)+ Y 列表(背景/风格等)→ 输出 JSON 列表 + `columns/x_labels/y_labels`
2) 下游照常跑图(`Prompt JSON Unpack` → CLIP/KSampler → VAE Decode)
3) `XY Plot (Labels)`:把 batch 图 + `columns/x_labels/y_labels` 输入,输出一张带标签的完整 XY 表格图
### 8. Markdown 渲染预览 (Markdown Preview)
把文本以 Markdown 渲染的方式展示(弹窗),适合预览 LLM 的结构化输出/说明文档。
* **节点**: `Markdown Preview`
* **用法**: 把任意文本输出接到 `text`,执行后自动弹窗渲染。
### 9. Anima 提示词模板(文档)
### 9. 批量测试:画师表格(Text List + Router + Image Grid)
想做“尽可能多画师的对比表格”,推荐这条最短链路(不依赖臃肿大包):
1) `Text List (Batch)`:把画师列表按行贴进去(每行一个 `@artist`)
2) `Anima Prompt Router (Editable)`:把 `Text List.item` 接到 `override_artist`,并勾选 `lock_*` 把主体/风格/背景等固定住
3) 把 Router 的 `positive/negative` 接到你的出图工作流(CLIP/KSampler)
4) 最后把出图得到的 batch 接到 `Image Grid (Batch)` 拼成表格图
### 10. Anima 提示词模板(文档)
如果你用 SimpleChat 让 LLM 帮你写 Anima 的提示词,推荐直接用这份 System Prompt:
* `docs/anima_prompt.md`
+21
View File
@@ -14,9 +14,16 @@ from .nodes import (
SimpleChatMustacheVar,
SimpleChatMustacheRender,
SimpleChatTextInput,
SimpleChatTextList,
SimpleChatJsonParse,
SimpleChatJsonParse16,
SimpleChatJsonToVars,
SimpleChatMarkdownPreview,
SimpleChatPromptJsonUnpack,
SimpleChatAnimaPromptRouter,
SimpleChatImageGrid,
SimpleChatAnimaPromptXYMatrix,
SimpleChatXYPlot,
)
# Register API routes
@@ -34,9 +41,16 @@ NODE_CLASS_MAPPINGS = {
"SimpleChatMustacheVar": SimpleChatMustacheVar,
"SimpleChatMustacheRender": SimpleChatMustacheRender,
"SimpleChatTextInput": SimpleChatTextInput,
"SimpleChatTextList": SimpleChatTextList,
"SimpleChatJsonParse": SimpleChatJsonParse,
"SimpleChatJsonParse16": SimpleChatJsonParse16,
"SimpleChatJsonToVars": SimpleChatJsonToVars,
"SimpleChatMarkdownPreview": SimpleChatMarkdownPreview,
"SimpleChatPromptJsonUnpack": SimpleChatPromptJsonUnpack,
"SimpleChatAnimaPromptRouter": SimpleChatAnimaPromptRouter,
"SimpleChatImageGrid": SimpleChatImageGrid,
"SimpleChatAnimaPromptXYMatrix": SimpleChatAnimaPromptXYMatrix,
"SimpleChatXYPlot": SimpleChatXYPlot,
}
# Display names for the UI
@@ -50,9 +64,16 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"SimpleChatMustacheVar": "Mustache Var",
"SimpleChatMustacheRender": "Mustache Render",
"SimpleChatTextInput": "Text Input",
"SimpleChatTextList": "Text List (Batch)",
"SimpleChatJsonParse": "JSON Parse",
"SimpleChatJsonParse16": "JSON Parse (16)",
"SimpleChatJsonToVars": "JSON -> Vars (Editable)",
"SimpleChatMarkdownPreview": "Markdown Preview",
"SimpleChatPromptJsonUnpack": "Prompt JSON Unpack",
"SimpleChatAnimaPromptRouter": "Anima Prompt Router (Editable)",
"SimpleChatImageGrid": "Image Grid (Batch)",
"SimpleChatAnimaPromptXYMatrix": "Anima XY Matrix (JSON List)",
"SimpleChatXYPlot": "XY Plot (Labels)",
}
# Expose web directory for JS extensions
+287
View File
@@ -0,0 +1,287 @@
{
"id": "simplechat-artist-batch-test-grid",
"revision": 0,
"last_node_id": 13,
"last_link_id": 16,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [-980, 120],
"size": [380, 105],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [3] },
{ "name": "CLIP", "type": "CLIP", "links": [1, 2] },
{ "name": "VAE", "type": "VAE", "links": [4] }
],
"properties": { "Node name for S&R": "CheckpointLoaderSimple" },
"widgets_values": ["YOUR_CHECKPOINT.safetensors"]
},
{
"id": 2,
"type": "SimpleChatTextInput",
"pos": [-980, 280],
"size": [520, 240],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "text", "type": "STRING", "links": [5] }
],
"properties": { "aux_id": "Moeblack/ComfyUI-SimpleChat", "Node name for S&R": "SimpleChatTextInput" },
"widgets_values": [
"{\n \"quality_meta_year_safe\": \"masterpiece, best quality, year 2025, safe\",\n \"count\": \"1girl\",\n \"character\": \"dreamlike girl, gentle expression, soft eyes\",\n \"series\": \"\",\n \"artist\": \"@ask\",\n \"style\": \"anime illustration, soft painterly shading\",\n \"environment\": \"soft bokeh background, warm indoor light\",\n \"tags\": \"upper body, looking at viewer, delicate hair strands\\nA dreamy and tender mood.\",\n \"neg\": \"worst quality, low quality, blurry, bad anatomy, bad hands, extra fingers, missing fingers, text, watermark, logo, jpeg artifacts, nsfw, explicit\"\n}\n"
]
},
{
"id": 3,
"type": "SimpleChatTextList",
"pos": [-980, 560],
"size": [360, 230],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "item", "type": "STRING", "links": [6] }
],
"properties": { "aux_id": "Moeblack/ComfyUI-SimpleChat", "Node name for S&R": "SimpleChatTextList" },
"widgets_values": [
"@ask\n@artist_a\n@artist_b\n@artist_c\n",
"lines"
]
},
{
"id": 4,
"type": "SimpleChatAnimaPromptRouter",
"pos": [-400, 280],
"size": [520, 420],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{ "name": "json_text", "type": "STRING", "link": 5 },
{ "name": "override_artist", "type": "STRING", "link": 6 }
],
"outputs": [
{ "name": "quality_meta_year_safe", "type": "STRING", "links": null },
{ "name": "count", "type": "STRING", "links": null },
{ "name": "character", "type": "STRING", "links": null },
{ "name": "series", "type": "STRING", "links": null },
{ "name": "artist", "type": "STRING", "links": null },
{ "name": "style", "type": "STRING", "links": null },
{ "name": "environment", "type": "STRING", "links": null },
{ "name": "tags", "type": "STRING", "links": null },
{ "name": "neg", "type": "STRING", "links": null },
{ "name": "positive", "type": "STRING", "links": [7] },
{ "name": "negative", "type": "STRING", "links": [8] },
{ "name": "vars", "type": "SIMPLECHAT_VARS", "links": null },
{ "name": "obj", "type": "SIMPLECHAT_JSON", "links": null },
{ "name": "json_text", "type": "STRING", "links": null }
],
"properties": { "aux_id": "Moeblack/ComfyUI-SimpleChat", "Node name for S&R": "SimpleChatAnimaPromptRouter" },
"widgets_values": [
"",
true,
false,
true,
true,
true,
true,
false,
true,
true,
true,
true,
"",
"",
"",
"",
"",
"",
"",
"",
""
]
},
{
"id": 5,
"type": "CLIPTextEncode",
"pos": [200, 260],
"size": [420, 180],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": 1 },
{ "name": "text", "type": "STRING", "link": 7 }
],
"outputs": [
{ "name": "CONDITIONING", "type": "CONDITIONING", "links": [9] }
],
"properties": { "Node name for S&R": "CLIPTextEncode" },
"widgets_values": []
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [200, 470],
"size": [420, 180],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": 2 },
{ "name": "text", "type": "STRING", "link": 8 }
],
"outputs": [
{ "name": "CONDITIONING", "type": "CONDITIONING", "links": [10] }
],
"properties": { "Node name for S&R": "CLIPTextEncode" },
"widgets_values": []
},
{
"id": 7,
"type": "EmptyLatentImage",
"pos": [200, 700],
"size": [320, 110],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "LATENT", "type": "LATENT", "links": [11] }
],
"properties": { "Node name for S&R": "EmptyLatentImage" },
"widgets_values": [1024, 1024, 1]
},
{
"id": 8,
"type": "KSampler",
"pos": [680, 560],
"size": [420, 300],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": 3 },
{ "name": "positive", "type": "CONDITIONING", "link": 9 },
{ "name": "negative", "type": "CONDITIONING", "link": 10 },
{ "name": "latent_image", "type": "LATENT", "link": 11 }
],
"outputs": [
{ "name": "LATENT", "type": "LATENT", "links": [12] }
],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [1234, 30, 4.5, "euler_ancestral", "normal", 1.0]
},
{
"id": 9,
"type": "VAEDecode",
"pos": [1140, 620],
"size": [170, 50],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{ "name": "samples", "type": "LATENT", "link": 12 },
{ "name": "vae", "type": "VAE", "link": 4 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [13, 14] }
],
"properties": { "Node name for S&R": "VAEDecode" },
"widgets_values": []
},
{
"id": 13,
"type": "SaveImage",
"pos": [1340, 520],
"size": [320, 60],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 13 }
],
"outputs": [],
"properties": { "Node name for S&R": "SaveImage" },
"widgets_values": ["artist_test_each"]
},
{
"id": 10,
"type": "SimpleChatImageGrid",
"pos": [1340, 660],
"size": [340, 160],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 14 }
],
"outputs": [
{ "name": "image", "type": "IMAGE", "links": [15, 16] }
],
"properties": { "aux_id": "Moeblack/ComfyUI-SimpleChat", "Node name for S&R": "SimpleChatImageGrid" },
"widgets_values": [4, 8, "black"]
},
{
"id": 11,
"type": "PreviewImage",
"pos": [1720, 660],
"size": [280, 340],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 15 }
],
"outputs": [],
"properties": { "Node name for S&R": "PreviewImage" },
"widgets_values": []
},
{
"id": 12,
"type": "SaveImage",
"pos": [1720, 1030],
"size": [320, 60],
"flags": {},
"order": 12,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 16 }
],
"outputs": [],
"properties": { "Node name for S&R": "SaveImage" },
"widgets_values": ["artist_test_grid"]
}
],
"links": [
[1, 1, 1, 5, 0, "CLIP"],
[2, 1, 1, 6, 0, "CLIP"],
[3, 1, 0, 8, 0, "MODEL"],
[4, 1, 2, 9, 1, "VAE"],
[5, 2, 0, 4, 0, "STRING"],
[6, 3, 0, 4, 11, "STRING"],
[7, 4, 9, 5, 0, "STRING"],
[8, 4, 10, 6, 0, "STRING"],
[9, 5, 0, 8, 5, "CONDITIONING"],
[10, 6, 0, 8, 6, "CONDITIONING"],
[11, 7, 0, 8, 7, "LATENT"],
[12, 8, 0, 9, 0, "LATENT"],
[13, 9, 0, 13, 0, "IMAGE"],
[14, 9, 0, 10, 0, "IMAGE"],
[15, 10, 0, 11, 0, "IMAGE"],
[16, 10, 0, 12, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": { "workflowRendererVersion": "LG" },
"version": 0.4
}
+277
View File
@@ -0,0 +1,277 @@
{
"id": "simplechat-artist-xyplot-test",
"revision": 0,
"last_node_id": 13,
"last_link_id": 19,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [-980, 140],
"size": [380, 105],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [4] },
{ "name": "CLIP", "type": "CLIP", "links": [1, 2] },
{ "name": "VAE", "type": "VAE", "links": [5] }
],
"properties": { "Node name for S&R": "CheckpointLoaderSimple" },
"widgets_values": ["YOUR_CHECKPOINT.safetensors"]
},
{
"id": 2,
"type": "SimpleChatTextInput",
"pos": [-980, 290],
"size": [520, 280],
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "text", "type": "STRING", "links": [6] }
],
"properties": { "aux_id": "Moeblack/ComfyUI-SimpleChat", "Node name for S&R": "SimpleChatTextInput" },
"widgets_values": [
"{\n \"quality_meta_year_safe\": \"masterpiece, best quality, year 2025, safe\",\n \"count\": \"1girl\",\n \"character\": \"dreamlike girl, soft eyes, gentle smile\",\n \"series\": \"\",\n \"artist\": \"@ask\",\n \"style\": \"anime illustration, soft painterly shading\",\n \"environment\": \"\",\n \"tags\": \"upper body, looking at viewer\\nA dreamy and tender mood.\",\n \"neg\": \"worst quality, low quality, blurry, bad anatomy, bad hands, extra fingers, missing fingers, text, watermark, logo, jpeg artifacts, nsfw, explicit\",\n\n \"positive\": \"\",\n \"negative\": \"\",\n \"width\": 1024,\n \"height\": 1024,\n \"steps\": 30,\n \"cfg\": 4.5,\n \"sampler\": \"euler_ancestral\",\n \"seed\": 1234,\n \"notes\": \"\"\n}\n"
]
},
{
"id": 3,
"type": "SimpleChatAnimaPromptXYMatrix",
"pos": [-400, 290],
"size": [520, 360],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{ "name": "json_text", "type": "STRING", "link": 6 }
],
"outputs": [
{ "name": "json_text", "type": "STRING", "links": [7] },
{ "name": "columns", "type": "INT", "links": [15] },
{ "name": "x_labels", "type": "STRING", "links": [16] },
{ "name": "y_labels", "type": "STRING", "links": [17] }
],
"properties": { "aux_id": "Moeblack/ComfyUI-SimpleChat", "Node name for S&R": "SimpleChatAnimaPromptXYMatrix" },
"widgets_values": [
"",
true,
"artist",
"@ask\n@artist_a\n@artist_b\n@artist_c\n",
"environment",
"warm indoor light\nrainy neon street\nsunset seaside\n",
true
]
},
{
"id": 4,
"type": "SimpleChatPromptJsonUnpack",
"pos": [200, 290],
"size": [460, 320],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{ "name": "json_text", "type": "STRING", "link": 7 }
],
"outputs": [
{ "name": "positive", "type": "STRING", "links": [8] },
{ "name": "negative", "type": "STRING", "links": [9] },
{ "name": "width", "type": "INT", "links": null },
{ "name": "height", "type": "INT", "links": null },
{ "name": "steps", "type": "INT", "links": null },
{ "name": "cfg", "type": "FLOAT", "links": null },
{ "name": "sampler", "type": "SAMPLER", "links": null },
{ "name": "seed", "type": "INT", "links": null },
{ "name": "notes", "type": "STRING", "links": null },
{ "name": "vars", "type": "SIMPLECHAT_VARS", "links": null },
{ "name": "obj", "type": "SIMPLECHAT_JSON", "links": null },
{ "name": "sampler_name", "type": "STRING", "links": null }
],
"properties": { "aux_id": "Moeblack/ComfyUI-SimpleChat", "Node name for S&R": "SimpleChatPromptJsonUnpack" },
"widgets_values": ["", true]
},
{
"id": 5,
"type": "CLIPTextEncode",
"pos": [720, 260],
"size": [420, 180],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": 1 },
{ "name": "text", "type": "STRING", "link": 8 }
],
"outputs": [
{ "name": "CONDITIONING", "type": "CONDITIONING", "links": [10] }
],
"properties": { "Node name for S&R": "CLIPTextEncode" },
"widgets_values": []
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [720, 470],
"size": [420, 180],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{ "name": "clip", "type": "CLIP", "link": 2 },
{ "name": "text", "type": "STRING", "link": 9 }
],
"outputs": [
{ "name": "CONDITIONING", "type": "CONDITIONING", "links": [11] }
],
"properties": { "Node name for S&R": "CLIPTextEncode" },
"widgets_values": []
},
{
"id": 7,
"type": "EmptyLatentImage",
"pos": [720, 700],
"size": [320, 110],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [],
"outputs": [
{ "name": "LATENT", "type": "LATENT", "links": [12] }
],
"properties": { "Node name for S&R": "EmptyLatentImage" },
"widgets_values": [1024, 1024, 1]
},
{
"id": 8,
"type": "KSampler",
"pos": [1200, 560],
"size": [420, 300],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": 4 },
{ "name": "positive", "type": "CONDITIONING", "link": 10 },
{ "name": "negative", "type": "CONDITIONING", "link": 11 },
{ "name": "latent_image", "type": "LATENT", "link": 12 }
],
"outputs": [
{ "name": "LATENT", "type": "LATENT", "links": [13] }
],
"properties": { "Node name for S&R": "KSampler" },
"widgets_values": [1234, 30, 4.5, "euler_ancestral", "normal", 1.0]
},
{
"id": 9,
"type": "VAEDecode",
"pos": [1660, 620],
"size": [170, 50],
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{ "name": "samples", "type": "LATENT", "link": 13 },
{ "name": "vae", "type": "VAE", "link": 5 }
],
"outputs": [
{ "name": "IMAGE", "type": "IMAGE", "links": [14, 18] }
],
"properties": { "Node name for S&R": "VAEDecode" },
"widgets_values": []
},
{
"id": 13,
"type": "SaveImage",
"pos": [1880, 520],
"size": [320, 60],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 14 }
],
"outputs": [],
"properties": { "Node name for S&R": "SaveImage" },
"widgets_values": ["xy_test_each"]
},
{
"id": 10,
"type": "SimpleChatXYPlot",
"pos": [1880, 660],
"size": [460, 240],
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 18 },
{ "name": "columns", "type": "INT", "link": 15 },
{ "name": "x_labels", "type": "STRING", "link": 16 },
{ "name": "y_labels", "type": "STRING", "link": 17 }
],
"outputs": [
{ "name": "image", "type": "IMAGE", "links": [19] }
],
"properties": { "aux_id": "Moeblack/ComfyUI-SimpleChat", "Node name for S&R": "SimpleChatXYPlot" },
"widgets_values": [8, 8, "black", "white", "Artist x Environment"]
},
{
"id": 11,
"type": "PreviewImage",
"pos": [2360, 660],
"size": [280, 340],
"flags": {},
"order": 11,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 19 }
],
"outputs": [],
"properties": { "Node name for S&R": "PreviewImage" },
"widgets_values": []
},
{
"id": 12,
"type": "SaveImage",
"pos": [2360, 1030],
"size": [320, 60],
"flags": {},
"order": 12,
"mode": 0,
"inputs": [
{ "name": "images", "type": "IMAGE", "link": 19 }
],
"outputs": [],
"properties": { "Node name for S&R": "SaveImage" },
"widgets_values": ["xy_plot"]
}
],
"links": [
[1, 1, 1, 5, 0, "CLIP"],
[2, 1, 1, 6, 0, "CLIP"],
[4, 1, 0, 8, 0, "MODEL"],
[5, 1, 2, 9, 1, "VAE"],
[6, 2, 0, 3, 0, "STRING"],
[7, 3, 0, 4, 0, "STRING"],
[8, 4, 0, 5, 1, "STRING"],
[9, 4, 1, 6, 1, "STRING"],
[10, 5, 0, 8, 5, "CONDITIONING"],
[11, 6, 0, 8, 6, "CONDITIONING"],
[12, 7, 0, 8, 7, "LATENT"],
[13, 8, 0, 9, 0, "LATENT"],
[14, 9, 0, 13, 0, "IMAGE"],
[15, 3, 1, 10, 1, "INT"],
[16, 3, 2, 10, 2, "STRING"],
[17, 3, 3, 10, 3, "STRING"],
[18, 9, 0, 10, 0, "IMAGE"],
[19, 10, 0, 11, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": { "workflowRendererVersion": "LG" },
"version": 0.4
}
+7
View File
@@ -19,9 +19,16 @@
## SimpleChat/Utils(工具节点)
- **Text Input**:纯文本输入框(输出 STRING)
- **Text List (Batch)**:把多行文本拆成字符串列表(输出 LIST[STRING],可驱动批次跑图)
- **Mustache Var**:定义变量(输出 `SIMPLECHAT_VARS` 字典,支持菊花链合并)
- **Mustache Render**:不经过 Chat,也能把 `{{变量}}` 渲染成纯文本(输出 STRING)
- **JSON Parse**:解析 JSON 并按 path 拆 8 路输出(支持 `a.b[0].c`、支持 ```json 代码块提取)
- **JSON Parse (16)**:同上,但固定 16 路输出端口
- **JSON -> Vars (Editable)**:任意 JSON 转胡子变量(可扁平化 `a.b.c` / `arr[0]`,支持覆盖)
- **Prompt JSON Unpack**:固定 schema 的“一键拆包”(强类型输出 + 自动生成 `vars`)
- **Anima Prompt Router (Editable)**:分段拆提示词(画师/风格/背景等)+ 支持手动覆盖 + 锁定锁存 + 输出重组后的 positive/negative 与更新后的 JSON
- **Anima XY Matrix (JSON List)**:生成 X×Y 交叉组合的 JSON 列表(用于画师/背景 XY 测试)
- **Image Grid (Batch)**:把 IMAGE batch 拼成一张网格表格图(便于画师/背景对比)
- **XY Plot (Labels)**:把 IMAGE batch 拼成带 X/Y 标签的“完整 XY 表格图”
- **Markdown Preview**:把文本以 Markdown 方式弹窗预览(已净化)
+14
View File
@@ -10,9 +10,16 @@ from .gemini_edit import GeminiImageEdit
from .mustache_var import SimpleChatMustacheVar
from .mustache_render import SimpleChatMustacheRender
from .text_input import SimpleChatTextInput
from .text_list import SimpleChatTextList
from .json_parse import SimpleChatJsonParse
from .json_parse_16 import SimpleChatJsonParse16
from .json_to_vars import SimpleChatJsonToVars
from .markdown_preview import SimpleChatMarkdownPreview
from .prompt_json_unpack import SimpleChatPromptJsonUnpack
from .anima_prompt_router import SimpleChatAnimaPromptRouter
from .anima_prompt_xy_matrix import SimpleChatAnimaPromptXYMatrix
from .image_grid import SimpleChatImageGrid
from .image_xy_plot import SimpleChatXYPlot
__all__ = [
"SimpleChatConfig",
@@ -24,7 +31,14 @@ __all__ = [
"SimpleChatMustacheVar",
"SimpleChatMustacheRender",
"SimpleChatTextInput",
"SimpleChatTextList",
"SimpleChatJsonParse",
"SimpleChatJsonParse16",
"SimpleChatJsonToVars",
"SimpleChatMarkdownPreview",
"SimpleChatPromptJsonUnpack",
"SimpleChatAnimaPromptRouter",
"SimpleChatAnimaPromptXYMatrix",
"SimpleChatImageGrid",
"SimpleChatXYPlot",
]
+339
View File
@@ -0,0 +1,339 @@
"""
Anima Prompt Router (editable + latchable).
Why this node exists:
- LLM outputs ONE JSON.
- You want ComfyUI to automatically split it into prompt parts (quality/count/artist/...).
- You also want to *actively edit* some fields inside the node (manual overrides).
- And you want to *lock* some fields so that even if a new JSON comes in, those locked
outputs do not change (latch behavior).
This node:
- Parses JSON (supports ```json fences)
- Extracts common Anima parts:
quality_meta_year_safe / count / character / series / artist / style / environment / tags / neg
- Applies manual overrides
- Applies latch locks (sticky values per field)
- Rebuilds positive/negative from final parts
- Outputs SIMPLECHAT_VARS for Mustache usage
- Outputs the updated JSON as text (so you can log it or feed downstream)
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, Optional
_CODE_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)\s*```", re.IGNORECASE)
def _strip_code_fence(text: str) -> str:
if not isinstance(text, str):
return text
m = _CODE_FENCE_RE.search(text)
return m.group(1) if m else text
def _try_parse_json(text: str) -> Any:
"""
Try strict JSON first; if it fails, apply a minimal cleanup and retry.
"""
def _extract_obj(s: str) -> str:
if not isinstance(s, str):
return s
a = s.find("{")
b = s.rfind("}")
if a != -1 and b != -1 and b > a:
return s[a : b + 1]
return s
try:
return json.loads(text)
except Exception:
# Common LLM mistake: trailing commas
cleaned = re.sub(r",\s*([}\]])", r"\1", text)
try:
return json.loads(cleaned)
except Exception:
return json.loads(_extract_obj(cleaned))
def _as_str(value: Any, default: str = "") -> str:
if value is None:
return default
if isinstance(value, str):
return value
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
def _clean_piece(text: str) -> str:
if not isinstance(text, str):
return ""
s = text.strip()
s = s.rstrip(",,")
return s.strip()
def _join_pieces(*pieces: str) -> str:
cleaned = [_clean_piece(p) for p in pieces]
cleaned = [p for p in cleaned if p]
return ", ".join(cleaned)
def _first_nonempty(*values: Any) -> str:
for v in values:
s = _as_str(v, "").strip()
if s:
return s
return ""
class SimpleChatAnimaPromptRouter:
"""
Split an Anima-style prompt JSON into editable parts with lock(latch) support.
"""
def __init__(self):
# Sticky values per field when lock_* is enabled
self._latched: Dict[str, str] = {}
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"json_text": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
"strip_code_fence": ("BOOLEAN", {"default": True}),
"reset_latches": ("BOOLEAN", {"default": False, "tooltip": "Clear all locked(latched) values."}),
},
"optional": {
# Locks: when enabled, keep previous value even if JSON changes.
"lock_quality": ("BOOLEAN", {"default": False}),
"lock_count": ("BOOLEAN", {"default": False}),
"lock_character": ("BOOLEAN", {"default": False}),
"lock_series": ("BOOLEAN", {"default": False}),
"lock_artist": ("BOOLEAN", {"default": False}),
"lock_style": ("BOOLEAN", {"default": False}),
"lock_environment": ("BOOLEAN", {"default": False}),
"lock_tags": ("BOOLEAN", {"default": False}),
"lock_neg": ("BOOLEAN", {"default": False}),
# Manual overrides (active editing): if non-empty, override JSON output.
"override_quality": ("STRING", {"multiline": True, "default": ""}),
"override_count": ("STRING", {"multiline": False, "default": ""}),
"override_character": ("STRING", {"multiline": True, "default": ""}),
"override_series": ("STRING", {"multiline": False, "default": ""}),
"override_artist": ("STRING", {"multiline": False, "default": ""}),
"override_style": ("STRING", {"multiline": True, "default": ""}),
"override_environment": ("STRING", {"multiline": True, "default": ""}),
"override_tags": ("STRING", {"multiline": True, "default": ""}),
"override_neg": ("STRING", {"multiline": True, "default": ""}),
},
}
RETURN_TYPES = (
"STRING", # quality_meta_year_safe
"STRING", # count
"STRING", # character
"STRING", # series
"STRING", # artist
"STRING", # style
"STRING", # environment
"STRING", # tags
"STRING", # neg
"STRING", # positive
"STRING", # negative
"SIMPLECHAT_VARS", # vars
"SIMPLECHAT_JSON", # obj
"STRING", # json_text_out
)
RETURN_NAMES = (
"quality_meta_year_safe",
"count",
"character",
"series",
"artist",
"style",
"environment",
"tags",
"neg",
"positive",
"negative",
"vars",
"obj",
"json_text",
)
FUNCTION = "route"
CATEGORY = "SimpleChat/Utils"
DESCRIPTION = "Parse Anima JSON into editable parts with lock(latch) support; outputs rebuilt positive/negative + vars + updated JSON."
def _resolve_field(
self,
*,
key: str,
json_value: str,
lock: bool,
override: str,
default: str = "",
) -> str:
# 1) Manual override always wins if non-empty (supports active editing)
ov = _as_str(override, "").strip()
if ov:
self._latched[key] = ov
return ov
# 2) Latch behavior when locked
if lock:
if key in self._latched:
return self._latched[key]
# First time locking: latch current JSON value (or default)
cur = (json_value or "").strip() or default
self._latched[key] = cur
return cur
# 3) Follow JSON when not locked
return (json_value or "").strip() or default
def route(
self,
json_text: str,
strip_code_fence: bool = True,
reset_latches: bool = False,
lock_quality: bool = False,
lock_count: bool = False,
lock_character: bool = False,
lock_series: bool = False,
lock_artist: bool = False,
lock_style: bool = False,
lock_environment: bool = False,
lock_tags: bool = False,
lock_neg: bool = False,
override_quality: str = "",
override_count: str = "",
override_character: str = "",
override_series: str = "",
override_artist: str = "",
override_style: str = "",
override_environment: str = "",
override_tags: str = "",
override_neg: str = "",
):
if reset_latches:
self._latched = {}
raw = _strip_code_fence(json_text) if strip_code_fence else json_text
data = _try_parse_json(raw) if isinstance(raw, str) else raw
if not isinstance(data, dict):
raise ValueError("Expected a JSON object (dict) at top-level.")
# Extract with friendly aliases (support multiple schema variants)
q_json = _first_nonempty(data.get("quality_meta_year_safe"), data.get("quality"))
count_json = _first_nonempty(data.get("count"))
character_json = _first_nonempty(data.get("character"), data.get("subject"))
series_json = _first_nonempty(data.get("series"))
artist_json = _first_nonempty(data.get("artist"))
style_json = _first_nonempty(data.get("style"))
env_json = _first_nonempty(data.get("environment"), data.get("env"), data.get("background"), data.get("scene"))
tags_json = _first_nonempty(data.get("tags"))
neg_json = _first_nonempty(data.get("neg"), data.get("negative"))
# Resolve final values (override + lock/latch)
q = self._resolve_field(key="quality_meta_year_safe", json_value=q_json, lock=lock_quality, override=override_quality)
count = self._resolve_field(key="count", json_value=count_json, lock=lock_count, override=override_count)
character = self._resolve_field(key="character", json_value=character_json, lock=lock_character, override=override_character)
series = self._resolve_field(key="series", json_value=series_json, lock=lock_series, override=override_series)
artist = self._resolve_field(key="artist", json_value=artist_json, lock=lock_artist, override=override_artist)
style = self._resolve_field(key="style", json_value=style_json, lock=lock_style, override=override_style)
environment = self._resolve_field(key="environment", json_value=env_json, lock=lock_environment, override=override_environment)
tags = self._resolve_field(key="tags", json_value=tags_json, lock=lock_tags, override=override_tags)
neg = self._resolve_field(key="neg", json_value=neg_json, lock=lock_neg, override=override_neg)
# Always rebuild positive/negative from final parts (keeps consistency after edits)
positive = _join_pieces(q, count, character, series, artist, style, environment, tags)
# In Anima-parted schema, negative is expected to mirror `neg`.
negative = neg
# Build vars for Mustache usage (both plain + prefixed "anima.")
vars_dict: Dict[str, Any] = {
"quality_meta_year_safe": q,
"count": count,
"character": character,
"series": series,
"artist": artist,
"style": style,
"environment": environment,
"tags": tags,
"neg": neg,
"positive": positive,
"negative": negative,
"anima.quality_meta_year_safe": q,
"anima.count": count,
"anima.character": character,
"anima.series": series,
"anima.artist": artist,
"anima.style": style,
"anima.environment": environment,
"anima.tags": tags,
"anima.neg": neg,
"anima.positive": positive,
"anima.negative": negative,
# Chinese aliases (optional convenience)
"正面提示词": positive,
"负面提示词": negative,
"背景": environment,
"画师": artist,
"角色": character,
"风格": style,
"通用标签": tags,
"质量": q,
"人数": count,
"作品": series,
"anima.正面提示词": positive,
"anima.负面提示词": negative,
"anima.背景": environment,
"anima.画师": artist,
"anima.角色": character,
"anima.风格": style,
"anima.通用标签": tags,
"anima.质量": q,
"anima.人数": count,
"anima.作品": series,
}
# Produce updated JSON object (keep original keys, but ensure these fields are consistent)
out_obj = dict(data)
out_obj["quality_meta_year_safe"] = q
out_obj["count"] = count
out_obj["character"] = character
out_obj["series"] = series
out_obj["artist"] = artist
out_obj["style"] = style
out_obj["environment"] = environment
out_obj["tags"] = tags
out_obj["neg"] = neg
out_obj["positive"] = positive
out_obj["negative"] = negative
json_text_out = json.dumps(out_obj, ensure_ascii=False, indent=2)
return (
q,
count,
character,
series,
artist,
style,
environment,
tags,
neg,
positive,
negative,
vars_dict,
out_obj,
json_text_out,
)
+204
View File
@@ -0,0 +1,204 @@
"""
Anima Prompt XY Matrix (JSON list generator).
Goal:
- Given ONE base JSON prompt (Anima-style parts),
- Provide X list (e.g. artist list) and Y list (e.g. background list),
- Generate cross-product JSON_TEXT list in row-major order:
y0: x0, x1, x2...
y1: x0, x1, x2...
- Also output columns (len(x)) and labels texts for plotting.
This node does NOT run sampling. It only generates the prompt JSON list.
Downstream you can connect to:
Prompt JSON Unpack -> CLIP/KSampler -> Image Grid / XY Plot
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict, List, Tuple
_CODE_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)\s*```", re.IGNORECASE)
def _strip_code_fence(text: str) -> str:
if not isinstance(text, str):
return text
m = _CODE_FENCE_RE.search(text)
return m.group(1) if m else text
def _try_parse_json(text: str) -> Any:
try:
return json.loads(text)
except Exception:
cleaned = re.sub(r",\s*([}\]])", r"\1", text)
try:
return json.loads(cleaned)
except Exception:
a = cleaned.find("{")
b = cleaned.rfind("}")
if a != -1 and b != -1 and b > a:
return json.loads(cleaned[a : b + 1])
raise
def _split_list(text: str) -> List[str]:
if not isinstance(text, str):
return []
raw = text.replace("\r\n", "\n").replace("\r", "\n")
items = []
for line in raw.split("\n"):
s = (line or "").strip()
if not s:
continue
if s.startswith("#") or s.startswith("//"):
continue
items.append(s)
return items
def _as_str(v: Any, default: str = "") -> str:
if v is None:
return default
if isinstance(v, str):
return v
try:
return json.dumps(v, ensure_ascii=False)
except Exception:
return str(v)
def _clean_piece(text: str) -> str:
if not isinstance(text, str):
return ""
s = text.strip()
s = s.rstrip(",,")
return s.strip()
def _join_pieces(*pieces: str) -> str:
cleaned = [_clean_piece(p) for p in pieces]
cleaned = [p for p in cleaned if p]
return ", ".join(cleaned)
def _get_field_key(field: str) -> str:
# Normalize UI field selection to JSON key
mapping = {
"quality": "quality_meta_year_safe",
"count": "count",
"character": "character",
"series": "series",
"artist": "artist",
"style": "style",
"environment": "environment",
"tags": "tags",
"neg": "neg",
}
return mapping.get(field, field)
class SimpleChatAnimaPromptXYMatrix:
@classmethod
def INPUT_TYPES(cls):
fields = ["artist", "style", "environment", "character", "series", "tags", "quality", "count", "neg"]
return {
"required": {
"json_text": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
"strip_code_fence": ("BOOLEAN", {"default": True}),
"x_field": (fields, {"default": "artist"}),
"x_list": ("STRING", {"multiline": True, "default": "@artist1\n@artist2"}),
"y_field": (fields, {"default": "environment"}),
"y_list": ("STRING", {"multiline": True, "default": "sunset street\nnight city"}),
"include_environment_in_positive": ("BOOLEAN", {"default": True}),
}
}
RETURN_TYPES = (
"STRING", # json_text (list)
"INT", # columns
"STRING", # x_labels (multiline)
"STRING", # y_labels (multiline)
)
RETURN_NAMES = ("json_text", "columns", "x_labels", "y_labels")
OUTPUT_IS_LIST = (True, False, False, False)
FUNCTION = "build"
CATEGORY = "SimpleChat/Utils"
DESCRIPTION = "Generate cross-product JSON list for XY testing (artist x background, etc.)."
def build(
self,
json_text: str,
strip_code_fence: bool = True,
x_field: str = "artist",
x_list: str = "",
y_field: str = "environment",
y_list: str = "",
include_environment_in_positive: bool = True,
):
raw = _strip_code_fence(json_text) if strip_code_fence else json_text
base = _try_parse_json(raw) if isinstance(raw, str) else raw
if not isinstance(base, dict):
raise ValueError("Expected a JSON object (dict) at top-level.")
xs = _split_list(x_list)
ys = _split_list(y_list)
if not xs:
xs = [""]
if not ys:
ys = [""]
x_key = _get_field_key(x_field)
y_key = _get_field_key(y_field)
def _build_one(xv: str, yv: str) -> str:
obj = dict(base)
if x_key:
obj[x_key] = xv
if y_key:
obj[y_key] = yv
# Keep alias fields in sync for neg/negative
if x_key == "neg":
obj["negative"] = xv
if y_key == "neg":
obj["negative"] = yv
# Rebuild positive/negative from parts (compatible with our router scheme)
q = _as_str(obj.get("quality_meta_year_safe", obj.get("quality", "")), "")
count = _as_str(obj.get("count", ""), "")
character = _as_str(obj.get("character", ""), "")
series = _as_str(obj.get("series", ""), "")
artist = _as_str(obj.get("artist", ""), "")
style = _as_str(obj.get("style", ""), "")
env = _as_str(obj.get("environment", obj.get("background", obj.get("scene", ""))), "")
tags = _as_str(obj.get("tags", ""), "")
neg = _as_str(obj.get("neg", obj.get("negative", "")), "")
if include_environment_in_positive:
positive = _join_pieces(q, count, character, series, artist, style, env, tags)
else:
positive = _join_pieces(q, count, character, series, artist, style, tags)
obj["positive"] = positive
obj["neg"] = neg
obj["negative"] = neg
return json.dumps(obj, ensure_ascii=False, indent=2)
# Row-major (y rows)
out_list: List[str] = []
for yv in ys:
for xv in xs:
out_list.append(_build_one(xv, yv))
columns = len(xs)
x_labels = "\n".join(xs)
y_labels = "\n".join(ys)
return (out_list, columns, x_labels, y_labels)
+58
View File
@@ -0,0 +1,58 @@
"""
Image Grid node (minimal).
Take a batch of images (B,H,W,C) and stitch into a single grid image.
Useful for artist/background batch testing.
"""
from __future__ import annotations
import math
import torch
class SimpleChatImageGrid:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"columns": ("INT", {"default": 4, "min": 1, "max": 64}),
"padding": ("INT", {"default": 0, "min": 0, "max": 256}),
"pad_color": (["black", "white"], {"default": "black"}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "grid"
CATEGORY = "SimpleChat/Utils"
DESCRIPTION = "Stitch an IMAGE batch into a grid."
def grid(self, images: torch.Tensor, columns: int = 4, padding: int = 0, pad_color: str = "black"):
if images is None or not isinstance(images, torch.Tensor):
raise ValueError("images must be a torch Tensor (ComfyUI IMAGE).")
if images.dim() != 4:
raise ValueError(f"Expected IMAGE tensor with 4 dims (B,H,W,C), got shape={tuple(images.shape)}")
b, h, w, c = images.shape
columns = max(1, int(columns))
rows = int(math.ceil(b / columns)) if b > 0 else 1
padding = max(0, int(padding))
out_h = rows * h + max(0, rows - 1) * padding
out_w = columns * w + max(0, columns - 1) * padding
fill = 1.0 if (pad_color == "white") else 0.0
canvas = images.new_full((1, out_h, out_w, c), fill)
for idx in range(b):
r = idx // columns
col = idx % columns
y = r * (h + padding)
x = col * (w + padding)
canvas[0, y : y + h, x : x + w, :] = images[idx]
return (canvas,)
+166
View File
@@ -0,0 +1,166 @@
"""
XY Plot (with labels) for IMAGE batch.
Takes an IMAGE batch (B,H,W,C) and stitches into a grid with X/Y labels.
This is meant for fast artist/background testing.
"""
from __future__ import annotations
import math
from typing import List, Tuple
import numpy as np
import torch
from PIL import Image, ImageDraw, ImageFont
def _split_labels(text: str) -> List[str]:
if not isinstance(text, str):
return []
raw = text.replace("\r\n", "\n").replace("\r", "\n")
out = []
for line in raw.split("\n"):
s = (line or "").strip()
if not s:
continue
if s.startswith("#") or s.startswith("//"):
continue
out.append(s)
return out
def _to_pil(img: torch.Tensor) -> Image.Image:
# img: (H,W,C) float 0..1
arr = (img.detach().cpu().numpy() * 255.0).clip(0, 255).astype(np.uint8)
return Image.fromarray(arr, mode="RGB")
def _to_tensor(img: Image.Image) -> torch.Tensor:
arr = np.array(img).astype(np.float32) / 255.0
t = torch.from_numpy(arr)[None, ...]
return t
class SimpleChatXYPlot:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"images": ("IMAGE",),
"columns": ("INT", {"default": 4, "min": 1, "max": 64}),
"x_labels": ("STRING", {"multiline": True, "default": ""}),
"y_labels": ("STRING", {"multiline": True, "default": ""}),
"padding": ("INT", {"default": 8, "min": 0, "max": 256}),
"label_padding": ("INT", {"default": 8, "min": 0, "max": 256}),
"bg_color": (["black", "white"], {"default": "black"}),
"text_color": (["white", "black"], {"default": "white"}),
"title": ("STRING", {"default": ""}),
}
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "plot"
CATEGORY = "SimpleChat/Utils"
DESCRIPTION = "Create an XY Plot image (grid + axis labels) from an IMAGE batch."
def plot(
self,
images: torch.Tensor,
columns: int = 4,
x_labels: str = "",
y_labels: str = "",
padding: int = 8,
label_padding: int = 8,
bg_color: str = "black",
text_color: str = "white",
title: str = "",
):
if images is None or not isinstance(images, torch.Tensor):
raise ValueError("images must be a torch Tensor (ComfyUI IMAGE).")
if images.dim() != 4:
raise ValueError(f"Expected IMAGE tensor with 4 dims (B,H,W,C), got shape={tuple(images.shape)}")
b, h, w, c = images.shape
columns = max(1, int(columns))
rows = int(math.ceil(b / columns)) if b > 0 else 1
padding = max(0, int(padding))
label_padding = max(0, int(label_padding))
xs = _split_labels(x_labels)
ys = _split_labels(y_labels)
# Try to align labels length with grid
if len(xs) < columns:
xs = xs + [""] * (columns - len(xs))
if len(ys) < rows:
ys = ys + [""] * (rows - len(ys))
# Font
font = ImageFont.load_default()
# Measure label sizes
tmp = Image.new("RGB", (10, 10), color=bg_color)
draw_tmp = ImageDraw.Draw(tmp)
def _text_size(s: str) -> Tuple[int, int]:
if not s:
return (0, 0)
bbox = draw_tmp.textbbox((0, 0), s, font=font)
return (bbox[2] - bbox[0], bbox[3] - bbox[1])
title_w, title_h = _text_size(title.strip())
x_h = max([_text_size(t)[1] for t in xs] + [0])
y_w = max([_text_size(t)[0] for t in ys] + [0])
# Layout margins
top_margin = (title_h + label_padding) if title.strip() else 0
top_margin += (x_h + label_padding) if (x_h > 0) else 0
left_margin = (y_w + label_padding) if (y_w > 0) else 0
grid_h = rows * h + max(0, rows - 1) * padding
grid_w = columns * w + max(0, columns - 1) * padding
out_w = left_margin + grid_w
out_h = top_margin + grid_h
bg = (0, 0, 0) if bg_color == "black" else (255, 255, 255)
fg = (255, 255, 255) if text_color == "white" else (0, 0, 0)
canvas = Image.new("RGB", (out_w, out_h), color=bg)
draw = ImageDraw.Draw(canvas)
# Title
y_cursor = 0
if title.strip():
draw.text((left_margin, 0), title.strip(), fill=fg, font=font)
y_cursor += title_h + label_padding
# X labels row
if x_h > 0:
for col in range(columns):
label = xs[col] if col < len(xs) else ""
tw, th = _text_size(label)
x0 = left_margin + col * (w + padding) + max(0, (w - tw) // 2)
draw.text((x0, y_cursor), label, fill=fg, font=font)
y_cursor += x_h + label_padding
# Paste images + Y labels
for idx in range(b):
r = idx // columns
col = idx % columns
x = left_margin + col * (w + padding)
y = y_cursor + r * (h + padding)
canvas.paste(_to_pil(images[idx]), (x, y))
if y_w > 0:
for r in range(rows):
label = ys[r] if r < len(ys) else ""
tw, th = _text_size(label)
lx = max(0, left_margin - label_padding - tw)
ly = y_cursor + r * (h + padding) + max(0, (h - th) // 2)
draw.text((lx, ly), label, fill=fg, font=font)
return (_to_tensor(canvas),)
+169
View File
@@ -0,0 +1,169 @@
"""
JSON Parse (16 outputs).
Same as SimpleChatJsonParse but with 16 extract paths instead of 8.
Useful when you want more "ports" without adding many Mustache Var nodes.
"""
from __future__ import annotations
import json
import re
from typing import Any
_CODE_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)\s*```", re.IGNORECASE)
def _strip_code_fence(text: str) -> str:
if not isinstance(text, str):
return text
m = _CODE_FENCE_RE.search(text)
return m.group(1) if m else text
def _tokenize_path(path: str) -> list[Any]:
if not isinstance(path, str):
return []
s = path.strip()
if not s:
return []
tokens: list[Any] = []
i = 0
n = len(s)
while i < n:
ch = s[i]
if ch == ".":
i += 1
continue
if ch == "[":
j = s.find("]", i + 1)
if j == -1:
tokens.append(s[i:])
break
inner = s[i + 1 : j].strip()
if (inner.startswith('"') and inner.endswith('"')) or (inner.startswith("'") and inner.endswith("'")):
tokens.append(inner[1:-1])
else:
try:
tokens.append(int(inner))
except Exception:
tokens.append(inner)
i = j + 1
continue
j = i
while j < n and s[j] not in ".[":
j += 1
key = s[i:j].strip()
if key:
tokens.append(key)
i = j
return tokens
_MISSING = object()
def _get_by_tokens(obj: Any, tokens: list[Any]) -> Any:
cur = obj
for tok in tokens:
if isinstance(tok, int):
if isinstance(cur, (list, tuple)) and -len(cur) <= tok < len(cur):
cur = cur[tok]
else:
return _MISSING
else:
if isinstance(cur, dict) and tok in cur:
cur = cur[tok]
else:
return _MISSING
return cur
def _stringify(value: Any) -> str:
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, (int, float, bool)):
return str(value)
try:
return json.dumps(value, ensure_ascii=False)
except Exception:
return str(value)
class SimpleChatJsonParse16:
@classmethod
def INPUT_TYPES(cls):
req = {
"json_text": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
"strip_code_fence": ("BOOLEAN", {"default": True}),
"default": ("STRING", {"default": ""}),
}
for i in range(1, 17):
req[f"path{i}"] = ("STRING", {"default": ""})
return {"required": req}
RETURN_TYPES = (
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"STRING",
"SIMPLECHAT_JSON",
)
RETURN_NAMES = (
"out1",
"out2",
"out3",
"out4",
"out5",
"out6",
"out7",
"out8",
"out9",
"out10",
"out11",
"out12",
"out13",
"out14",
"out15",
"out16",
"obj",
)
FUNCTION = "parse"
CATEGORY = "SimpleChat/Utils"
DESCRIPTION = "Parse JSON and extract up to 16 values by dot/bracket paths."
def parse(self, json_text: str, strip_code_fence: bool = True, default: str = "", **kwargs):
raw = _strip_code_fence(json_text) if strip_code_fence else json_text
data = json.loads(raw) if isinstance(raw, str) else raw
outs: list[str] = []
for i in range(1, 17):
p = (kwargs.get(f"path{i}") or "").strip()
if not p:
outs.append(default)
continue
tokens = _tokenize_path(p)
val = _get_by_tokens(data, tokens)
outs.append(default if val is _MISSING else _stringify(val))
return (*outs, data)
+185
View File
@@ -0,0 +1,185 @@
"""
JSON to Mustache Vars.
Convert an arbitrary JSON object into SIMPLECHAT_VARS for Mustache rendering.
Supports:
- ```json code fences extraction
- minimal JSON cleanup (trailing commas)
- optional flattening (a.b.c keys)
- manual overrides (key=value lines)
- vars_in merge (vars_in overrides JSON by default)
"""
from __future__ import annotations
import json
import re
from typing import Any, Dict
_CODE_FENCE_RE = re.compile(r"```(?:json)?\s*([\s\S]*?)\s*```", re.IGNORECASE)
def _strip_code_fence(text: str) -> str:
if not isinstance(text, str):
return text
m = _CODE_FENCE_RE.search(text)
return m.group(1) if m else text
def _try_parse_json(text: str) -> Any:
try:
return json.loads(text)
except Exception:
cleaned = re.sub(r",\s*([}\]])", r"\1", text)
try:
return json.loads(cleaned)
except Exception:
# last resort: try extracting first {...} block
a = cleaned.find("{")
b = cleaned.rfind("}")
if a != -1 and b != -1 and b > a:
return json.loads(cleaned[a : b + 1])
raise
def _as_scalar_or_str(v: Any):
if v is None:
return ""
if isinstance(v, (str, int, float, bool)):
return v
try:
return json.dumps(v, ensure_ascii=False)
except Exception:
return str(v)
def _flatten(prefix: str, obj: Any, out: Dict[str, Any]):
if isinstance(obj, dict):
for k, v in obj.items():
if not isinstance(k, str) or not k:
continue
key = f"{prefix}.{k}" if prefix else k
_flatten(key, v, out)
return
if isinstance(obj, list):
for i, v in enumerate(obj):
key = f"{prefix}[{i}]"
_flatten(key, v, out)
return
out[prefix] = _as_scalar_or_str(obj)
def _parse_overrides(text: str) -> Dict[str, str]:
"""
Parse overrides from lines like:
key=value
key: value
Ignores empty lines and #// comments.
"""
if not isinstance(text, str):
return {}
raw = text.replace("\r\n", "\n").replace("\r", "\n")
out: Dict[str, str] = {}
for line in raw.split("\n"):
s = (line or "").strip()
if not s:
continue
if s.startswith("#") or s.startswith("//"):
continue
if "=" in s:
k, v = s.split("=", 1)
elif ":" in s:
k, v = s.split(":", 1)
else:
# allow "key" alone => set to empty
k, v = s, ""
k = (k or "").strip()
if not k:
continue
out[k] = (v or "").strip()
return out
class SimpleChatJsonToVars:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"json_text": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
"strip_code_fence": ("BOOLEAN", {"default": True}),
"flatten_keys": ("BOOLEAN", {"default": True, "tooltip": "Flatten nested keys into a.b.c and arr[0] style keys."}),
"prefix": ("STRING", {"default": "", "tooltip": "Optional prefix, e.g. anima. (will become prefix.key)"}),
"override_mode": (["vars_in_overrides_json", "json_overrides_vars_in"], {"default": "vars_in_overrides_json"}),
"overrides": ("STRING", {"multiline": True, "default": "", "placeholder": "key=value\\nkey2=value2"}),
},
"optional": {
"vars_in": ("SIMPLECHAT_VARS",),
},
}
RETURN_TYPES = ("SIMPLECHAT_VARS", "SIMPLECHAT_JSON")
RETURN_NAMES = ("vars", "obj")
FUNCTION = "to_vars"
CATEGORY = "SimpleChat/Utils"
DESCRIPTION = "Convert JSON to SIMPLECHAT_VARS (Mustache vars), with optional flatten + overrides."
def to_vars(
self,
json_text: str,
strip_code_fence: bool = True,
flatten_keys: bool = True,
prefix: str = "",
override_mode: str = "vars_in_overrides_json",
overrides: str = "",
vars_in: Any = None,
):
raw = _strip_code_fence(json_text) if strip_code_fence else json_text
data = _try_parse_json(raw) if isinstance(raw, str) else raw
if not isinstance(data, dict):
raise ValueError("Expected a JSON object (dict) at top-level.")
pfx = (prefix or "").strip()
if pfx.endswith("."):
pfx = pfx[:-1]
base: Dict[str, Any] = {}
if flatten_keys:
_flatten("", data, base)
else:
for k, v in data.items():
if not isinstance(k, str) or not k:
continue
base[k] = _as_scalar_or_str(v)
# Apply prefix
if pfx:
prefixed = {}
for k, v in base.items():
if not k:
continue
prefixed[f"{pfx}.{k}"] = v
base.update(prefixed)
# Apply manual overrides (always last)
manual = _parse_overrides(overrides)
if pfx and manual:
# also set prefixed versions when prefix is set and key is not already prefixed
for k, v in list(manual.items()):
if "." not in k and not k.startswith(f"{pfx}."):
manual[f"{pfx}.{k}"] = v
merged: Dict[str, Any] = {}
if override_mode == "json_overrides_vars_in":
if isinstance(vars_in, dict):
merged.update(vars_in)
merged.update(base)
else:
merged.update(base)
if isinstance(vars_in, dict):
merged.update(vars_in)
merged.update(manual)
return (merged, data)
+70
View File
@@ -0,0 +1,70 @@
"""
Text List node.
Create a list of strings from a multiline (or comma-separated) text input.
This is useful for batch testing (e.g. artist list) by connecting to inputs
that accept STRING and letting ComfyUI's list mapping run the graph multiple times.
"""
from __future__ import annotations
def _split_items(text: str, mode: str) -> list[str]:
if not isinstance(text, str):
return []
s = text.strip()
if not s:
return []
if mode == "comma":
raw = s.replace("\r\n", "\n").replace("\r", "\n")
raw = raw.replace("\n", ",")
parts = raw.split(",")
elif mode == "lines_or_comma":
# if there is any newline, split by lines; else split by comma
raw = s.replace("\r\n", "\n").replace("\r", "\n")
if "\n" in raw:
parts = raw.split("\n")
else:
parts = raw.split(",")
else:
# lines
raw = s.replace("\r\n", "\n").replace("\r", "\n")
parts = raw.split("\n")
out: list[str] = []
for p in parts:
item = (p or "").strip()
if not item:
continue
# allow comments
if item.startswith("#") or item.startswith("//"):
continue
out.append(item)
return out
class SimpleChatTextList:
"""
Produce a STRING list output from text.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"multiline": True, "default": ""}),
"split_mode": (["lines", "comma", "lines_or_comma"], {"default": "lines"}),
}
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("item",)
OUTPUT_IS_LIST = (True,)
FUNCTION = "make_list"
CATEGORY = "SimpleChat/Utils"
DESCRIPTION = "Split text into a list of strings (for batch testing)."
def make_list(self, text: str, split_mode: str = "lines"):
return (_split_items(text, split_mode),)