diff --git a/README_CN.md b/README_CN.md index b93d92e..b2290bb 100644 --- a/README_CN.md +++ b/README_CN.md @@ -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` diff --git a/__init__.py b/__init__.py index 53e59ef..626725a 100644 --- a/__init__.py +++ b/__init__.py @@ -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 diff --git a/assets/ArtistBatchTest_Grid.json b/assets/ArtistBatchTest_Grid.json new file mode 100644 index 0000000..7d5574d --- /dev/null +++ b/assets/ArtistBatchTest_Grid.json @@ -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 +} + diff --git a/assets/ArtistXYPlot_Test.json b/assets/ArtistXYPlot_Test.json new file mode 100644 index 0000000..331e0cc --- /dev/null +++ b/assets/ArtistXYPlot_Test.json @@ -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 +} + diff --git a/docs/wiki/Nodes.md b/docs/wiki/Nodes.md index 445eb70..95c279c 100644 --- a/docs/wiki/Nodes.md +++ b/docs/wiki/Nodes.md @@ -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 方式弹窗预览(已净化) diff --git a/nodes/__init__.py b/nodes/__init__.py index 892b683..b0dc8b5 100644 --- a/nodes/__init__.py +++ b/nodes/__init__.py @@ -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", ] diff --git a/nodes/anima_prompt_router.py b/nodes/anima_prompt_router.py new file mode 100644 index 0000000..6c9b130 --- /dev/null +++ b/nodes/anima_prompt_router.py @@ -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, + ) + diff --git a/nodes/anima_prompt_xy_matrix.py b/nodes/anima_prompt_xy_matrix.py new file mode 100644 index 0000000..4793eb8 --- /dev/null +++ b/nodes/anima_prompt_xy_matrix.py @@ -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) + diff --git a/nodes/image_grid.py b/nodes/image_grid.py new file mode 100644 index 0000000..d8e5dfc --- /dev/null +++ b/nodes/image_grid.py @@ -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,) + diff --git a/nodes/image_xy_plot.py b/nodes/image_xy_plot.py new file mode 100644 index 0000000..cdda375 --- /dev/null +++ b/nodes/image_xy_plot.py @@ -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),) + diff --git a/nodes/json_parse_16.py b/nodes/json_parse_16.py new file mode 100644 index 0000000..6333408 --- /dev/null +++ b/nodes/json_parse_16.py @@ -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) + diff --git a/nodes/json_to_vars.py b/nodes/json_to_vars.py new file mode 100644 index 0000000..09fca25 --- /dev/null +++ b/nodes/json_to_vars.py @@ -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) + diff --git a/nodes/text_list.py b/nodes/text_list.py new file mode 100644 index 0000000..461c3cf --- /dev/null +++ b/nodes/text_list.py @@ -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),) +