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:
+57
-1
@@ -7,6 +7,9 @@ ComfyUI 的极简 LLM 聊天节点。轻松连接 OpenAI, Claude, Gemini 以及
|
||||

|
||||
|
||||
[查看示例工作流 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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 方式弹窗预览(已净化)
|
||||
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,)
|
||||
|
||||
@@ -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),)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),)
|
||||
|
||||
Reference in New Issue
Block a user