Compare commits

..
71 Commits
Author SHA1 Message Date
yolain 67afdc8204 fix: make reboot routes awaitable (#1045) 2026-09-29 12:23:08 +08:00
yolain 8a0f2fc412 fix: harden model metadata and preview routes (#1045)
Refs #1045
2026-09-29 12:14:30 +08:00
yolain 22145befb3 fix: app mode rendering race condition 2026-09-29 11:33:08 +08:00
yolain 8730ffd140 Fix:a conflict between the easyuse and the new frontend that affecting ComfyUI-Easy-Media 2026-09-21 15:31:43 +08:00
altoiddealer 32931f09a7 Add a "default" input to PassOrNone node (#1038)
* Add a "default" input to PassOrNone node

* Fix tooltip
2026-09-20 12:07:55 +08:00
yolain 86873e7bda Merge branch 'main' of https://github.com/yolain/ComfyUI-Easy-Use 2026-09-20 12:04:39 +08:00
yolain 375f3b77e0 fix:Disabled Alt+1–9 template shortcuts still block browser Alt+number shortcuts #1039 2026-09-20 11:57:04 +08:00
御坂桜 450b1ce4ce Add Anima and Krea2 diffusion model support (#1033) 2026-09-07 12:51:49 +08:00
altoiddealer 457b3a81e8 Add PassOrNone including locales and add tooltip/description to existing IsNone node. (#1035) 2026-09-07 12:49:51 +08:00
yolain 271685698b feat: bump version to 1.4.1 2026-09-05 00:37:55 +08:00
Aleksey Smolenchuk 859af7e7b4 fix: confine save text outputs to output directory (#1032) 2026-09-05 00:13:34 +08:00
yolain 8b522f121d fix: add missing permissions for contents in publish workflow 2026-09-03 15:58:31 +08:00
yolain 5b469409bc feat: bump version 1.4.0 with new features and bug fixes 2026-09-03 15:34:58 +08:00
yolain 80e1261b1c fix: prevent EasyUse from preempting prompt errors
Stop wrapping ComfyUI's queuePrompt so disconnected nodes, invalid connections, and validation failures remain under native frontend error handling. Move global seed metadata injection to workflow generation.
2026-09-03 01:31:00 +08:00
御坂桜 005c57839c fix: use native VAEDecodeTiled for tiled decode (Qwen Image VAE support) (#1028) 2026-09-01 11:21:44 +08:00
Tai An cf15032ab6 fix(pixart): use pe_interpolation instead of the removed lewei_scale in the ControlNet wrappers (#1030)
ControlPixArtHalf.forward_c and ControlPixArtMSHalf.forward_raw still use the
pre-rename name lewei_scale, both for the attribute and for the
get_2d_sincos_pos_embed keyword. Neither exists any more, so both PixArt
ControlNet targets fail on the first forward pass.

Signed-off-by: Anai-Guo <antai12232931@outlook.com>
2026-09-01 11:21:08 +08:00
Tai An ca93381de8 fix(preSampling): make vae/pixels optional on samplerCustomSettings.ip2p (#1029)
The latent-only call site passes neither vae nor pixels, so the IP2P
guider raises TypeError whenever a latent is supplied instead of an
image. samplerFull.ip2p in samplers.py already has the optional form.
2026-09-01 11:19:56 +08:00
max-russellandmaxru 58e077a743 fix(forLoopStart): allow total to be zero, preventing unnecessary loop execution (#1024)
Co-authored-by: maxru <m@m.com>
2026-08-27 03:07:18 +08:00
yolain 4de1ab3b66 fix: connection lines were missing from the "Refresh Nodes" feature in the new comfyui frontend 2026-08-14 21:35:08 +08:00
YseraJYandsherjy 595e0738a9 fix(promptConcat): TypeError when an input is a list (#1019)
Normalize prompt1/prompt2/separator to strings before concatenation,
joining lists with ", ", so outputs from nodes that return lists of
strings (e.g. WD14 tagger pipelines) no longer raise
"can only concatenate list (not str) to list" and the node always
outputs a plain string.

Fixes #993

Co-authored-by: sherjy <23369551+sherjy@users.noreply.github.com>
2026-07-28 18:29:14 +08:00
Joly0 7535cd0dfd Use a dedicated RNG for global seed generation (#1017)
Custom nodes calling random.seed() while they run reset the global RNG,
which makes the generated seeds deterministic and repeat after a workflow
reload or a server restart.
2026-07-28 18:28:23 +08:00
ImpactFramesandClaude Fable 5 960862223b fix(imageDetailTransfer): multi-frame masks crash on channel broadcast (#1015)
The optional mask is resized to (B, H, W) but then passed to
torch.lerp against channels-first (B, C, H, W) tensors. With a
single-frame mask (B=1) broadcasting happens to work, but any
batch/video mask (B>1) raises:

  RuntimeError: The size of tensor a (3) must match the size of
  tensor b (B) at non-singleton dimension 1

Add the missing channel dimension so per-frame video mattes
(e.g. SAM2 alpha over a clip) work.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-24 11:47:53 +08:00
kannkyo 54d080bf6a Specify UTF-8 encoding for file reads (#1007)
Added UTF-8 encoding to file reads for YAML and JSON files.
2026-06-19 23:07:20 +08:00
yolain 625efbfa2f Fixed an issue where the PrimeVue dialog overlay was not being cleared in a Windows environment #1000 2026-05-30 11:57:49 +08:00
yolain 5618a748c1 Fix: TypeError: object of type 'LockedMeta' has no len() #994 2026-05-28 09:58:17 +08:00
yolain 130c1b5796 fix(math): enhance evaluate_formula to handle list inputs and return results accordingly 2026-04-30 03:24:51 +08:00
yolain 3cf9ab4e63 remove unnecessary print statements 2026-04-23 12:08:38 +08:00
Ralkey ff5e3a34fc fixed detailer not working in subgraphs (#989) 2026-04-21 10:28:59 +08:00
xmarreandxmarre ec4ca6717f Fix Clean VRAM teardown ordering and clear Easy-Use cache in place (#982)
* Remove unused repo metadata and frontend submodule

* Remove repo metadata and frontend submodule

* Add .codex to .gitignore

* Delete .codex

---------

Co-authored-by: xmarre <mmquant1@gmail.com>
2026-04-09 08:12:22 +08:00
yolain b82bb48948 fix: avoid recursive group refs in nodes map 2026-04-08 12:56:30 +08:00
Mark Cockram d08eedabd3 fix(xyplot): fix crashes in Seeds++ Batch and tuple X/Y inputs (#984)
Three bugs in pipeXYPlotAdvanced.plot():

1. X/Y inputs can arrive as tuples in certain ComfyUI configurations,
   causing `AttributeError: 'tuple' object has no attribute 'get'`.
   Added isinstance check to unwrap single-element tuples.

2. Seeds++ Batch used `if new_pipe['seed']:` which:
   - Raises KeyError when 'seed' key is absent
   - Silently skips seed generation when seed is 0 (a valid seed)
   Changed to `new_pipe.get('seed') or 0` for safe fallback.

3. `!= None` → `is not None` per PEP 8.
2026-04-08 12:51:51 +08:00
Z-nonymous 8ba21d0b44 XY plot issue with XY Inputs Lora (#976)
* Fixed the LoRA handling bug in [`py/libs/xyplot.py:365-395`](py/libs/xyplot.py:365).

The original code used an if/else pattern that only processed one LoRA at a time:
```python
xy_values = x_value if self.x_type == "Lora" else y_value
```

This caused issues when both X and Y axes contained LoRAs - only the X axis LoRA was processed, and when only Y had LoRAs, the latent array wasn't populated correctly.

The fix now:
1. Creates an empty `lora_stack` list
2. Adds the X axis LoRA to the stack if `self.x_type == "Lora"`
3. Adds the Y axis LoRA to the stack if `self.y_type == "Lora"`
4. Appends any existing `plot_image_vars['lora_stack']` to the combined stack
5. Applies all LoRAs in sequence

This ensures both X and Y LoRAs are properly combined and applied when both axes contain LoRA values, fixing the `IndexError` in `rearrange_tensors()` that occurred due to mismatched latent array dimensions.

* Fixed: get_labels_and_sample loop structure (lines 588-623)

The IndexError persisted because the latent array length didn't match the expected grid dimensions. The issue is in the nested loop structure of get_labels_and_sample()
When only Y-axis has LoRA values (X is "None"), the nested loops don't generate the correct number of latents:
This results in 0 latents instead of len(y_values) latents, causing the IndexError in rearrange_tensors()

Replaced the nested loop structure with three explicit cases:

X-only variation (self.y_type == 'None'): Iterates over X values only
Y-only variation (self.x_type == 'None'): Iterates over Y values only
Both X and Y variation: Nested iteration over both axes
This ensures the correct number of latents are generated for all scenarios, fixing the IndexError in rearrange_tensors() that occurred when only Y-axis had values (like LoRAs).

* Fix for the LoRA label generation logic in [`py/libs/xyplot.py`](py/libs/xyplot.py:63-69). The changes made to the `define_variable()` method:

1. **Reduced model name truncation** from 30 to 25 characters to leave room for weight information
2. **Changed weight format** from `(0.50)` to ` w:0.50` for better visibility
3. **Only show weight when it differs from default** (1.0) - this ensures weight is displayed for non-default values
4. **Added bounds check** for `len(arr) > 3` before accessing `arr[3]` to prevent potential IndexError

Now when using the same LoRA with different weights (e.g., LoRA A at 0.5 and 1.0), each variation will have a distinct label in the axis, making it clear which weight is being applied in each column/row of the XY plot.
2026-03-31 15:11:24 +08:00
yolain 337a03bb19 Rollback the cycle node to version v1 2026-03-19 18:01:59 +08:00
yolain aef19b8772 Fix NodesMap #969 2026-03-19 14:26:21 +08:00
j2gg0s d60b61d575 fix: use getattr for flipped_img_txt to support newer ComfyUI versions (#968)
ComfyUI removed the `flipped_img_txt` attribute from `DoubleStreamBlock`
in a recent refactor (commit e1add563f, "Use torch RMSNorm for flux
models and refactor hunyuan video code"). This causes an AttributeError
when IPAdapter Flux nodes are executed.

Use `getattr` with a default of `False` (matching the original default)
to maintain compatibility with both old and new ComfyUI versions.
2026-03-13 12:18:51 +08:00
0AA01A0F1 a3f051f0c3 [*] Small Readme corrections (#963) 2026-03-05 11:39:14 +08:00
stuttlepress 8ca6ace667 Fix for loraStack/loraSwitcher/controlnetStack disable erases upstream stack (#962) 2026-03-03 23:45:35 +08:00
yolain 81c510c06e Optimize easy tableEditor display 2026-02-23 17:17:21 +08:00
yolain 7601371923 Add easy tableEditor 2026-02-23 11:53:28 +08:00
yolain b11c634872 Fix showAnything can not working on latest comfyui frontend 2026-02-13 21:56:09 +08:00
rjgoif 7c470c67d6 Update logic.py (#949)
Fixed Range(Float) node which would sometimes truncate the list of values by 1 due to rounding error.
2026-01-31 12:21:35 +08:00
yolain b5865efd16 Fix multiAnglePrompt settings failed to save 2026-01-30 19:01:11 +08:00
yolain 5ec3b5ef86 Fix easy showAnything #933 2026-01-25 17:42:53 +08:00
yolain b5e31ef12a Bump Version 2026-01-23 14:18:34 +08:00
yolain 21b3c15040 Fix #946 2026-01-23 14:04:47 +08:00
yolain 5dfcbcf51d Fix custom widgets to support subgraph and Nodes 2.0 #942 2026-01-17 19:27:14 +08:00
yolain 070001b36b latest commit supplemental fix #939 2026-01-13 18:48:50 +08:00
yolain 6b4c89adc4 prompt.py is compatible with comfyui version <= 0.7.0 #939 2026-01-13 15:46:48 +08:00
yolain 32ad26f0e1 Add Invert rotate mode (#940)
* Remove the degree restriction on the vertical viewing angle

* Modify multi-perspective prompt

* Add Invert rotate mode
2026-01-13 14:14:50 +08:00
yolain d9c2072a2d Add Hollow Mode to easy multiAngle (#936)
- Global control to enable or disable angle prompts
- Added `Hollow Mode` for more intuitive visualization
- Removed label quantity limit; now supports unlimited additions
- Label addition button will copy parameters from the selected page
- Double-clicking any face of the cube quickly switches camera angles for easier operation

- 全局控制是否添加角度提示词
- 新增了`镂空模式`,可更直观地展示
- 去除标签限制个数,可添加无数个
- 标签添加按钮将复制选中页的参数
- 双击正方体的每一面可以快速切换摄像机角度,便于操作
2026-01-11 15:51:14 +08:00
yolain e94405e610 Fix easy multiAngle styles on light theme 2026-01-10 18:22:18 +08:00
yolain 03d5a4cf12 Update easy multiAngle frontend 2026-01-10 17:44:15 +08:00
yolain ad43ed3154 Add easy multiAngle for qwen 2511 multi lora 2026-01-10 17:33:43 +08:00
yolain 5cc1f8535a Convert prompt.py to V3 Schema 2026-01-10 13:20:48 +08:00
yolain 9f42ead9db Fix humanSegmentation error 2026-01-05 15:20:41 +08:00
yolain 23d9c365bd Add stringJoinLines 2025-12-30 13:47:07 +08:00
yolain 7a17ad010d Add stringToIntList and SimpleMath 2025-12-30 13:05:38 +08:00
yolain 3b38a5ae60 Fix lazy options 2025-12-30 12:25:43 +08:00
yolain 3b224fbccd update __init__.py 2025-12-28 14:51:23 +08:00
yolain 00c69fc816 Bump version and add icon 2025-12-19 12:13:54 +08:00
yolain d39b5e13e2 Fix isNone 2025-12-19 11:52:48 +08:00
yolain e900c2ca9c Add preview_rescale to easy imageChooser 2025-12-03 13:18:20 +08:00
yolain 9947b8be70 Fix widget hidden #910 2025-11-16 10:47:45 +08:00
yolain bc4cec287e Merge remote-tracking branch 'origin/main' 2025-11-16 10:47:03 +08:00
facok 84a4348bc5 Fix: Add max parameter to wildcardsPromptMatrix offset (#909)
- Set offset max to MAX_SEED_NUM (1125899906842624) instead of default 2048
 - This allows accessing all items in large wildcards (e.g., 100k lines)
- Previously, only the first 2048 items were accessible due to ComfyUI frontend default limit
2025-11-16 10:38:58 +08:00
yolain 29cca677c5 Fix #903 2025-11-08 11:13:51 +08:00
yolain 76b5896f08 Remove changes to widget styles #902 2025-11-06 14:32:39 +08:00
yolain be70a8671a Fix SubgraphNode titlebox style 2025-11-06 12:13:30 +08:00
yolain 0f6cc6958a Fix using JoinImageWithAlpha bug 2025-11-05 22:27:20 +08:00
yolain 499ed4eafc Add remove_empty_lines on easy promptLine 2025-10-30 14:13:51 +08:00
yolain 125b3a4905 When the sidebar is transparent, do not apply styles 2025-10-20 20:11:05 +08:00
58 changed files with 3739 additions and 1604 deletions
+17
View File
@@ -9,6 +9,7 @@ on:
permissions:
issues: write
contents: write
jobs:
publish-node:
@@ -18,6 +19,22 @@ jobs:
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Extract version from pyproject.toml
id: version
run: |
VERSION=$(grep -E '^\s*version\s*=' pyproject.toml | head -1 | sed -E 's/.*version\s*=\s*"([^"]+)".*/\1/')
if [ -z "$VERSION" ]; then
echo "ERROR: Could not extract version from pyproject.toml" >&2
exit 1
fi
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Extracted version: $VERSION"
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: v${{ steps.version.outputs.version }}
generate_release_notes: true
- name: Publish Custom Node
uses: Comfy-Org/publish-node-action@v1
with:
+4 -1
View File
@@ -11,10 +11,13 @@ web_beta/**
web_version/dev/**
docs/**
.vscode/
.vs/
.idea/
.claude/**
mmb-preset.custom.txt
config.yaml
node.tar.gz
.codex
.cursorrules
tools/ComfyUI-Easy-Use.json
tools/ComfyUI-Easy-Use.json
+50 -1
View File
@@ -19,7 +19,7 @@
- 增加了预采样参数配置的节点,可与采样节点分离,更方便预览。
- 支持通配符与Lora的提示词节点,如需使用Lora Block Weight用法,需先保证自定义节点包中安装了 [ComfyUI-Inspire-Pack](https://github.com/ltdrdata/ComfyUI-Inspire-Pack)
- 可多选的风格化提示词选择器,默认是Fooocus的样式json,可自定义json放在styles底下,samples文件夹里可放预览图(名称和name一致,图片文件名如有空格需转为下划线'_')
- 加载器可开启A1111提示词风格模式,可重现与webui生成近乎相同的图像,需先安装 [ComfyUI_smZNodes](https://github.com/shiimizu/ComfyUI_smZNodes)
- 加载器可开启A1111提示词风格模式,可重现与webui生成近乎相同的图像
- 可使用`easy latentNoisy`或`easy preSamplingNoiseIn`节点实现对潜空间的噪声注入
- 简化 SD1.x、SD2.x、SDXL、SVD、Zero123等流程
- 简化 Stable Cascade [示例参考](https://github.com/yolain/ComfyUI-Yolain-Workflows?tab=readme-ov-file#1-13-stable-cascade)
@@ -39,6 +39,7 @@
- 支持 kolors 模型
- 支持 flux 模型
- 支持 惰性条件判断(ifElse)和 for循环
- 支持 Anima 与 Krea2 diffusion 模型,可通过 `easy diffusionModelLoader` 加载(需显式选择文本编码器与 VAE),并使用 `easy XYInputs: DiffusionModel` 进行 XY 对比
## 👨🏻‍🔧 安装
@@ -52,6 +53,54 @@ git clone https://github.com/yolain/ComfyUI-Easy-Use
## 📜 更新日志
**v1.4.1**
- 修复 `easy saveText` 将文本输出限制在输出目录 #1032
**v1.4.0**
- 添加 `easy tableEditor` 节点 - 用于编辑和显示表格数据的节点
- 修复 `easy showAnything` 在最新版 ComfyUI 前端无法工作的问题
- 修复 `easy multiAnglePrompt` 设置保存失败的问题
- 修复 `easy detailer` 在子图中无法工作的问题
- 修复新版 ComfyUI 前端中"刷新节点"功能连接线丢失的问题
- 使用原生 `VAEDecodeTiled` 进行分块解码(支持 Qwen Image VAE)
- 修复 `easy forLoopStart` - 允许 `total=0` 以防止不必要的循环执行
- 修复 `easy preSampling` - `samplerCustomSettings.ip2p` 中 `vae`/`pixels` 现在是可选的
- 修复 `easy pixart` ControlNet 包装器 - 使用 `pe_interpolation` 替代已移除的 `lewei_scale`
- 修复 `easy promptConcat` - 当输入为列表时的 TypeError 问题
- 使用专用 RNG 进行全局种子生成
- 修复 `easy imageDetailTransfer` - 多帧蒙版在通道广播时崩溃
- 修复 Windows 环境下 PrimeVue 对话框遮罩未清除的问题
- 修复 `LockedMeta` 对象 TypeError(`object of type 'LockedMeta' has no len()`)
- 增强 `easy simpleMath` `evaluate_formula` 以处理列表输入
- 修复 `loraStack`/`controlnetStack` - 禁用时不再清除上游堆栈
- 修复 XYPlot 在 `Seeds++ Batch` 和元组 X/Y 输入时崩溃的问题
- 回滚循环节点到 v1 版本
- 修复 `easy NodesMap` - 避免递归组引用
- 修复 `easy CleanVRAM` 清理顺序并正确清除 Easy-Use 缓存
- 指定文件读取的 UTF-8 编码
- 移除不必要的 print 语句
**v1.3.6**
- 恢复 `easy showAnything` 对于列表类型的支持(但一些情况下展示庞大数据时仍会导致ComfyUI崩溃)
- 修复自定义小部件以支持子图和 Nodes 2.0 #942
- 添加 `easy multiAngle` 节点
- 将 `prompt.py` 转换为 V3 Schema
- 修复 `easy humanSegmentation` 错误
- 添加 `easy stringJoinLines`、`easy stringToIntList`、`easy simpleMath`
- 修复 `easy ifElse` 和 `easy anythingIndexSwitch` 在某些环境下失败的问题
**v1.3.5**
- 修复`isNone`
- 将`preview_rescale`添加到`easy imageChooser`
- 修复小部件隐藏#910
- 将 max 参数添加到 `wildcardsPromptMatrix` 偏移量 #909
- 修复子图节点上的标题框样式
- 在 `easypromptLine` 上添加 `remove_empty_lines`
**v1.3.4**
- 修复 `easy seedList` 最大值 #879
+69 -19
View File
@@ -12,29 +12,30 @@
**ComfyUI-Easy-Use** is an efficiency custom nodes integration package, which is extended on the basis of [TinyTerraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes). It has been integrated and optimized for many popular awesome custom nodes to achieve the purpose of faster and more convenient use of ComfyUI. While ensuring the degree of freedom, it restores the ultimate smooth image production experience that belongs to Stable Diffusion.
## 👨🏻‍🎨 Introduce
## 👨🏻‍🎨 Introduction
- Inspire by [tinyterraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes), which greatly reduces the time cost of tossing workflows。
- Inspired by [tinyterraNodes](https://github.com/TinyTerra/ComfyUI_tinyterraNodes), which greatly reduces the time cost of tossing workflows。
- UI interface beautification, the first time you install the user, if you need to use the UI theme, please switch the theme in Settings -> Color Palette and refresh page.
- Added a node for pre-sampling parameter configuration, which can be separated from the sampling node for easier previewing
- Wildcards and lora's are supported, for Lora Block Weight usage, ensure that the custom node package has the [ComfyUI-Inspire-Pack](https://github.com/ltdrdata/ComfyUI-Inspire-Pack)
- Multi-selectable styled cue word selector, default is Fooocus style json, custom json can be placed under styles, samples folder can be placed in the preview image (name and name consistent, image file name such as spaces need to be converted to underscores '_')
- The loader enables the A1111 prompt mode, which reproduces nearly identical images to those generated by webui, and needs to be installed [ComfyUI_smZNodes](https://github.com/shiimizu/ComfyUI_smZNodes) first.
- Noise injection into the latent space can be achieved using the `easy latentNoisy` or `easy preSamplingNoiseIn` node
- Simplified processes for SD1.x, SD2.x, SDXL, SVD, Zero123, etc. [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#StableDiffusion)
- Simplified Stable Cascade [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#StableCascade)
- Added a node for pre-sampling parameter configuration, which can be separated from the sampling node for easier previewing.
- Wildcards and lora's are supported, for Lora Block Weight usage, ensure that the custom node package has the [ComfyUI-Inspire-Pack](https://github.com/ltdrdata/ComfyUI-Inspire-Pack).
- Multi-selectable styled cue word selector, default is Fooocus style json, custom json can be placed under styles, samples folder can be placed in the preview image (name and name consistent, image file name such as spaces need to be converted to underscores '_').
- The loader enables the A1111 prompt mode, which reproduces nearly identical images to those generated by webui.
- Noise injection into the latent space can be achieved using the `easy latentNoisy` or `easy preSamplingNoiseIn` node.
- Simplified processes for SD1.x, SD2.x, SDXL, SVD, Zero123, etc. [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#StableDiffusion).
- Simplified Stable Cascade [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#StableCascade).
- Simplified Layer Diffuse [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#LayerDiffusion),The first time you use it you may need to run `pip install -r requirements.txt` to install the required dependencies.
- Simplified InstantID [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#InstantID), You need to make sure that the custom node package has the [ComfyUI_InstantID](https://github.com/cubiq/ComfyUI_InstantID)
- Extending the usability of XYplot
- Fooocus Inpaint integration
- Simplified InstantID [Example](https://github.com/yolain/ComfyUI-Easy-Use?tab=readme-ov-file#InstantID), You need to make sure that the custom node package has the [ComfyUI_InstantID](https://github.com/cubiq/ComfyUI_InstantID).
- Extending the usability of XYplot.
- Fooocus Inpaint integration.
- Integration of common logical calculations, conversion of types, display of all types, etc.
- Background removal nodes for the RMBG-1.4 model supporting BriaAI, [BriaAI Guide](https://huggingface.co/briaai/RMBG-1.4)
- Forcibly cleared the memory usage of the comfy UI model are supported
- Stable Diffusion 3 multi-account API nodes are supported
- Support SD3's model
- Support Kolors‘s model
- Support Flux's model
- Support lazy if else and for loops
- Background removal nodes for the RMBG-1.4 model supporting BriaAI, [BriaAI Guide](https://huggingface.co/briaai/RMBG-1.4).
- Forcibly cleared the memory usage of the comfy UI model are supported.
- Stable Diffusion 3 multi-account API nodes are supported.
- Support SD3's model.
- Support Kolors‘s model.
- Support Flux's model.
- Support lazy if else and for loops.
- Support Anima and Krea2 diffusion models with `easy diffusionModelLoader` and `easy XYInputs: DiffusionModel`. The loader requires an explicit text encoder and VAE selection.
## 👨🏻‍🔧 Installation
Clone the repo into the **custom_nodes** directory and install the requirements:
@@ -47,6 +48,54 @@ Double-click install.bat to install the required dependencies
## 📜 Changelog
**v1.4.1**
- Fix `easy saveText` to confine text outputs to output directory #1032
**v1.4.0**
- Add `easy tableEditor` node - A node for editing and displaying table data
- Fix `easy showAnything` not working on latest ComfyUI frontend
- Fix `easy multiAnglePrompt` settings failed to save
- Fix `easy detailer` not working in subgraphs
- Fix connection lines missing from "Refresh Nodes" feature in new ComfyUI frontend
- Use native `VAEDecodeTiled` for tiled decode (Qwen Image VAE support)
- Fix `easy forLoopStart` - allow `total=0` to prevent unnecessary loop execution
- Fix `easy preSampling` - `vae`/`pixels` now optional on `samplerCustomSettings.ip2p`
- Fix `easy pixart` ControlNet wrappers - use `pe_interpolation` instead of removed `lewei_scale`
- Fix `easy promptConcat` - TypeError when an input is a list
- Use a dedicated RNG for global seed generation
- Fix `easy imageDetailTransfer` - multi-frame masks crash on channel broadcast
- Fix PrimeVue dialog overlay not being cleared in Windows environment
- Fix `LockedMeta` object TypeError (`object of type 'LockedMeta' has no len()`)
- Enhance `easy simpleMath` `evaluate_formula` to handle list inputs
- Fix `loraStack`/`controlnetStack` - disable no longer erases upstream stack
- Fix XYPlot crashes in `Seeds++ Batch` and tuple X/Y inputs
- Rollback cycle node to version v1
- Fix `easy NodesMap` - avoid recursive group refs
- Fix `easy CleanVRAM` teardown ordering and clear Easy-Use cache properly
- Specify UTF-8 encoding for file reads
- Remove unnecessary print statements
**v1.3.6**
- Restored `easy showAnything` support for list types (but displaying large data in some cases may still cause ComfyUI to crash)
- Fix custom widgets to support subgraph and Nodes 2.0 #942
- Add `easy multiAngle` node
- Convert `prompt.py` to V3 Schema
- Fix `easy humanSegmentation` error
- Add `easy stringJoinLines`,`easy stringToIntList`, `easy simpleMath`
- Fix `easy ifElse` and `easy anythingIndexSwitch` fails in certain environments
**v1.3.5**
- Fix `isNone`
- Add `preview_rescale` to `easy imageChooser`
- Fix widget hidden #910
- Add max parameter to `wildcardsPromptMatrix` offset #909
- Fix title box style on subgraph node
- Add `remove_empty_lines` on `easy promptLine`
**v1.3.4**
- Fix `easy seedList` max_num #879
@@ -534,3 +583,4 @@ If my custom nodes has added value to your day, consider indulging in a coffee t
My gratitude extends to the generous souls who bestow a star. Your support is much appreciated!
[![Stargazers repo roster for @yolain/ComfyUI-Easy-Use](https://reporoster.com/stars/yolain/ComfyUI-Easy-Use)](https://github.com/yolain/ComfyUI-Easy-Use/stargazers)
+13 -1
View File
@@ -1,4 +1,4 @@
__version__ = "1.3.4"
__version__ = "1.4.1"
import yaml
import json
@@ -12,6 +12,18 @@ comfy_path = folder_paths.base_path
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
try:
import comfy.supported_models as _supported_models
_HAS_DIFFUSION_XY_SUPPORT = (
hasattr(_supported_models, "Anima")
and hasattr(_supported_models, "Krea2")
)
except Exception:
_HAS_DIFFUSION_XY_SUPPORT = False
if not _HAS_DIFFUSION_XY_SUPPORT:
print("[ComfyUI-Easy-Use] Anima/Krea2 XY nodes need comfy.supported_models.Anima and Krea2")
importlib.import_module('.py.routes', __name__)
importlib.import_module('.py.server', __name__)
nodes_list = ["util", "seed", "prompt", "loaders", "adapter", "inpaint", "preSampling", "samplers", "fix", "pipe", "xyplot", "image", "logic", "api", "deprecated"]
+202
View File
@@ -250,6 +250,9 @@
},
"max_rows": {
"name": "max_rows"
},
"remove_empty_lines":{
"name": "remove_empty_lines"
}
},
"outputs": {
@@ -466,6 +469,19 @@
}
}
},
"easy multiAngle":{
"display_name": "Multi Angle Prompt",
"inputs": {
},
"outputs": {
"0": {
"name": "prompt"
},
"1":{
"name": "params"
}
}
},
"easy fullLoader": {
"display_name": "EasyLoader (Full)",
"inputs": {
@@ -1186,6 +1202,173 @@
}
}
},
"easy diffusionModelLoader": {
"display_name": "EasyDiffusionModelLoader",
"inputs": {
"model_name": {
"name": "model_name"
},
"vae_name": {
"name": "vae_name"
},
"clip_name": {
"name": "clip_name"
},
"resolution": {
"name": "resolution"
},
"empty_latent_width": {
"name": "empty_latent_width"
},
"empty_latent_height": {
"name": "empty_latent_height"
},
"positive": {
"name": "positive"
},
"negative": {
"name": "negative"
},
"batch_size": {
"name": "batch_size"
},
"model_override": {
"name": "model_override"
},
"clip_override": {
"name": "clip_override"
},
"vae_override": {
"name": "vae_override"
}
},
"outputs": {
"0": {
"name": "pipe"
},
"1": {
"name": "model"
},
"2": {
"name": "vae"
},
"3": {
"name": "clip"
},
"4": {
"name": "positive"
},
"5": {
"name": "negative"
},
"6": {
"name": "latent"
}
}
},
"easy XYInputs: DiffusionModel": {
"display_name": "XY Inputs: Diffusion Model //EasyUse",
"inputs": {
"model_count": {
"name": "model_count"
},
"model_name_1": {
"name": "model_name_1"
},
"clip_name_1": {
"name": "clip_name_1"
},
"vae_name_1": {
"name": "vae_name_1"
},
"model_name_2": {
"name": "model_name_2"
},
"clip_name_2": {
"name": "clip_name_2"
},
"vae_name_2": {
"name": "vae_name_2"
},
"model_name_3": {
"name": "model_name_3"
},
"clip_name_3": {
"name": "clip_name_3"
},
"vae_name_3": {
"name": "vae_name_3"
},
"model_name_4": {
"name": "model_name_4"
},
"clip_name_4": {
"name": "clip_name_4"
},
"vae_name_4": {
"name": "vae_name_4"
},
"model_name_5": {
"name": "model_name_5"
},
"clip_name_5": {
"name": "clip_name_5"
},
"vae_name_5": {
"name": "vae_name_5"
},
"model_name_6": {
"name": "model_name_6"
},
"clip_name_6": {
"name": "clip_name_6"
},
"vae_name_6": {
"name": "vae_name_6"
},
"model_name_7": {
"name": "model_name_7"
},
"clip_name_7": {
"name": "clip_name_7"
},
"vae_name_7": {
"name": "vae_name_7"
},
"model_name_8": {
"name": "model_name_8"
},
"clip_name_8": {
"name": "clip_name_8"
},
"vae_name_8": {
"name": "vae_name_8"
},
"model_name_9": {
"name": "model_name_9"
},
"clip_name_9": {
"name": "clip_name_9"
},
"vae_name_9": {
"name": "vae_name_9"
},
"model_name_10": {
"name": "model_name_10"
},
"clip_name_10": {
"name": "clip_name_10"
},
"vae_name_10": {
"name": "vae_name_10"
}
},
"outputs": {
"0": {
"name": "X or Y"
}
}
},
"easy loraStack": {
"display_name": "EasyLoraStack",
"inputs": {
@@ -6255,6 +6438,25 @@
}
}
},
"easy PassOrNone": {
"display_name": "Pass or None",
"inputs": {
"any": {
"name": "anything"
},
"default": {
"name": "default"
}
},
"outputs": {
"0": {
"name": "output"
},
"1": {
"name": "is_none"
}
}
},
"easy isNone": {
"display_name": "Is None",
"inputs": {
+2 -1
View File
@@ -3,7 +3,8 @@
"Hotkeys": "快捷键",
"Nodes": "节点相关",
"NodesMap": "管理节点组",
"StylesSelector": "样式选择器"
"StylesSelector": "样式选择器",
"MultiAngle": "摄影机多角度提示词"
},
"nodeCategories": {
"Util": "工具",
+220 -1
View File
@@ -154,6 +154,9 @@
},
"max_rows": {
"name": "最大行数"
},
"remove_empty_lines":{
"name": "去除空行"
}
},
"outputs": {
@@ -390,6 +393,19 @@
}
}
},
"easy multiAngle":{
"display_name": "多视角提示词",
"inputs": {
},
"outputs": {
"0": {
"name": "提示词"
},
"1":{
"name": "参数"
}
}
},
"easy fullLoader": {
"display_name": "简易加载器 (完整版)",
"inputs": {
@@ -888,6 +904,173 @@
}
}
},
"easy diffusionModelLoader": {
"display_name": "简易加载器(扩散模型)",
"inputs": {
"model_name": {
"name": "扩散模型"
},
"vae_name": {
"name": "VAE"
},
"clip_name": {
"name": "文本编码器"
},
"resolution": {
"name": "分辨率"
},
"empty_latent_width": {
"name": "宽度"
},
"empty_latent_height": {
"name": "高度"
},
"positive": {
"name": "正面提示词"
},
"negative": {
"name": "负面提示词"
},
"batch_size": {
"name": "批次大小"
},
"model_override": {
"name": "模型(可选)"
},
"clip_override": {
"name": "CLIP(可选)"
},
"vae_override": {
"name": "VAE(可选)"
}
},
"outputs": {
"0": {
"name": "节点束"
},
"1": {
"name": "模型"
},
"2": {
"name": "VAE"
},
"3": {
"name": "CLIP"
},
"4": {
"name": "正面提示词"
},
"5": {
"name": "负面提示词"
},
"6": {
"name": "潜空间"
}
}
},
"easy XYInputs: DiffusionModel": {
"display_name": "XY输入: Diffusion Model",
"inputs": {
"model_count": {
"name": "模型数量"
},
"model_name_1": {
"name": "扩散模型1"
},
"clip_name_1": {
"name": "文本编码器1"
},
"vae_name_1": {
"name": "VAE1"
},
"model_name_2": {
"name": "扩散模型2"
},
"clip_name_2": {
"name": "文本编码器2"
},
"vae_name_2": {
"name": "VAE2"
},
"model_name_3": {
"name": "扩散模型3"
},
"clip_name_3": {
"name": "文本编码器3"
},
"vae_name_3": {
"name": "VAE3"
},
"model_name_4": {
"name": "扩散模型4"
},
"clip_name_4": {
"name": "文本编码器4"
},
"vae_name_4": {
"name": "VAE4"
},
"model_name_5": {
"name": "扩散模型5"
},
"clip_name_5": {
"name": "文本编码器5"
},
"vae_name_5": {
"name": "VAE5"
},
"model_name_6": {
"name": "扩散模型6"
},
"clip_name_6": {
"name": "文本编码器6"
},
"vae_name_6": {
"name": "VAE6"
},
"model_name_7": {
"name": "扩散模型7"
},
"clip_name_7": {
"name": "文本编码器7"
},
"vae_name_7": {
"name": "VAE7"
},
"model_name_8": {
"name": "扩散模型8"
},
"clip_name_8": {
"name": "文本编码器8"
},
"vae_name_8": {
"name": "VAE8"
},
"model_name_9": {
"name": "扩散模型9"
},
"clip_name_9": {
"name": "文本编码器9"
},
"vae_name_9": {
"name": "VAE9"
},
"model_name_10": {
"name": "扩散模型10"
},
"clip_name_10": {
"name": "文本编码器10"
},
"vae_name_10": {
"name": "VAE10"
}
},
"outputs": {
"0": {
"name": "X或Y"
}
}
},
"easy zero123Loader": {
"display_name": "简易加载器(Zero123)",
"inputs": {
@@ -6072,7 +6255,7 @@
"display_name": "ckpt名称列表",
"inputs": {
"ckpt_name": {
"name": "模型名称"
"name": "模型名称"
}
},
"outputs": {
@@ -6094,6 +6277,23 @@
}
}
},
"easy tableEditor": {
"display_name": "表格编辑器",
"description": "通过可视化表格或 Markdown 语法编辑数据,输出 Markdown 格式的表格字符串及渲染图像。",
"inputs": {
"table_data": {
"name": "表格数据"
}
},
"outputs": {
"0": {
"name": "Markdown"
},
"1": {
"name": "图像"
}
}
},
"easy string": {
"display_name": "字符串",
"inputs": {
@@ -6288,6 +6488,25 @@
}
}
},
"easy PassOrNone": {
"display_name": "传递或为空",
"inputs": {
"any": {
"name": "任何"
},
"default": {
"name": "默认值"
}
},
"outputs": {
"0": {
"name": "输出"
},
"1": {
"name": "是否为空"
}
}
},
"easy isNone": {
"display_name": "是否为空",
"inputs": {
+11
View File
@@ -71,5 +71,16 @@
"Grid": "网格",
"List": "列表"
}
},
"EasyUse_MultiAngle_InvertRotate": {
"name": "启用反转旋转模式",
"tooltip": "在多角度节点中启用反转旋转模式,使旋转方向与大多数3D软件一致"
},
"EasyUse_MultiAngle_HollowMode": {
"name": "启用多角度镂空展示模式",
"tooltip": "在多角度节点中启用镂空展示模式,可以更直观地查看相机角度"
},
"EasyUse_MultiAngle_AddAnglePrompt": {
"name": "启用添加多角度提示词"
}
}
+18
View File
@@ -405,3 +405,21 @@ PROMPT_TEMPLATE = {
}
NEW_SCHEDULERS = ['align_your_steps', 'gits']
DIFFUSION_MODEL_XY_DEFAULTS = {
"anima": {
"clip_name": "qwen_3_06b_base.safetensors",
"clip_type": "anima",
"vae_name": "qwen_image_vae.safetensors",
},
"krea2": {
"clip_name": "Huihui-Qwen3-VL-4B-Instruct-abliterated-fp8_scaled.safetensors",
"clip_type": "krea2",
"vae_name": "qwen_image_vae.safetensors",
},
}
DIFFUSION_MODEL_CLIP_TYPES = {
"anima": "anima",
"krea2": "krea2",
}
+4 -3
View File
@@ -77,10 +77,11 @@ def update_cache(k, tag, v):
else:
cache_count[k] += 1
def remove_cache(key):
global cache
if key == '*':
cache = TaggedCache(cache_settings)
cache.clear()
cache_count.clear()
elif key in cache:
del cache[key]
cache_count.pop(key, None)
else:
print(f"invalid {key}")
print(f"invalid {key}")
+1 -1
View File
@@ -14,7 +14,7 @@ def prompt_to_cond(type, model, clip, clip_skip, lora_stack, text, prompt_token_
if model_type not in ['hydit'] and text is not None and has_chinese(text):
text = zh_to_en([text])[0]
if model_type in ['hydit', 'flux', 'mochi']:
if model_type in ['hydit', 'flux', 'mochi', 'anima', 'krea2']:
log_node_warn(title + "...")
embeddings_final, = CLIPTextEncode().encode(clip, text) if text is not None else (None,)
+83 -3
View File
@@ -8,6 +8,8 @@ from comfy.model_patcher import ModelPatcher
from nodes import NODE_CLASS_MAPPINGS
from collections import defaultdict
from .log import log_node_info, log_node_error
from .utils import get_sd_version
from ..config import DIFFUSION_MODEL_XY_DEFAULTS, DIFFUSION_MODEL_CLIP_TYPES
from ..modules.dit.pixArt.loader import load_pixart
diffusion_loaders = ["easy fullLoader", "easy a1111Loader", "easy fluxLoader", "easy comfyLoader", "easy hunyuanDiTLoader", "easy zero123Loader", "easy svdLoader"]
@@ -145,6 +147,28 @@ class easyLoader:
scale_soft_weights = self.get_input_value(entry, "cn_soft_weights")
desired_controlnet_names.add(f'{control_net_name};{scale_soft_weights}')
elif class_type == "easy diffusionModelLoader":
desired_unet_names.add(self.get_input_value(entry, "model_name", prompt))
clip_name = self.get_input_value(entry, "clip_name", prompt)
vae_name = self.get_input_value(entry, "vae_name", prompt)
if clip_name not in ("None", "Auto"):
desired_clip_names.add(clip_name)
if vae_name not in ("None", "Auto"):
desired_vae_names.add(vae_name)
elif class_type == "easy XYInputs: DiffusionModel":
model_count = int(self.get_input_value(entry, "model_count", prompt) or 0)
for i in range(1, model_count + 1):
model_name = self.get_input_value(entry, f"model_name_{i}", prompt)
if model_name and model_name != "None":
desired_unet_names.add(model_name)
clip_name = self.get_input_value(entry, f"clip_name_{i}", prompt)
if clip_name not in ("None", "Auto"):
desired_clip_names.add(clip_name)
vae_name = self.get_input_value(entry, f"vae_name_{i}", prompt)
if vae_name not in ("None", "Auto"):
desired_vae_names.add(vae_name)
elif class_type in model_merge_node:
desired_ckpt_names.add(self.get_input_value(entry, "ckpt_name_1"))
desired_ckpt_names.add(self.get_input_value(entry, "ckpt_name_2"))
@@ -282,6 +306,57 @@ class easyLoader:
return model
def load_diffusion_model(self, model_name):
if model_name in self.loaded_objects["unet"]:
log_node_info("Load Diffusion Model", f"{model_name} cached")
return self.loaded_objects["unet"][model_name][0]
model_path = folder_paths.get_full_path("diffusion_models", model_name)
if not model_path:
raise FileNotFoundError(f"[EasyUse] diffusion model not found: {model_name}")
model = comfy.sd.load_diffusion_model(model_path)
self.add_to_cache("unet", model_name, model)
self.eviction_based_on_memory()
return model
def load_diffusion_xy_model(self, model_name, clip_name, vae_name):
model = self.load_diffusion_model(model_name)
family = get_sd_version(model)
defaults = DIFFUSION_MODEL_XY_DEFAULTS.get(family)
if defaults is None:
raise RuntimeError(f"[EasyUse] unsupported diffusion model family: {family}")
if clip_name in ("Auto", None):
clip_name = defaults["clip_name"]
if vae_name in ("Auto", None):
vae_name = defaults["vae_name"]
clip = self.load_clip(clip_name, type=defaults["clip_type"])
vae = self.load_vae(vae_name)
return model, clip, vae, family
def load_diffusion_model_required(self, model_name, clip_name, vae_name):
if clip_name in ("None", None):
raise RuntimeError("[EasyUse] clip_name is required: please select a text encoder")
if vae_name in ("None", None):
raise RuntimeError("[EasyUse] vae_name is required: please select a VAE")
model = self.load_diffusion_model(model_name)
family = get_sd_version(model)
clip_type = DIFFUSION_MODEL_CLIP_TYPES.get(family)
if clip_type is None:
raise RuntimeError(f"[EasyUse] unsupported diffusion model family: {family}")
clip = self.load_clip(clip_name, type=clip_type)
vae = self.load_vae(vae_name)
return model, clip, vae, family
def load_controlnet(self, control_net_name, scale_soft_weights=1, use_cache=True):
unique_id = f'{control_net_name};{str(scale_soft_weights)}'
if use_cache and unique_id in self.loaded_objects["controlnet"]:
@@ -303,8 +378,9 @@ class easyLoader:
return control_net
def load_clip(self, clip_name, type='stable_diffusion', load_clip=None):
if clip_name in self.loaded_objects["clip"]:
return self.loaded_objects["clip"][clip_name][0]
cache_key = f"{clip_name}::{type}"
if cache_key in self.loaded_objects["clip"]:
return self.loaded_objects["clip"][cache_key][0]
if type == 'stable_diffusion':
clip_type = comfy.sd.CLIPType.STABLE_DIFFUSION
@@ -316,9 +392,13 @@ class easyLoader:
clip_type = comfy.sd.CLIPType.FLUX
elif type == 'stable_audio':
clip_type = comfy.sd.CLIPType.STABLE_AUDIO
elif type == 'krea2':
clip_type = comfy.sd.CLIPType.KREA2
elif type == 'anima':
clip_type = comfy.sd.CLIPType.STABLE_DIFFUSION
clip_path = folder_paths.get_full_path("clip", clip_name)
load_clip = comfy.sd.load_clip(ckpt_paths=[clip_path], embedding_directory=folder_paths.get_folder_paths("embeddings"), clip_type=clip_type)
self.add_to_cache("clip", clip_name, load_clip)
self.add_to_cache("clip", cache_key, load_clip)
self.eviction_based_on_memory()
return load_clip
+148
View File
@@ -0,0 +1,148 @@
"""
Math utility functions for formula evaluation
"""
import math
import re
def evaluate_formula(formula: str, a=0, b=0, c=0, d=0):
"""
计算字符串数学公式
支持的运算符和函数:
- 基本运算:+, -, *, /, //, %, **
- 比较运算:>, <, >=, <=, ==, !=
- 数学函数:abs, pow, round, ceil, floor, sqrt, exp, log, log10
- 三角函数:sin, cos, tan, asin, acos, atan
- 常量:pi, e
Args:
formula: 数学公式字符串,可以使用变量a、b、c、d
a: 变量a的值
b: 变量b的值
c: 变量c的值
d: 变量d的值
Returns:
如果任意输入为list则返回list[float],否则返回float
Examples:
>>> evaluate_formula("a + b", 1, 2)
3.0
>>> evaluate_formula("pow(a, 2)", 5)
25.0
>>> evaluate_formula("ceil(a / b)", 5, 2)
3.0
>>> evaluate_formula("(a>b)*b+(a<=b)*a", 5, 3)
3.0
>>> evaluate_formula("(a>b)*b+(a<=b)*a", 2, 3)
2.0
"""
# 安全的数学函数白名单
safe_dict = {
# 基本运算
'abs': abs,
'pow': pow,
'round': round,
# 数学函数
'ceil': math.ceil,
'floor': math.floor,
'sqrt': math.sqrt,
'exp': math.exp,
'log': math.log,
'log10': math.log10,
# 三角函数
'sin': math.sin,
'cos': math.cos,
'tan': math.tan,
'asin': math.asin,
'acos': math.acos,
'atan': math.atan,
# 常量
'pi': math.pi,
'e': math.e,
}
# 判断是否有 list 输入
list_inputs = {k: v for k, v in {'a': a, 'b': b, 'c': c, 'd': d}.items() if isinstance(v, (list, tuple))}
scalar_inputs = {k: v for k, v in {'a': a, 'b': b, 'c': c, 'd': d}.items() if not isinstance(v, (list, tuple))}
def _eval_single(vals: dict) -> float:
env = dict(safe_dict)
env.update({k: float(v) for k, v in vals.items()})
try:
result = eval(formula, {"__builtins__": {}}, env)
return float(result)
except Exception as e:
raise ValueError(f"公式计算错误: {str(e)}")
if not list_inputs:
# 全是标量
return _eval_single({k: v for k, v in {'a': a, 'b': b, 'c': c, 'd': d}.items()})
# 有 list 输入,逐元素计算
max_len = max(len(v) for v in list_inputs.values())
results = []
for i in range(max_len):
vals = {k: float(v) for k, v in scalar_inputs.items()}
for k, v in list_inputs.items():
vals[k] = float(v[i] if i < len(v) else v[-1])
results.append(_eval_single(vals))
return results
def ceil_value(value: float) -> int:
"""向上取整"""
return math.ceil(value)
def floor_value(value: float) -> int:
"""向下取整"""
return math.floor(value)
def round_value(value: float, decimals: int = 0) -> float:
"""
四舍五入
Args:
value: 要取整的值
decimals: 保留小数位数
Returns:
四舍五入后的值
"""
return round(value, decimals)
def power(base: float, exponent: float) -> float:
"""计算幂运算"""
return math.pow(base, exponent)
def sqrt_value(value: float) -> float:
"""计算平方根"""
if value < 0:
raise ValueError("不能对负数求平方根")
return math.sqrt(value)
def add(a: float, b: float) -> float:
"""加法"""
return a + b
def subtract(a: float, b: float) -> float:
"""减法"""
return a - b
def multiply(a: float, b: float) -> float:
"""乘法"""
return a * b
def divide(a: float, b: float) -> float:
"""除法"""
if b == 0:
raise ValueError("除数不能为零")
return a / b
+29
View File
@@ -0,0 +1,29 @@
import os
def resolve_output_file_path(output_root, output_file_path, file_name, file_extension):
"""Resolve a workflow-provided output path beneath ``output_root``.
Relative output directories remain supported, but are interpreted relative
to ComfyUI's configured output directory rather than the process working
directory. Resolving both paths prevents ``..`` components and existing
symlinks from escaping the allowed root.
"""
output_root = os.path.realpath(output_root)
requested_directory = output_file_path
if not os.path.isabs(requested_directory):
requested_directory = os.path.join(output_root, requested_directory)
candidate = os.path.realpath(
os.path.join(requested_directory, f"{file_name}.{file_extension}")
)
try:
is_within_output = os.path.commonpath((output_root, candidate)) == output_root
except ValueError:
# Different Windows drives and paths containing null bytes are unsafe.
is_within_output = False
if not is_within_output:
raise ValueError("Saving outside the ComfyUI output directory is not allowed")
return candidate
+5 -2
View File
@@ -65,6 +65,9 @@ class easySampler:
elif model_type == 'mochi':
latent = torch.zeros([batch_size, 12, ((video_length - 1) // 6) + 1, empty_latent_height // 8, empty_latent_width // 8], device=self.device)
samples = {"samples": latent}
elif model_type in ("anima", "krea2"):
latent = torch.zeros([batch_size, 16, 1, empty_latent_height // 8, empty_latent_width // 8], device=self.device)
samples = {"samples": latent}
elif compression == 0:
latent = torch.zeros([batch_size, 4, empty_latent_height // 8, empty_latent_width // 8], device=self.device)
samples = {"samples": latent}
@@ -84,7 +87,7 @@ class easySampler:
"""
latent_size = latent_image.size()
latent_size_1batch = [1, latent_size[1], latent_size[2], latent_size[3]]
latent_size_1batch = [1] + list(latent_size[1:])
if variation_strength is not None and variation_strength > 0 or incremental_seed_mode.startswith(
"variation str inc"):
@@ -108,7 +111,7 @@ class easySampler:
if strength_up is not None:
strength += strength_up
variation_noise = variation_latent.expand(input_latent.size()[0], -1, -1, -1)
variation_noise = variation_latent.expand(input_latent.size()[0], *([-1] * (variation_latent.dim() - 1)))
result = (1 - strength) * input_latent + strength * variation_noise
return result
+22
View File
@@ -35,9 +35,13 @@ import sys
import importlib.util
import importlib.metadata
import comfy.model_management as mm
import logging
import gc
from packaging import version
from server import PromptServer
LOG = logging.getLogger(__name__)
def is_package_installed(package):
try:
module = importlib.util.find_spec(package)
@@ -124,6 +128,10 @@ def get_sd_version(model):
return 'flux'
elif isinstance(model_config, comfy.supported_models.GenmoMochi):
return 'mochi'
elif isinstance(model_config, comfy.supported_models.Anima):
return 'anima'
elif isinstance(model_config, comfy.supported_models.Krea2):
return 'krea2'
else:
return 'unknown'
@@ -277,6 +285,20 @@ def getMetadata(filepath):
return header
def cleanGPUUsedForce():
from .cache import remove_cache
remove_cache("*")
gc.collect()
try:
import torch
except (ImportError, OSError, RuntimeError) as exc:
LOG.debug("Skipping CUDA synchronize during cleanGPUUsedForce: torch import failed: %s", exc)
else:
try:
if torch.cuda.is_available():
torch.cuda.synchronize()
except (AttributeError, OSError, RuntimeError) as exc:
LOG.debug("Skipping CUDA synchronize during cleanGPUUsedForce: %s", exc)
mm.unload_all_models()
mm.soft_empty_cache()
+2 -2
View File
@@ -47,7 +47,7 @@ def read_wildcard_dict(wildcard_path):
easy_wildcard_dict[key] = lines
elif file.endswith('.yaml'):
file_path = os.path.join(root, file)
with open(file_path, 'r') as f:
with open(file_path, 'r', encoding="utf-8") as f:
yaml_data = yaml.load(f, Loader=yaml.FullLoader)
for k, v in yaml_data.items():
@@ -55,7 +55,7 @@ def read_wildcard_dict(wildcard_path):
elif file.endswith('.json'):
file_path = os.path.join(root, file)
try:
with open(file_path, 'r') as f:
with open(file_path, 'r', encoding="utf-8") as f:
json_data = json.load(f)
for key, value in json_data.items():
key = wildcard_normalize(key)
+100 -26
View File
@@ -63,10 +63,14 @@ class easyXYPlot():
if value_type in ['Lora', 'Checkpoint']:
arr = value.split(',')
model_name = os.path.basename(os.path.splitext(arr[0])[0])
trigger_words = ' ' + arr[3] if value_type == 'Lora' and len(arr[3]) > 2 else ''
trigger_words = ' ' + arr[3] if value_type == 'Lora' and len(arr) > 3 and len(arr[3]) > 2 else ''
lora_weight = float(arr[1]) if value_type == 'Lora' and len(arr) > 1 else 0
lora_weight_desc = f"({lora_weight:.2f})" if lora_weight > 0 else ''
value_label = f"{model_name[:30]}{lora_weight_desc} {trigger_words}"
lora_weight_desc = f" w:{lora_weight:.2f}" if value_type == 'Lora' and lora_weight != 1.0 else ''
value_label = f"{model_name[:25]}{lora_weight_desc}{trigger_words}"
if value_type == "DiffusionModel":
model_name = os.path.basename(os.path.splitext(value.split(",")[0])[0])
value_label = model_name[:25]
if value_type in ["ModelMergeBlocks"]:
if ":" in value:
@@ -98,6 +102,32 @@ class easyXYPlot():
return plot_image_vars, value_label
@staticmethod
def _ensure_latent_for_model(model, vae, samples, plot_image_vars):
fmt = model.model.latent_format
x = samples["samples"]
expected_ndim = 2 + fmt.latent_dimensions
if x.ndim == expected_ndim and x.shape[1] == fmt.latent_channels:
return samples
if fmt.latent_dimensions == 3 and x.ndim == 4:
if x.count_nonzero() == 0:
x = torch.zeros(
[x.shape[0], fmt.latent_channels, 1, x.shape[2], x.shape[3]],
dtype=x.dtype, device=x.device)
elif plot_image_vars.get("images") is not None:
x = vae.encode(plot_image_vars["images"][..., :3])
else:
raise RuntimeError(
"Switching to a 3D-latent model requires an input image "
"or an empty latent"
)
return {**samples, "samples": x}
return samples
@staticmethod
def get_font(font_size, font_path=None):
if font_path is None:
@@ -362,14 +392,46 @@ class easyXYPlot():
if "negative_cond" in plot_image_vars:
negative = negative + plot_image_vars["negative_cond"]
# DiffusionModel
if self.x_type == "DiffusionModel" or self.y_type == "DiffusionModel":
xy_values = x_value if self.x_type == "DiffusionModel" else y_value
model_name, clip_name, vae_name = xy_values.split(",")
model, clip, vae, family = self.easyCache.load_diffusion_xy_model(
model_name.replace("*", ","),
clip_name.replace("*", ","),
vae_name.replace("*", ","),
)
sd_version = family
positive = plot_image_vars["positive"]
negative = plot_image_vars["negative"]
if positive is not None:
positive, = CLIPTextEncode().encode(clip, positive)
if negative is not None:
negative, = CLIPTextEncode().encode(clip, negative)
samples = self._ensure_latent_for_model(
model, vae, samples, plot_image_vars
)
# Lora
if self.x_type == "Lora" or self.y_type == "Lora":
# print(f"Lora: {x_value} {y_value}")
model = model if model is not None else plot_image_vars["model"]
clip = clip if clip is not None else plot_image_vars["clip"]
xy_values = x_value if self.x_type == "Lora" else y_value
lora_name, lora_model_strength, lora_clip_strength, _ = xy_values.split(",")
lora_stack = [{"lora_name": lora_name, "model": model, "clip" :clip, "model_strength": float(lora_model_strength), "clip_strength": float(lora_clip_strength)}]
# Build lora_stack from both X and Y axes if both are LoRA types
lora_stack = []
# Add X axis LoRA if present
if self.x_type == "Lora":
lora_name, lora_model_strength, lora_clip_strength, _ = x_value.split(",")
lora_stack.append({"lora_name": lora_name, "model": model, "clip": clip, "model_strength": float(lora_model_strength), "clip_strength": float(lora_clip_strength)})
# Add Y axis LoRA if present
if self.y_type == "Lora":
lora_name, lora_model_strength, lora_clip_strength, _ = y_value.split(",")
lora_stack.append({"lora_name": lora_name, "model": model, "clip": clip, "model_strength": float(lora_model_strength), "clip_strength": float(lora_clip_strength)})
# print(f"new_lora_stack: {new_lora_stack}")
@@ -389,7 +451,7 @@ class easyXYPlot():
if self.x_type == 'Positive Prompt S/R' or self.y_type == 'Positive Prompt S/R':
positive = x_value if self.x_type == "Positive Prompt S/R" else y_value
if sd_version == 'flux':
if sd_version in ("flux", "anima", "krea2"):
positive, = CLIPTextEncode().encode(clip, positive)
else:
positive = advanced_encode(clip, positive,
@@ -405,7 +467,7 @@ class easyXYPlot():
if self.x_type == 'Negative Prompt S/R' or self.y_type == 'Negative Prompt S/R':
negative = x_value if self.x_type == "Negative Prompt S/R" else y_value
if sd_version == 'flux':
if sd_version in ("flux", "anima", "krea2"):
negative, = CLIPTextEncode().encode(clip, negative)
else:
negative = advanced_encode(clip, negative,
@@ -473,7 +535,7 @@ class easyXYPlot():
clip = clip.clone()
clip.clip_layer(plot_image_vars['clip_skip'])
if sd_version == 'flux':
if sd_version in ("flux", "anima", "krea2"):
positive, = CLIPTextEncode().encode(clip, positive)
else:
positive = advanced_encode(clip, plot_image_vars['positive'],
@@ -481,7 +543,7 @@ class easyXYPlot():
plot_image_vars['positive_weight_interpretation'], w_max=1.0,
apply_to_pooled="enable",a1111_prompt_style=a1111_prompt_style, steps=steps)
if sd_version == 'flux':
if sd_version in ("flux", "anima", "krea2"):
negative, = CLIPTextEncode().encode(clip, negative)
else:
negative = advanced_encode(clip, plot_image_vars['negative'],
@@ -577,28 +639,40 @@ class easyXYPlot():
def get_labels_and_sample(self, plot_image_vars, latent_image, preview_latent, start_step, last_step,
force_full_denoise, disable_noise):
for x_index, x_value in enumerate(self.x_values):
plot_image_vars, x_value_label = self.define_variable(plot_image_vars, self.x_type, x_value,
x_index)
self.x_label = self.update_label(self.x_label, x_value_label, len(self.x_values))
if self.y_type != 'None':
# Handle X-only variation (Y is "None")
if self.y_type == 'None':
for x_index, x_value in enumerate(self.x_values):
plot_image_vars, x_value_label = self.define_variable(plot_image_vars, self.x_type, x_value, x_index)
self.x_label = self.update_label(self.x_label, x_value_label, len(self.x_values))
self.image_list, self.max_width, self.max_height, self.latents_plot = self.sample_plot_image(
plot_image_vars, latent_image, preview_latent, self.latents_plot, self.image_list,
disable_noise, start_step, last_step, force_full_denoise, x_value)
self.num += 1
# Handle Y-only variation (X is "None")
elif self.x_type == 'None':
for y_index, y_value in enumerate(self.y_values):
plot_image_vars, y_value_label = self.define_variable(plot_image_vars, self.y_type, y_value, y_index)
self.y_label = self.update_label(self.y_label, y_value_label, len(self.y_values))
self.image_list, self.max_width, self.max_height, self.latents_plot = self.sample_plot_image(
plot_image_vars, latent_image, preview_latent, self.latents_plot, self.image_list,
disable_noise, start_step, last_step, force_full_denoise, y_value=y_value)
self.num += 1
# Handle both X and Y variation
else:
for x_index, x_value in enumerate(self.x_values):
plot_image_vars, x_value_label = self.define_variable(plot_image_vars, self.x_type, x_value, x_index)
self.x_label = self.update_label(self.x_label, x_value_label, len(self.x_values))
for y_index, y_value in enumerate(self.y_values):
plot_image_vars, y_value_label = self.define_variable(plot_image_vars, self.y_type, y_value,
y_index)
plot_image_vars, y_value_label = self.define_variable(plot_image_vars, self.y_type, y_value, y_index)
self.y_label = self.update_label(self.y_label, y_value_label, len(self.y_values))
# ttNl(f'{CC.GREY}X: {x_value_label}, Y: {y_value_label}').t(
# f'Plot Values {self.num}/{self.total} ->').p()
self.image_list, self.max_width, self.max_height, self.latents_plot = self.sample_plot_image(
plot_image_vars, latent_image, preview_latent, self.latents_plot, self.image_list,
disable_noise, start_step, last_step, force_full_denoise, x_value, y_value)
self.num += 1
else:
# ttNl(f'{CC.GREY}X: {x_value_label}').t(f'Plot Values {self.num}/{self.total} ->').p()
self.image_list, self.max_width, self.max_height, self.latents_plot = self.sample_plot_image(
plot_image_vars, latent_image, preview_latent, self.latents_plot, self.image_list, disable_noise,
start_step, last_step, force_full_denoise, x_value)
self.num += 1
# Rearrange latent array to match preview image grid
self.latents_plot = self.rearrange_tensors(self.latents_plot, self.num_cols, self.num_rows)
@@ -77,7 +77,7 @@ class ControlPixArtHalf(Module):
def forward_c(self, c):
self.h, self.w = c.shape[-2]//self.patch_size, c.shape[-1]//self.patch_size
pos_embed = torch.from_numpy(get_2d_sincos_pos_embed(self.pos_embed.shape[-1], (self.h, self.w), lewei_scale=self.lewei_scale, base_size=self.base_size)).unsqueeze(0).to(c.device).to(self.dtype)
pos_embed = torch.from_numpy(get_2d_sincos_pos_embed(self.pos_embed.shape[-1], (self.h, self.w), pe_interpolation=self.pe_interpolation, base_size=self.base_size)).unsqueeze(0).to(c.device).to(self.dtype)
return self.x_embedder(c) + pos_embed if c is not None else c
# def forward(self, x, t, c, **kwargs):
@@ -225,7 +225,7 @@ class ControlPixArtMSHalf(ControlPixArtHalf):
c_size, ar = data_info['img_hw'].to(self.dtype), data_info['aspect_ratio'].to(self.dtype)
self.h, self.w = x.shape[-2]//self.patch_size, x.shape[-1]//self.patch_size
pos_embed = torch.from_numpy(get_2d_sincos_pos_embed(self.pos_embed.shape[-1], (self.h, self.w), lewei_scale=self.lewei_scale, base_size=self.base_size)).unsqueeze(0).to(x.device).to(self.dtype)
pos_embed = torch.from_numpy(get_2d_sincos_pos_embed(self.pos_embed.shape[-1], (self.h, self.w), pe_interpolation=self.pe_interpolation, base_size=self.base_size)).unsqueeze(0).to(x.device).to(self.dtype)
x = self.x_embedder(x) + pos_embed # (N, T, D), where T = H * W / patch_size ** 2
t = self.t_embedder(timestep) # (N, D)
csize = self.csize_embedder(c_size, bs) # (N, D)
+1 -1
View File
@@ -28,7 +28,7 @@ class DoubleStreamBlockIPA(nn.Module):
self.txt_norm2 = original_block.txt_norm2
self.txt_mlp = original_block.txt_mlp
self.flipped_img_txt = original_block.flipped_img_txt
self.flipped_img_txt = getattr(original_block, 'flipped_img_txt', False)
self.ip_adapter = ip_adapter
self.image_emb = image_emb
+4 -1
View File
@@ -150,7 +150,10 @@ class LayerDiffuse:
alpha = pixel_with_alpha[..., 0]
alpha = 1.0 - alpha
new_images, = JoinImageWithAlpha().join_image_with_alpha(image, alpha)
try:
new_images, = JoinImageWithAlpha().execute(image, alpha)
except:
new_images, = JoinImageWithAlpha().join_image_with_alpha(image, alpha)
return new_images, alpha
def make_3d_mask(self, mask):
+6 -2
View File
@@ -438,7 +438,11 @@ class detailerFix:
# Clean loaded_objects
easyCache.update_loaded_objects(prompt)
my_unique_id = int(my_unique_id)
# my_unique_id can be a composite ID (e.g. `101:134`) when put inside a sub-graph; this fixes it so that it can support sub-graphs
try:
my_unique_id = int(my_unique_id)
except (ValueError, TypeError):
my_unique_id = int(str(my_unique_id).split(':')[-1])
model = model or (pipe["model"] if "model" in pipe else None)
if model is None:
@@ -640,4 +644,4 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"easy ultralyticsDetectorPipe": "UltralyticsDetector (Pipe)",
"easy samLoaderPipe": "SAMLoader (Pipe)",
"easy detailerFix": "DetailerFix",
}
}
+26 -2
View File
@@ -1012,6 +1012,7 @@ class imageChooser(PreviewImage):
return {
"required":{
"mode": (['Always Pause', 'Keep Last Selection'], {"default": "Always Pause"}),
"preview_rescale": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 1.0, "step": 0.05}),
},
"optional": {
"images": ("IMAGE",),
@@ -1053,7 +1054,13 @@ class imageChooser(PreviewImage):
pnginfo = extra_pnginfo[0]
except:
pnginfo = None
result = self.save_images(images=images_in, prompt=prompt, extra_pnginfo=pnginfo)
preview_rescale = kwargs.pop('preview_rescale', 1.0)
if preview_rescale < 1.0:
images_preview, = imageScaleDownBy().image_scale_down_by(images_in, preview_rescale)
else:
images_preview = images_in
result = self.save_images(images=images_preview, prompt=prompt, extra_pnginfo=pnginfo)
if "ui" in result and "images" in result['ui']:
images = result["ui"]["images"]
else:
@@ -1211,6 +1218,8 @@ class imageDetailTransfer:
new_image = torch.lerp(target_tensor, new_image, blend_factor)
if mask is not None:
mask = mask.to(device)
if mask.dim() == 3: # (B,H,W) batch/video mask -> (B,1,H,W) so it broadcasts over channels
mask = mask.unsqueeze(1)
new_image = torch.lerp(target_tensor, new_image, mask)
new_image = torch.clamp(new_image, 0, 1)
new_image = new_image.permute(0, 2, 3, 1).cpu().float()
@@ -1295,6 +1304,11 @@ class humanSegmentation:
return mp.Image(image_format=image_format, data=numpy_image)
def parsing(self, image, confidence, method, crop_multi, mask_components, prompt=None, my_unique_id=None):
if isinstance(mask_components, str):
mask_components = [int(x) for x in mask_components.split(',') if x]
else:
mask_components = mask_components if mask_components else []
if method == 'selfie_multiclass_256x256':
try:
import mediapipe as mp
@@ -1321,6 +1335,9 @@ class humanSegmentation:
ret_images = []
ret_masks = []
if len(mask_components) == 0:
return (image, torch.zeros_like(image[:, :, :, 0:1]), torch.tensor([0,0,0,0]))
with mp.tasks.vision.ImageSegmenter.create_from_options(options) as segmenter:
for img in image:
_image = torch.unsqueeze(img, 0)
@@ -1354,7 +1371,14 @@ class humanSegmentation:
mask_arrays.append(mask_background_array)
else:
for i, mask in enumerate(masks):
condition = np.stack((mask.numpy_view(),) * image_shape[-1], axis=-1) > confidence
mask_2d = mask.numpy_view()
if mask_2d.ndim == 3 and mask_2d.shape[2] == 1:
mask_2d = mask_2d.squeeze(axis=2)
elif mask_2d.ndim != 2:
raise ValueError(f"Unexpected mask shape: {mask_2d.shape}")
condition = np.stack((mask_2d,) * image_shape[-1], axis=-1) > confidence
if condition.ndim == 4 and condition.shape[2] == 1:
condition = condition.squeeze(2)
mask_array = np.where(condition, mask_foreground_array, mask_background_array)
mask_arrays.append(mask_array)
# Merge our masks taking the maximum from each
+100 -5
View File
@@ -1146,6 +1146,90 @@ class mochiLoader(fullLoader):
batch_size, model_override, clip_override, vae_override, a1111_prompt_style=False, video_length=length, prompt=prompt,
my_unique_id=my_unique_id
)
# Diffusion model loader
class diffusionModelLoader:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model_name": (folder_paths.get_filename_list("diffusion_models"),),
"vae_name": (["None"] + folder_paths.get_filename_list("vae"), {"default": "None"}),
"clip_name": (["None"] + folder_paths.get_filename_list("text_encoders"), {"default": "None"}),
"resolution": (resolution_strings, {"default": "1024 x 1024"}),
"empty_latent_width": ("INT", {"default": 1024, "min": 64, "max": MAX_RESOLUTION, "step": 8}),
"empty_latent_height": ("INT", {"default": 1024, "min": 64, "max": MAX_RESOLUTION, "step": 8}),
"positive": ("STRING", {"default": "", "multiline": True}),
"negative": ("STRING", {"default": "", "multiline": True}),
"batch_size": ("INT", {"default": 1, "min": 1, "max": 64}),
},
"optional": {
"model_override": ("MODEL",),
"clip_override": ("CLIP",),
"vae_override": ("VAE",),
},
"hidden": {"prompt": "PROMPT", "my_unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = ("PIPE_LINE", "MODEL", "VAE", "CLIP", "CONDITIONING", "CONDITIONING", "LATENT")
RETURN_NAMES = ("pipe", "model", "vae", "clip", "positive", "negative", "latent")
FUNCTION = "adv_pipeloader"
CATEGORY = "EasyUse/Loaders"
def adv_pipeloader(self, model_name, vae_name, clip_name, resolution,
empty_latent_width, empty_latent_height, positive, negative,
batch_size, model_override=None, clip_override=None,
vae_override=None, prompt=None, my_unique_id=None):
easyCache.update_loaded_objects(prompt)
model, clip, vae, family = easyCache.load_diffusion_model_required(
model_name, clip_name, vae_name
)
if model_override is not None:
model = model_override
if clip_override is not None:
clip = clip_override
if vae_override is not None:
vae = vae_override
samples = sampler.emptyLatent(resolution, empty_latent_width,
empty_latent_height, batch_size,
model_type=family)
positive_cond, positive_wildcard, model, clip = prompt_to_cond(
"positive", model, clip, 0, [], positive, "none", "comfy",
False, my_unique_id, prompt, easyCache, model_type=family)
negative_cond, negative_wildcard, model, clip = prompt_to_cond(
"negative", model, clip, 0, [], negative, "none", "comfy",
False, my_unique_id, prompt, easyCache, model_type=family)
if negative_cond is None:
negative_cond, = ConditioningZeroOut().zero_out(positive_cond)
pipe = {
"model": model,
"positive": positive_cond,
"negative": negative_cond,
"vae": vae,
"clip": clip,
"samples": samples,
"images": None,
"loader_settings": {
"model_name": model_name,
"clip_name": clip_name,
"vae_name": vae_name,
"model_type": family,
"positive": positive,
"negative": negative,
"resolution": resolution,
"empty_latent_width": empty_latent_width,
"empty_latent_height": empty_latent_height,
"batch_size": batch_size,
},
}
return pipe, model, vae, clip, positive_cond, negative_cond, samples
# lora
class loraSwitcher:
@classmethod
@@ -1175,9 +1259,12 @@ class loraSwitcher:
CATEGORY = "EasyUse/Loaders"
def stack(self, toggle, select,num_loras, lora_strength, optional_lora_stack=None, **kwargs):
if (toggle in [False, None, "False"]) or not kwargs:
return (None,'')
def stack(self, toggle, select,num_loras, lora_strength, optional_lora_stack=None, **kwargs):
if toggle in [False, None, "False"]:
return (optional_lora_stack, '')
if not kwargs and optional_lora_stack is None:
return (None, '')
loras = []
@@ -1234,7 +1321,10 @@ class loraStack:
CATEGORY = "EasyUse/Loaders"
def stack(self, toggle, mode, num_loras, optional_lora_stack=None, **kwargs):
if (toggle in [False, None, "False"]) or not kwargs:
if toggle in [False, None, "False"]:
return (optional_lora_stack,)
if not kwargs and optional_lora_stack is None:
return (None,)
loras = []
@@ -1291,7 +1381,10 @@ class controlnetStack:
CATEGORY = "EasyUse/Loaders"
def stack(self, toggle, mode, num_controlnet, optional_controlnet_stack=None, **kwargs):
if (toggle in [False, None, "False"]) or not kwargs:
if toggle in [False, None, "False"]:
return (optional_controlnet_stack,)
if not kwargs and optional_controlnet_stack is None:
return (None,)
controlnets = []
@@ -1533,6 +1626,7 @@ NODE_CLASS_MAPPINGS = {
"easy hunyuanDiTLoader": hunyuanDiTLoader,
"easy pixArtLoader": pixArtLoader,
"easy mochiLoader": mochiLoader,
"easy diffusionModelLoader": diffusionModelLoader,
"easy loraSwitcher": loraSwitcher,
"easy loraStack": loraStack,
"easy controlnetStack": controlnetStack,
@@ -1555,6 +1649,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"easy hunyuanDiTLoader": "EasyLoader (HunyuanDiT)",
"easy pixArtLoader": "EasyLoader (PixArt)",
"easy mochiLoader": "EasyLoader (Mochi)",
"easy diffusionModelLoader": "EasyDiffusionModelLoader",
"easy loraSwitcher": "EasyLoraSwitcher",
"easy loraStack": "EasyLoraStack",
"easy controlnetStack": "EasyControlnetStack",
+1052 -1059
View File
File diff suppressed because it is too large Load Diff
+24 -14
View File
@@ -554,13 +554,17 @@ class pipeXYPlotAdvanced:
if font_path and not os.path.exists(font_path):
font_path = os.path.join(self.user_font_dir, font)
if X != None:
if X is not None:
if isinstance(X, tuple):
X = X[0]
x_axis = X.get('axis')
x_values = X.get('values')
else:
x_axis = "Nothing"
x_values = [""]
if Y != None:
if Y is not None:
if isinstance(Y, tuple):
Y = Y[0]
y_axis = Y.get('axis')
y_values = Y.get('values')
else:
@@ -625,20 +629,26 @@ class pipeXYPlotAdvanced:
"lora_stack": lora_stack,
}
if x_axis == "advanced: DiffusionModel":
x_values = "; ".join(x_values)
if y_axis == "advanced: DiffusionModel":
y_values = "; ".join(y_values)
if x_axis == 'advanced: Seeds++ Batch':
if new_pipe['seed']:
value = x_values
x_values = []
for index in range(value):
x_values.append(str(new_pipe['seed'] + index))
x_values = "; ".join(x_values)
seed = new_pipe.get('seed') or 0
value = x_values
x_values = []
for index in range(value):
x_values.append(str(seed + index))
x_values = "; ".join(x_values)
if y_axis == 'advanced: Seeds++ Batch':
if new_pipe['seed']:
value = y_values
y_values = []
for index in range(value):
y_values.append(str(new_pipe['seed'] + index))
y_values = "; ".join(y_values)
seed = new_pipe.get('seed') or 0
value = y_values
y_values = []
for index in range(value):
y_values.append(str(seed + index))
y_values = "; ".join(y_values)
if x_axis == 'advanced: Positive Prompt S/R':
if positive:
+5 -4
View File
@@ -262,15 +262,16 @@ class samplerSettingsNoiseIn:
model = pipe["model"]
# generate base noise
batch_size, _, height, width = latent["samples"].shape
sample_shape = latent["samples"].shape
batch_size = sample_shape[0]
generator = torch.manual_seed(seed)
base_noise = torch.randn((1, 4, height, width), dtype=torch.float32, device="cpu", generator=generator).repeat(batch_size, 1, 1, 1).cpu()
base_noise = torch.randn((1, *sample_shape[1:]), dtype=torch.float32, device="cpu", generator=generator).repeat(batch_size, *([1] * (len(sample_shape) - 1))).cpu()
# generate variation noise
if optional_noise_seed is None or optional_noise_seed == seed:
optional_noise_seed = seed+1
generator = torch.manual_seed(optional_noise_seed)
variation_noise = torch.randn((batch_size, 4, height, width), dtype=torch.float32, device="cpu",
variation_noise = torch.randn(sample_shape, dtype=torch.float32, device="cpu",
generator=generator).cpu()
slerp_noise = self.slerp(factor, base_noise, variation_noise)
@@ -367,7 +368,7 @@ class samplerCustomSettings:
FUNCTION = "settings"
CATEGORY = "EasyUse/PreSampling"
def ip2p(self, positive, negative, vae, pixels, latent=None):
def ip2p(self, positive, negative, vae=None, pixels=None, latent=None):
if latent is not None:
concat_latent = latent
else:
+447 -295
View File
@@ -1,157 +1,151 @@
import json
import os
from urllib.request import urlopen
import folder_paths
from .. import easyCache
from ..config import FOOOCUS_STYLES_DIR, MAX_SEED_NUM, PROMPT_TEMPLATE, RESOURCES_DIR
from ..libs.log import log_node_info
from ..libs.utils import AlwaysEqualProxy
from ..libs.wildcards import WildcardProcessor, get_wildcard_list, process
from comfy_api.latest import io
# 正面提示词
class positivePrompt:
def __init__(self):
pass
class positivePrompt(io.ComfyNode):
@classmethod
def INPUT_TYPES(s):
return {"required": {
"positive": ("STRING", {"default": "", "multiline": True, "placeholder": "Positive"}),}
}
def define_schema(cls):
return io.Schema(
node_id="easy positive",
category="EasyUse/Prompt",
inputs=[
io.String.Input("positive", default="", multiline=True, placeholder="Positive"),
],
outputs=[
io.String.Output(id="output_positive", display_name="positive"),
],
)
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("positive",)
FUNCTION = "main"
CATEGORY = "EasyUse/Prompt"
@staticmethod
def main(positive):
return positive,
@classmethod
def execute(cls, positive):
return io.NodeOutput(positive)
# 通配符提示词
class wildcardsPrompt:
def __init__(self):
pass
class wildcardsPrompt(io.ComfyNode):
@classmethod
def INPUT_TYPES(s):
def define_schema(cls):
wildcard_list = get_wildcard_list()
return {"required": {
"text": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False, "placeholder": "(Support wildcard)"}),
"Select to add LoRA": (["Select the LoRA to add to the text"] + folder_paths.get_filename_list("loras"),),
"Select to add Wildcard": (["Select the Wildcard to add to the text"] + wildcard_list,),
"seed": ("INT", {"default": 0, "min": 0, "max": MAX_SEED_NUM}),
"multiline_mode": ("BOOLEAN", {"default": False}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", "my_unique_id": "UNIQUE_ID"},
}
return io.Schema(
node_id="easy wildcards",
category="EasyUse/Prompt",
inputs=[
io.String.Input("text", default="", multiline=True, dynamic_prompts=False, placeholder="(Support wildcard)"),
io.Combo.Input("Select to add LoRA", options=["Select the LoRA to add to the text"] + folder_paths.get_filename_list("loras")),
io.Combo.Input("Select to add Wildcard", options=["Select the Wildcard to add to the text"] + wildcard_list),
io.Int.Input("seed", default=0, min=0, max=MAX_SEED_NUM),
io.Boolean.Input("multiline_mode", default=False),
],
outputs=[
io.String.Output(id="output_text", display_name="text", is_output_list=True),
io.String.Output(id="populated_text", display_name="populated_text", is_output_list=True),
],
hidden=[
io.Hidden.prompt,
io.Hidden.extra_pnginfo,
io.Hidden.unique_id,
],
)
RETURN_TYPES = ("STRING", "STRING")
RETURN_NAMES = ("text", "populated_text")
OUTPUT_IS_LIST = (True, True)
FUNCTION = "main"
CATEGORY = "EasyUse/Prompt"
def main(self, *args, **kwargs):
prompt = kwargs["prompt"] if "prompt" in kwargs else None
seed = kwargs["seed"]
@classmethod
def execute(cls, text, seed, multiline_mode, **kwargs):
prompt = cls.hidden.prompt
# Clean loaded_objects
if prompt:
easyCache.update_loaded_objects(prompt)
text = kwargs['text']
if "multiline_mode" in kwargs and kwargs["multiline_mode"]:
if multiline_mode:
populated_text = []
_text = []
text = text.split("\n")
for t in text:
text_lines = text.split("\n")
for t in text_lines:
_text.append(t)
populated_text.append(process(t, seed))
text = _text
else:
populated_text = [process(text, seed)]
text = [text]
return {"ui": {"value": [seed]}, "result": (text, populated_text)}
return io.NodeOutput(text, populated_text, ui={"value": [seed]})
# 通配符提示词矩阵,会按顺序返回包含通配符的提示词所生成的所有可能
class wildcardsPromptMatrix:
def __init__(self):
pass
class wildcardsPromptMatrix(io.ComfyNode):
@classmethod
def INPUT_TYPES(s):
def define_schema(cls):
wildcard_list = get_wildcard_list()
return {"required": {
"text": ("STRING", {"default": "", "multiline": True, "dynamicPrompts": False, "placeholder": "(Support Lora Block Weight and wildcard)"}),
"Select to add LoRA": (["Select the LoRA to add to the text"] + folder_paths.get_filename_list("loras"),),
"Select to add Wildcard": (["Select the Wildcard to add to the text"] + wildcard_list,),
"offset": ("INT", {"default": 0, "min": 0, "step": 1, "control_after_generate": True}),
},
"optional":{
"output_limit": ("INT", {"default": 1, "min": -1, "step": 1, "tooltip": "Output All Probilities"})
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", "my_unique_id": "UNIQUE_ID"},
}
return io.Schema(
node_id="easy wildcardsMatrix",
category="EasyUse/Prompt",
inputs=[
io.String.Input("text", default="", multiline=True, dynamic_prompts=False, placeholder="(Support Lora Block Weight and wildcard)"),
io.Combo.Input("Select to add LoRA", options=["Select the LoRA to add to the text"] + folder_paths.get_filename_list("loras")),
io.Combo.Input("Select to add Wildcard", options=["Select the Wildcard to add to the text"] + wildcard_list),
io.Int.Input("offset", default=0, min=0, max=MAX_SEED_NUM, step=1, control_after_generate=True),
io.Int.Input("output_limit", default=1, min=-1, step=1, tooltip="Output All Probilities", optional=True),
],
outputs=[
io.String.Output("populated_text", is_output_list=True),
io.Int.Output("total"),
io.Int.Output("factors", is_output_list=True),
],
hidden=[
io.Hidden.prompt,
io.Hidden.extra_pnginfo,
io.Hidden.unique_id,
],
)
RETURN_TYPES = ("STRING", "INT", "INT")
RETURN_NAMES = ("populated_text", "total", "factors")
OUTPUT_IS_LIST = (True, False, True)
FUNCTION = "main"
CATEGORY = "EasyUse/Prompt"
def main(self, *args, **kwargs):
prompt = kwargs["prompt"] if "prompt" in kwargs else None
offset = kwargs["offset"]
output_limit = kwargs.get("output_limit", 1)
@classmethod
def execute(cls, text, offset, output_limit=1, **kwargs):
prompt = cls.hidden.prompt
# Clean loaded_objects
if prompt:
easyCache.update_loaded_objects(prompt)
text = kwargs['text']
p = WildcardProcessor(text)
total = p.total()
limit = total if output_limit > total or output_limit == -1 else output_limit
offset = 0 if output_limit == -1 else offset
populated_text = p.getmany(limit, offset) if output_limit != 1 else [p.getn(offset)]
return {"ui": {"value": [offset]}, "result": (populated_text, p.total(), list(p.placeholder_choices.values()))}
return io.NodeOutput(populated_text, p.total(), list(p.placeholder_choices.values()), ui={"value": [offset]})
# 负面提示词
class negativePrompt:
def __init__(self):
pass
class negativePrompt(io.ComfyNode):
@classmethod
def INPUT_TYPES(s):
return {"required": {
"negative": ("STRING", {"default": "", "multiline": True, "placeholder": "Negative"}),}
}
def define_schema(cls):
return io.Schema(
node_id="easy negative",
category="EasyUse/Prompt",
inputs=[
io.String.Input("negative", default="", multiline=True, placeholder="Negative"),
],
outputs=[
io.String.Output(id="output_negative", display_name="negative"),
],
)
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("negative",)
FUNCTION = "main"
CATEGORY = "EasyUse/Prompt"
@staticmethod
def main(negative):
return negative,
@classmethod
def execute(cls, negative):
return io.NodeOutput(negative)
# 风格提示词选择器
class stylesPromptSelector:
class stylesPromptSelector(io.ComfyNode):
@classmethod
def INPUT_TYPES(s):
def define_schema(cls):
styles = ["fooocus_styles"]
styles_dir = FOOOCUS_STYLES_DIR
for file_name in os.listdir(styles_dir):
@@ -160,25 +154,28 @@ class stylesPromptSelector:
if file_name != "fooocus_styles.json":
styles.append(file_name.split(".")[0])
return {
"required": {
"styles": (styles, {"default": "fooocus_styles"}),
},
"optional": {
"positive": ("STRING", {"forceInput": True}),
"negative": ("STRING", {"forceInput": True}),
"select_styles": ("EASY_PROMPT_STYLES", {}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", "my_unique_id": "UNIQUE_ID"},
}
return io.Schema(
node_id="easy stylesSelector",
category="EasyUse/Prompt",
inputs=[
io.Combo.Input("styles", options=styles, default="fooocus_styles"),
io.String.Input("positive", default="", force_input=True, optional=True),
io.String.Input("negative", default="", force_input=True, optional=True),
io.Custom(io_type="EASY_PROMPT_STYLES").Input("select_styles", optional=True),
],
outputs=[
io.String.Output(id="output_positive", display_name="positive"),
io.String.Output(id="output_negative", display_name="negative"),
],
hidden=[
io.Hidden.prompt,
io.Hidden.extra_pnginfo,
io.Hidden.unique_id,
],
)
RETURN_TYPES = ("STRING", "STRING",)
RETURN_NAMES = ("positive", "negative",)
CATEGORY = 'EasyUse/Prompt'
FUNCTION = 'run'
def run(self, styles, positive='', negative='', select_styles=None, prompt=None, extra_pnginfo=None, my_unique_id=None):
@classmethod
def execute(cls, styles, positive='', negative='', select_styles=None, **kwargs):
values = []
all_styles = {}
positive_prompt, negative_prompt = '', negative
@@ -203,9 +200,11 @@ class stylesPromptSelector:
has_prompt = False
if len(values) == 0:
return (positive, negative)
return io.NodeOutput(positive, negative)
for index, val in enumerate(values):
if val not in all_styles:
continue
if 'prompt' in all_styles[val]:
if "{prompt}" in all_styles[val]['prompt'] and has_prompt == False:
positive_prompt = all_styles[val]['prompt'].replace('{prompt}', positive)
@@ -220,96 +219,105 @@ class stylesPromptSelector:
if has_prompt == False and positive:
positive_prompt = positive + positive_prompt + ', '
return (positive_prompt, negative_prompt)
return io.NodeOutput(positive_prompt, negative_prompt)
#prompt
class prompt:
class prompt(io.ComfyNode):
@classmethod
def INPUT_TYPES(s):
return {"required": {
"text": ("STRING", {"default": "", "multiline": True, "placeholder": "Prompt"}),
"prefix": (["Select the prefix add to the text"] + PROMPT_TEMPLATE["prefix"], {"default": "Select the prefix add to the text"}),
"subject": (["👤Select the subject add to the text"] + PROMPT_TEMPLATE["subject"], {"default": "👤Select the subject add to the text"}),
"action": (["🎬Select the action add to the text"] + PROMPT_TEMPLATE["action"], {"default": "🎬Select the action add to the text"}),
"clothes": (["👚Select the clothes add to the text"] + PROMPT_TEMPLATE["clothes"], {"default": "👚Select the clothes add to the text"}),
"environment": (["☀️Select the illumination environment add to the text"] + PROMPT_TEMPLATE["environment"], {"default": "☀️Select the illumination environment add to the text"}),
"background": (["🎞️Select the background add to the text"] + PROMPT_TEMPLATE["background"], {"default": "🎞️Select the background add to the text"}),
"nsfw": (["🔞Select the nsfw add to the text"] + PROMPT_TEMPLATE["nsfw"], {"default": "🔞️Select the nsfw add to the text"}),
},"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO", "my_unique_id": "UNIQUE_ID"},}
def define_schema(cls):
return io.Schema(
node_id="easy prompt",
category="EasyUse/Prompt",
inputs=[
io.String.Input("text", default="", multiline=True, placeholder="Prompt"),
io.Combo.Input("prefix", options=["Select the prefix add to the text"] + PROMPT_TEMPLATE["prefix"], default="Select the prefix add to the text"),
io.Combo.Input("subject", options=["👤Select the subject add to the text"] + PROMPT_TEMPLATE["subject"], default="👤Select the subject add to the text"),
io.Combo.Input("action", options=["🎬Select the action add to the text"] + PROMPT_TEMPLATE["action"], default="🎬Select the action add to the text"),
io.Combo.Input("clothes", options=["👚Select the clothes add to the text"] + PROMPT_TEMPLATE["clothes"], default="👚Select the clothes add to the text"),
io.Combo.Input("environment", options=["☀️Select the illumination environment add to the text"] + PROMPT_TEMPLATE["environment"], default="☀️Select the illumination environment add to the text"),
io.Combo.Input("background", options=["🎞️Select the background add to the text"] + PROMPT_TEMPLATE["background"], default="🎞️Select the background add to the text"),
io.Combo.Input("nsfw", options=["🔞Select the nsfw add to the text"] + PROMPT_TEMPLATE["nsfw"], default="🔞️Select the nsfw add to the text"),
],
outputs=[
io.String.Output("prompt"),
],
hidden=[
io.Hidden.prompt,
io.Hidden.extra_pnginfo,
io.Hidden.unique_id,
],
)
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",)
FUNCTION = "doit"
CATEGORY = "EasyUse/Prompt"
def doit(self, *args, **kwargs):
text = kwargs['text']
return (text,)
@classmethod
def execute(cls, text, **kwargs):
return io.NodeOutput(text)
#promptList
class promptList:
class promptList(io.ComfyNode):
@classmethod
def INPUT_TYPES(cls):
return {"required": {
"prompt_1": ("STRING", {"multiline": True, "default": ""}),
"prompt_2": ("STRING", {"multiline": True, "default": ""}),
"prompt_3": ("STRING", {"multiline": True, "default": ""}),
"prompt_4": ("STRING", {"multiline": True, "default": ""}),
"prompt_5": ("STRING", {"multiline": True, "default": ""}),
},
"optional": {
"optional_prompt_list": ("LIST",)
}
}
def define_schema(cls):
return io.Schema(
node_id="easy promptList",
category="EasyUse/Prompt",
inputs=[
io.String.Input("prompt_1", multiline=True, default=""),
io.String.Input("prompt_2", multiline=True, default=""),
io.String.Input("prompt_3", multiline=True, default=""),
io.String.Input("prompt_4", multiline=True, default=""),
io.String.Input("prompt_5", multiline=True, default=""),
io.Custom(io_type="LIST").Input("optional_prompt_list", optional=True),
],
outputs=[
io.Custom(io_type="LIST").Output("prompt_list"),
io.String.Output("prompt_strings", is_output_list=True),
],
)
RETURN_TYPES = ("LIST", "STRING")
RETURN_NAMES = ("prompt_list", "prompt_strings")
OUTPUT_IS_LIST = (False, True)
FUNCTION = "run"
CATEGORY = "EasyUse/Prompt"
def run(self, **kwargs):
@classmethod
def execute(cls, prompt_1="", prompt_2="", prompt_3="", prompt_4="", prompt_5="", optional_prompt_list=None, **kwargs):
prompts = []
if "optional_prompt_list" in kwargs:
for l in kwargs["optional_prompt_list"]:
if optional_prompt_list:
for l in optional_prompt_list:
prompts.append(l)
# Iterate over the received inputs in sorted order.
for k in sorted(kwargs.keys()):
v = kwargs[k]
# Add individual prompts
for p in [prompt_1, prompt_2, prompt_3, prompt_4, prompt_5]:
if isinstance(p, str) and p != '':
prompts.append(p)
# Only process string input ports.
if isinstance(v, str) and v != '':
prompts.append(v)
return (prompts, prompts)
return io.NodeOutput(prompts, prompts)
#promptLine
class promptLine:
class promptLine(io.ComfyNode):
@classmethod
def INPUT_TYPES(s):
return {"required": {
"prompt": ("STRING", {"multiline": True, "default": "text"}),
"start_index": ("INT", {"default": 0, "min": 0, "max": 9999}),
"max_rows": ("INT", {"default": 1000, "min": 1, "max": 9999}),
},
"hidden":{
"workflow_prompt": "PROMPT", "my_unique_id": "UNIQUE_ID"
}
}
def define_schema(cls):
return io.Schema(
node_id="easy promptLine",
category="EasyUse/Prompt",
inputs=[
io.String.Input("prompt", multiline=True, default="text"),
io.Int.Input("start_index", default=0, min=0, max=9999),
io.Int.Input("max_rows", default=1000, min=1, max=9999),
io.Boolean.Input("remove_empty_lines", default=True),
],
outputs=[
io.String.Output("STRING", is_output_list=True),
io.Combo.Output("COMBO", is_output_list=True),
],
hidden=[
io.Hidden.prompt,
io.Hidden.unique_id,
],
)
RETURN_TYPES = ("STRING", AlwaysEqualProxy('*'))
RETURN_NAMES = ("STRING", "COMBO")
OUTPUT_IS_LIST = (True, True)
FUNCTION = "generate_strings"
CATEGORY = "EasyUse/Prompt"
def generate_strings(self, prompt, start_index, max_rows, workflow_prompt=None, my_unique_id=None):
@classmethod
def execute(cls, prompt, start_index, max_rows, remove_empty_lines=True, **kwargs):
lines = prompt.split('\n')
# lines = [zh_to_en([v])[0] if has_chinese(v) else v for v in lines if v]
if remove_empty_lines:
lines = [line for line in lines if line.strip()]
start_index = max(0, min(start_index, len(lines) - 1))
@@ -317,35 +325,40 @@ class promptLine:
rows = lines[start_index:end_index]
return (rows, rows)
return io.NodeOutput(rows, rows)
import comfy.utils
from server import PromptServer
from ..libs.messages import MessageCancelled, Message
any_type = AlwaysEqualProxy("*")
class promptAwait:
class promptAwait(io.ComfyNode):
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"now": (any_type,),
"prompt": ("STRING", {"multiline": True, "default": "", "placeholder":"Enter a prompt or use voice to enter to text"}),
"toolbar":("EASY_PROMPT_AWAIT_BAR",),
},
"optional":{
"prev": (any_type,),
},
"hidden": {"workflow_prompt": "PROMPT", "my_unique_id": "UNIQUE_ID", "extra_pnginfo": "EXTRA_PNGINFO"},
}
def define_schema(cls):
return io.Schema(
node_id="easy promptAwait",
category="EasyUse/Prompt",
inputs=[
io.AnyType.Input("now"),
io.String.Input("prompt", multiline=True, default="", placeholder="Enter a prompt or use voice to enter to text"),
io.Custom(io_type="EASY_PROMPT_AWAIT_BAR").Input("toolbar"),
io.AnyType.Input("prev", optional=True),
],
outputs=[
io.AnyType.Output(id="output", display_name="output"),
io.String.Output(id="output_prompt", display_name="prompt"),
io.Boolean.Output("continue"),
io.Int.Output("seed"),
],
hidden=[
io.Hidden.prompt,
io.Hidden.unique_id,
io.Hidden.extra_pnginfo,
],
)
RETURN_TYPES = (any_type, "STRING", "BOOLEAN", "INT")
RETURN_NAMES = ("output", "prompt", "continue", "seed")
FUNCTION = "await_select"
CATEGORY = "EasyUse/Prompt"
def await_select(self, now, prompt, toolbar, prev=None, workflow_prompt=None, my_unique_id=None, extra_pnginfo=None, **kwargs):
id = my_unique_id
@classmethod
def execute(cls, now, prompt, toolbar, prev=None, **kwargs):
id = cls.hidden.unique_id
id = id.split('.')[len(id.split('.')) - 1] if "." in id else id
if ":" in id:
id = id.split(":")[0]
@@ -360,60 +373,64 @@ class promptAwait:
input = now if res['select'] == 'now' or prev is None else prev
result = (input, res['prompt'], False if res['result'] == -1 else True, res['seed'] if res['unlock'] else res['last_seed'])
pbar.update_absolute(100)
return result
return io.NodeOutput(*result)
except MessageCancelled:
pbar.update_absolute(100)
raise comfy.model_management.InterruptProcessingException()
class promptConcat:
class promptConcat(io.ComfyNode):
@classmethod
def INPUT_TYPES(cls):
return {"required": {},
"optional": {
"prompt1": ("STRING", {"multiline": False, "default": "", "forceInput": True}),
"prompt2": ("STRING", {"multiline": False, "default": "", "forceInput": True}),
"separator": ("STRING", {"multiline": False, "default": ""}),
},
}
RETURN_TYPES = ("STRING", )
RETURN_NAMES = ("prompt", )
FUNCTION = "concat_text"
CATEGORY = "EasyUse/Prompt"
def concat_text(self, prompt1="", prompt2="", separator=""):
return (prompt1 + separator + prompt2,)
class promptReplace:
def define_schema(cls):
return io.Schema(
node_id="easy promptConcat",
category="EasyUse/Prompt",
inputs=[
io.String.Input("prompt1", multiline=False, default="", force_input=True, optional=True),
io.String.Input("prompt2", multiline=False, default="", force_input=True, optional=True),
io.String.Input("separator", multiline=False, default="", optional=True),
],
outputs=[
io.String.Output("prompt"),
],
)
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"prompt": ("STRING", {"multiline": True, "default": "", "forceInput": True}),
},
"optional": {
"find1": ("STRING", {"multiline": False, "default": ""}),
"replace1": ("STRING", {"multiline": False, "default": ""}),
"find2": ("STRING", {"multiline": False, "default": ""}),
"replace2": ("STRING", {"multiline": False, "default": ""}),
"find3": ("STRING", {"multiline": False, "default": ""}),
"replace3": ("STRING", {"multiline": False, "default": ""}),
},
}
def execute(cls, prompt1="", prompt2="", separator=""):
def to_string(value):
if isinstance(value, (list, tuple)):
return ", ".join(to_string(v) for v in value)
return str(value)
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("prompt",)
FUNCTION = "replace_text"
CATEGORY = "EasyUse/Prompt"
return io.NodeOutput(to_string(prompt1) + to_string(separator) + to_string(prompt2))
def replace_text(self, prompt, find1="", replace1="", find2="", replace2="", find3="", replace3=""):
class promptReplace(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="easy promptReplace",
category="EasyUse/Prompt",
inputs=[
io.String.Input("prompt", multiline=True, default="", force_input=True),
io.String.Input("find1", multiline=False, default="", optional=True),
io.String.Input("replace1", multiline=False, default="", optional=True),
io.String.Input("find2", multiline=False, default="", optional=True),
io.String.Input("replace2", multiline=False, default="", optional=True),
io.String.Input("find3", multiline=False, default="", optional=True),
io.String.Input("replace3", multiline=False, default="", optional=True),
],
outputs=[
io.String.Output(id="output_prompt",display_name="prompt"),
],
)
@classmethod
def execute(cls, prompt, find1="", replace1="", find2="", replace2="", find3="", replace3=""):
prompt = prompt.replace(find1, replace1)
prompt = prompt.replace(find2, replace2)
prompt = prompt.replace(find3, replace3)
return (prompt,)
return io.NodeOutput(prompt)
# 肖像大师
@@ -421,10 +438,10 @@ class promptReplace:
# Version: 2.2
# https://stefanoflore.it
# https://ai-wiz.art
class portraitMaster:
class portraitMaster(io.ComfyNode):
@classmethod
def INPUT_TYPES(s):
def define_schema(cls):
max_float_value = 1.95
prompt_path = os.path.join(RESOURCES_DIR, 'portrait_prompt.json')
if not os.path.exists(prompt_path):
@@ -436,50 +453,72 @@ class portraitMaster:
del response, temp_prompt
# Load local
with open(prompt_path, 'r') as f:
list = json.load(f)
keys = [
['shot', 'COMBO', {"key": "shot_list"}], ['shot_weight', 'FLOAT'],
['gender', 'COMBO', {"default": "Woman", "key": "gender_list"}], ['age', 'INT', {"default": 30, "min": 18, "max": 90, "step": 1, "display": "slider"}],
['nationality_1', 'COMBO', {"default": "Chinese", "key": "nationality_list"}], ['nationality_2', 'COMBO', {"key": "nationality_list"}], ['nationality_mix', 'FLOAT'],
['body_type', 'COMBO', {"key": "body_type_list"}], ['body_type_weight', 'FLOAT'], ['model_pose', 'COMBO', {"key": "model_pose_list"}], ['eyes_color', 'COMBO', {"key": "eyes_color_list"}],
['facial_expression', 'COMBO', {"key": "face_expression_list"}], ['facial_expression_weight', 'FLOAT'], ['face_shape', 'COMBO', {"key": "face_shape_list"}], ['face_shape_weight', 'FLOAT'], ['facial_asymmetry', 'FLOAT'],
['hair_style', 'COMBO', {"key": "hair_style_list"}], ['hair_color', 'COMBO', {"key": "hair_color_list"}], ['disheveled', 'FLOAT'], ['beard', 'COMBO', {"key": "beard_list"}],
['skin_details', 'FLOAT'], ['skin_pores', 'FLOAT'], ['dimples', 'FLOAT'], ['freckles', 'FLOAT'],
['moles', 'FLOAT'], ['skin_imperfections', 'FLOAT'], ['skin_acne', 'FLOAT'], ['tanned_skin', 'FLOAT'],
['eyes_details', 'FLOAT'], ['iris_details', 'FLOAT'], ['circular_iris', 'FLOAT'], ['circular_pupil', 'FLOAT'],
['light_type', 'COMBO', {"key": "light_type_list"}], ['light_direction', 'COMBO', {"key": "light_direction_list"}], ['light_weight', 'FLOAT']
]
widgets = {}
for i, obj in enumerate(keys):
if obj[1] == 'COMBO':
key = obj[2]['key'] if obj[2] and 'key' in obj[2] else obj[0]
_list = list[key].copy()
_list.insert(0, '-')
widgets[obj[0]] = (_list, {**obj[2]})
elif obj[1] == 'FLOAT':
widgets[obj[0]] = ("FLOAT", {"default": 0, "step": 0.05, "min": 0, "max": max_float_value, "display": "slider",})
elif obj[1] == 'INT':
widgets[obj[0]] = (obj[1], obj[2])
del list
return {
"required": {
**widgets,
"photorealism_improvement": (["enable", "disable"],),
"prompt_start": ("STRING", {"multiline": True, "default": "raw photo, (realistic:1.5)"}),
"prompt_additional": ("STRING", {"multiline": True, "default": ""}),
"prompt_end": ("STRING", {"multiline": True, "default": ""}),
"negative_prompt": ("STRING", {"multiline": True, "default": ""}),
}
}
data = json.load(f)
inputs = []
# Shot
inputs.append(io.Combo.Input("shot", options=['-'] + data['shot_list']))
inputs.append(io.Float.Input("shot_weight", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
# Gender and age
inputs.append(io.Combo.Input("gender", options=['-'] + data['gender_list'], default="Woman"))
inputs.append(io.Int.Input("age", default=30, min=18, max=90, step=1, display_mode=io.NumberDisplay.slider))
# Nationality
inputs.append(io.Combo.Input("nationality_1", options=['-'] + data['nationality_list'], default="Chinese"))
inputs.append(io.Combo.Input("nationality_2", options=['-'] + data['nationality_list']))
inputs.append(io.Float.Input("nationality_mix", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
# Body
inputs.append(io.Combo.Input("body_type", options=['-'] + data['body_type_list']))
inputs.append(io.Float.Input("body_type_weight", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Combo.Input("model_pose", options=['-'] + data['model_pose_list']))
inputs.append(io.Combo.Input("eyes_color", options=['-'] + data['eyes_color_list']))
# Face
inputs.append(io.Combo.Input("facial_expression", options=['-'] + data['face_expression_list']))
inputs.append(io.Float.Input("facial_expression_weight", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Combo.Input("face_shape", options=['-'] + data['face_shape_list']))
inputs.append(io.Float.Input("face_shape_weight", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("facial_asymmetry", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
# Hair
inputs.append(io.Combo.Input("hair_style", options=['-'] + data['hair_style_list']))
inputs.append(io.Combo.Input("hair_color", options=['-'] + data['hair_color_list']))
inputs.append(io.Float.Input("disheveled", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Combo.Input("beard", options=['-'] + data['beard_list']))
# Skin details
inputs.append(io.Float.Input("skin_details", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("skin_pores", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("dimples", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("freckles", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("moles", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("skin_imperfections", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("skin_acne", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("tanned_skin", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
# Eyes
inputs.append(io.Float.Input("eyes_details", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("iris_details", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("circular_iris", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
inputs.append(io.Float.Input("circular_pupil", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
# Light
inputs.append(io.Combo.Input("light_type", options=['-'] + data['light_type_list']))
inputs.append(io.Combo.Input("light_direction", options=['-'] + data['light_direction_list']))
inputs.append(io.Float.Input("light_weight", default=0, step=0.05, min=0, max=max_float_value, display_mode=io.NumberDisplay.slider))
# Additional
inputs.append(io.Combo.Input("photorealism_improvement", options=["enable", "disable"]))
inputs.append(io.String.Input("prompt_start", multiline=True, default="raw photo, (realistic:1.5)"))
inputs.append(io.String.Input("prompt_additional", multiline=True, default=""))
inputs.append(io.String.Input("prompt_end", multiline=True, default=""))
inputs.append(io.String.Input("negative_prompt", multiline=True, default=""))
return io.Schema(
node_id="easy portraitMaster",
category="EasyUse/Prompt",
inputs=inputs,
outputs=[
io.String.Output("positive"),
io.String.Output("negative"),
],
)
RETURN_TYPES = ("STRING", "STRING",)
RETURN_NAMES = ("positive", "negative",)
FUNCTION = "pm"
CATEGORY = "EasyUse/Prompt"
def pm(self, shot="-", shot_weight=1, gender="-", body_type="-", body_type_weight=0, eyes_color="-",
@classmethod
def execute(cls, shot="-", shot_weight=1, gender="-", body_type="-", body_type_weight=0, eyes_color="-",
facial_expression="-", facial_expression_weight=0, face_shape="-", face_shape_weight=0,
nationality_1="-", nationality_2="-", nationality_mix=0.5, age=30, hair_style="-", hair_color="-",
disheveled=0, dimples=0, freckles=0, skin_pores=0, skin_details=0, moles=0, skin_imperfections=0,
@@ -609,7 +648,118 @@ class portraitMaster:
log_node_info("Portrait Master as generate the prompt:", prompt)
return (prompt, negative_prompt,)
return io.NodeOutput(prompt, negative_prompt)
# 多角度
class multiAngle(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="easy multiAngle",
category="EasyUse/Prompt",
inputs=[
io.Custom(io_type="EASY_MULTI_ANGLE").Input("multi_angle", optional=True),
],
outputs=[
io.String.Output("prompt", is_output_list=True),
io.Custom(io_type="EASY_MULTI_ANGLE").Output("params"),
],
)
@classmethod
def execute(cls, multi_angle=None, **kwargs):
if multi_angle is None:
return io.NodeOutput([""])
if isinstance(multi_angle, str):
try:
multi_angle = json.loads(multi_angle)
except:
raise Exception(f"Invalid multi angle: {multi_angle}")
prompts = []
for angle_data in multi_angle:
rotate = angle_data.get("rotate", 0)
vertical = angle_data.get("vertical", 0)
zoom = angle_data.get("zoom", 5)
add_angle_prompt = angle_data.get("add_angle_prompt", True)
# Validate input ranges
rotate = max(0, min(360, int(rotate)))
vertical = max(-90, min(90, int(vertical)))
zoom = max(0.0, min(10.0, float(zoom)))
h_angle = rotate % 360
# Horizontal direction mapping
h_suffix = "" if add_angle_prompt else " quarter"
if h_angle < 22.5 or h_angle >= 337.5: h_direction = "front view"
elif h_angle < 67.5: h_direction = f"front-right{h_suffix} view"
elif h_angle < 112.5: h_direction = "right side view"
elif h_angle < 157.5: h_direction = f"back-right{h_suffix} view"
elif h_angle < 202.5: h_direction = "back view"
elif h_angle < 247.5: h_direction = f"back-left{h_suffix} view"
elif h_angle < 292.5: h_direction = "left side view"
else: h_direction = f"front-left{h_suffix} view"
# Vertical direction mapping
if add_angle_prompt:
if vertical == -90:
v_direction = "bottom-looking-up perspective, extreme worm's eye view, focus subject bottom"
elif vertical < -75:
v_direction = "bottom-looking-up perspective, extreme worm's eye view"
elif vertical < -45:
v_direction = "ultra-low angle"
elif vertical < -15:
v_direction = "low angle"
elif vertical < 15:
v_direction = "eye level"
elif vertical < 45:
v_direction = "high angle"
elif vertical < 75:
v_direction = "bird's eye view"
elif vertical < 90:
v_direction = "top-down perspective, looking straight down at the top of the subject"
else:
v_direction = "top-down perspective, looking straight down at the top of the subject, face not visible, focus on subject head"
else:
if vertical < -15:
v_direction = "low-angle shot"
elif vertical < 15:
v_direction = "eye-level shot"
elif vertical < 45:
v_direction = "elevated shot"
elif vertical < 75:
v_direction = "high-angle shot"
elif vertical < 90:
v_direction = "top-down perspective, looking straight down at the top of the subject"
else:
v_direction = "top-down perspective, looking straight down at the top of the subject, face not visible, focus on subject head"
# Distance/zoom mapping
if add_angle_prompt:
if zoom < 2: distance = "extreme wide shot"
elif zoom < 4: distance = "wide shot"
elif zoom < 6: distance = "medium shot"
elif zoom < 8: distance = "close-up"
else: distance = "extreme close-up"
else:
if zoom < 2: distance = "extreme wide shot"
elif zoom < 4: distance = "wide shot"
elif zoom < 6: distance = "medium shot"
elif zoom < 8: distance = "close-up"
else: distance = "extreme close-up"
# Build prompt
if add_angle_prompt:
prompt = f"{h_direction}, {v_direction}, {distance} (horizontal: {rotate}, vertical: {vertical}, zoom: {zoom:.1f})"
else:
prompt = f"{h_direction} {v_direction} {distance}"
prompts.append(prompt)
return io.NodeOutput(prompts, multi_angle)
NODE_CLASS_MAPPINGS = {
@@ -625,6 +775,7 @@ NODE_CLASS_MAPPINGS = {
"easy promptReplace": promptReplace,
"easy stylesSelector": stylesPromptSelector,
"easy portraitMaster": portraitMaster,
"easy multiAngle": multiAngle,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -640,4 +791,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"easy promptReplace": "PromptReplace",
"easy stylesSelector": "Styles Selector",
"easy portraitMaster": "Portrait Master",
}
"easy multiAngle": "Multi Angle",
}
+13 -11
View File
@@ -8,7 +8,7 @@ import comfy_extras.nodes_custom_sampler as custom_samplers
from tqdm import trange
from server import PromptServer
from nodes import RepeatLatentBatch, NODE_CLASS_MAPPINGS as ALL_NODE_CLASS_MAPPINGS, VAEEncodeForInpaint, InpaintModelConditioning
from nodes import RepeatLatentBatch, NODE_CLASS_MAPPINGS as ALL_NODE_CLASS_MAPPINGS, VAEEncodeForInpaint, InpaintModelConditioning, VAEDecodeTiled
from ..modules.layer_diffuse import LayerDiffuse
from ..config import *
@@ -138,6 +138,14 @@ class samplerFull:
to["model_patch"] = {}
return to
def get_align_your_steps_sigmas(self, model, steps, denoise):
model_type = get_sd_version(model)
# Anima/Krea2 have no dedicated AYS table; keep the SDXL table they used before these families were recognized.
if model_type in ("anima", "krea2", "unknown"):
model_type = "sdxl"
sigmas, = alignYourStepsScheduler().get_sigmas(model_type.upper(), steps, denoise)
return sigmas
def get_sampler_custom(self, model, positive, negative, loader_settings):
_guider = None
middle = loader_settings['middle'] if "middle" in loader_settings else negative
@@ -174,10 +182,7 @@ class samplerFull:
elif scheduler == 'sdturbo':
sigmas, = self.get_custom_cls('SDTurboScheduler').execute(model, steps, denoise)
elif scheduler == 'alignYourSteps':
model_type = get_sd_version(model)
if model_type == 'unknown':
model_type = 'sdxl'
sigmas, = alignYourStepsScheduler().get_sigmas(model_type.upper(), steps, denoise)
sigmas = self.get_align_your_steps_sigmas(model, steps, denoise)
elif scheduler == 'gits':
sigmas, = gitsScheduler().get_sigmas(coeff, steps, denoise)
else:
@@ -340,10 +345,7 @@ class samplerFull:
_guider, _sampler, sigmas = self.get_sampler_custom(samp_model, samp_positive, samp_negative, samp_custom)
samp_samples, samp_blend_samples = sampler.custom_advanced_ksampler(_guider, _sampler, sigmas, samp_samples, add_noise, samp_seed, preview_latent=preview_latent)
elif scheduler == 'align_your_steps':
model_type = get_sd_version(samp_model)
if model_type == 'unknown':
model_type = 'sdxl'
sigmas, = alignYourStepsScheduler().get_sigmas(model_type.upper(), steps, denoise)
sigmas = self.get_align_your_steps_sigmas(samp_model, steps, denoise)
_sampler = comfy.samplers.sampler_object(sampler_name)
samp_samples = sampler.custom_ksampler(samp_model, samp_seed, steps, cfg, _sampler, sigmas, samp_positive, samp_negative, samp_samples, disable_noise=disable_noise, preview_latent=preview_latent, noise_device=noise_device)
elif scheduler == 'gits':
@@ -362,7 +364,7 @@ class samplerFull:
spent_time = 'Diffusion:' + str((end_time - start_time) / 1000) + '″'
else:
if tile_size is not None:
samp_images = samp_vae.decode_tiled(latent, tile_x=tile_size // 8, tile_y=tile_size // 8, )
samp_images, = VAEDecodeTiled().decode(samp_vae, {"samples": latent}, tile_size)
else:
samp_images = samp_vae.decode(latent).cpu()
if len(samp_images.shape) == 5: # Combine batches
@@ -980,7 +982,7 @@ class samplerSDTurbo:
# 解码图片
if tile_size is not None:
samp_images = samp_vae.decode_tiled(latent, tile_x=tile_size // 8, tile_y=tile_size // 8, )
samp_images, = VAEDecodeTiled().decode(samp_vae, {"samples": latent}, tile_size)
else:
samp_images = samp_vae.decode(latent).cpu()
+282
View File
@@ -1,5 +1,7 @@
import os
import re
import folder_paths
import json
from ..libs.utils import AlwaysEqualProxy
class showLoaderSettingsNames:
@@ -125,12 +127,291 @@ class setLoraName:
return (lora_name,)
def _markdown_table_to_image(markdown: str, font_path: str):
"""将 Markdown 表格字符串渲染为 PIL.Image(RGB),支持单元格自动换行。"""
from PIL import Image, ImageDraw, ImageFont
# 解析行,过滤分隔行(如 |---|---|)
lines = [l for l in (markdown or '').strip().splitlines() if l.strip()]
table_rows = []
for line in lines:
if re.match(r'^\|[\s\-:|]+\|$', line.strip()):
continue
cells = [re.sub(r'\*\*(.+?)\*\*', lambda m: '\x01' + m.group(1) + '\x02',
re.sub(r'<br\s*/?>', '\n', c.strip(), flags=re.IGNORECASE))
for c in line.strip().strip('|').split('|')]
table_rows.append(cells)
if not table_rows:
return Image.new("RGB", (400, 80), (255, 255, 255))
num_cols = max(len(r) for r in table_rows)
table_rows = [r + [''] * (num_cols - len(r)) for r in table_rows]
# 加载字体
font_size = 16
try:
font = ImageFont.truetype(font_path, font_size)
except Exception:
font = ImageFont.load_default()
pad_x, pad_y = 14, 10
border = 1
max_cell_text_width = 200 # 单元格文字区域最大宽度(像素)
def get_text_width(text):
clean = re.sub('[\x01\x02]', '', text)
try:
bbox = font.getbbox(clean)
return bbox[2] - bbox[0]
except Exception:
return len(clean) * 9
def get_line_height():
try:
bbox = font.getbbox('Ag\u4e2d')
return bbox[3] - bbox[1]
except Exception:
return font_size + 2
def wrap_text(text, max_width):
"""换行:先按 \\n 切段,每段再按英文单词边界 / CJK 字符换行。"""
if not text:
return ['']
# 先按显式换行符切段,再对每段分别软换行
hard_lines = text.split('\n')
if len(hard_lines) > 1:
result = []
for hl in hard_lines:
result.extend(wrap_text(hl, max_width))
return result if result else ['']
# 将文本拆分为:CJK 单字符 / 空白序列 / 非CJK非空白序列(英文单词/标点等)
tokens = re.findall(
r'[\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]'
r'|[ \t]+'
r'|[^ \t\u4e00-\u9fff\u3000-\u303f\uff00-\uffef]+',
text
)
result, current = [], ''
for token in tokens:
is_space = token.strip() == ''
test = current + token
if get_text_width(test) <= max_width:
if is_space and not current:
continue # 跳过行首空格
current = test
else:
if is_space:
# 空白处换行,丢弃该空白
if current:
result.append(current)
current = ''
elif get_text_width(token) <= max_width:
# 整个 token 能放一行,整体移到下一行
if current:
result.append(current)
current = token
else:
# token 本身超宽(极长单词),逐字符强拆
for char in token:
if get_text_width(current + char) <= max_width:
current += char
else:
if current:
result.append(current)
current = char
if current:
result.append(current)
return result if result else ['']
line_h = get_line_height()
def parse_line_segments(line):
"""将含 \\x01..\\x02 粗体标记的行拆分为 (text, is_bold) 片段列表。"""
result, bold = [], False
for part in re.split('([\x01\x02])', line):
if part == '\x01':
bold = True
elif part == '\x02':
bold = False
elif part:
result.append((part, bold))
return result or [('', False)]
def balance_bold_markers(lines):
"""确保每行粗体标记自成一对:跨行时在行首补开、行尾补关标记。"""
result, in_bold = [], False
for line in lines:
if in_bold:
line = '\x01' + line
for ch in line:
if ch == '\x01': in_bold = True
elif ch == '\x02': in_bold = False
if in_bold:
line = line + '\x02'
result.append(line)
return result
# 第一遍:计算各列宽度(不超过 max_cell_text_width,按子行分别测量)
col_text_widths = []
for col_idx in range(num_cols):
max_w = 0
for row in table_rows:
cell = row[col_idx] if col_idx < len(row) else ''
for seg_line in cell.split('\n'):
max_w = max(max_w, min(get_text_width(seg_line), max_cell_text_width))
col_text_widths.append(max_w)
col_widths = [w + pad_x * 2 for w in col_text_widths]
# 第二遍:对每行每格换行,计算各行高度
wrapped_rows = []
row_heights = []
for row in table_rows:
wrapped_cells = []
max_lines = 1
for col_idx in range(num_cols):
cell = row[col_idx] if col_idx < len(row) else ''
wrapped = balance_bold_markers(wrap_text(cell, col_text_widths[col_idx]))
wrapped_cells.append(wrapped)
max_lines = max(max_lines, len(wrapped))
wrapped_rows.append(wrapped_cells)
row_heights.append(max_lines * line_h + pad_y * 2)
# 每列左边缘 x 坐标(每列前留 1px 边框)
col_x = [border]
for cw in col_widths:
col_x.append(col_x[-1] + cw + border)
total_width = col_x[-1]
total_height = border + sum(rh + border for rh in row_heights)
# 配色
header_bg = (52, 73, 94)
header_fg = (255, 255, 255)
even_bg = (248, 249, 252)
odd_bg = (255, 255, 255)
border_color = (180, 185, 195)
text_color = (50, 54, 62)
# 以边框色填充整张图,格线自然显现
img = Image.new("RGB", (total_width, total_height), border_color)
draw = ImageDraw.Draw(img)
def render_line(x, y, line, fg):
"""逐片段渲染一行文字;粗体通过向右偏移 1px 再描一遍来模拟加粗。"""
try:
text_offset = -font.getbbox(re.sub('[\x01\x02]', '', line) or 'A')[1]
except Exception:
text_offset = 0
sx = x
for seg, is_bold in parse_line_segments(line):
draw.text((sx, y + text_offset), seg, font=font, fill=fg)
if is_bold:
draw.text((sx + 1, y + text_offset), seg, font=font, fill=fg)
try:
w = font.getbbox(seg)[2] - font.getbbox(seg)[0]
except Exception:
w = len(seg) * 9
sx += w + (1 if is_bold else 0)
row_y = border
for row_idx, (wrapped_cells, rh) in enumerate(zip(wrapped_rows, row_heights)):
is_header = row_idx == 0
bg = header_bg if is_header else (odd_bg if row_idx % 2 == 1 else even_bg)
fg = header_fg if is_header else text_color
for col_idx in range(num_cols):
cx, cw = col_x[col_idx], col_widths[col_idx]
# 填充单元格背景
draw.rectangle([cx, row_y, cx + cw - 1, row_y + rh - 1], fill=bg)
cell_lines = wrapped_cells[col_idx] if col_idx < len(wrapped_cells) else ['']
total_text_h = len(cell_lines) * line_h
ty = row_y + (rh - total_text_h) // 2 # 垂直居中起点
for line_text in cell_lines:
render_line(cx + pad_x, ty, line_text, fg)
ty += line_h
row_y += rh + border
# 最长边不小于 1280,等比放大
min_long_side = 1280
long_side = max(img.width, img.height)
if long_side < min_long_side:
scale = min_long_side / long_side
new_w = round(img.width * scale)
new_h = round(img.height * scale)
img = img.resize((new_w, new_h), Image.LANCZOS)
return img
class tableEditor:
"""表格编辑器节点 —— 通过可视化表格或 Markdown 语法编辑数据,输出 Markdown 字符串。"""
CATEGORY = "EasyUse/Util"
RETURN_TYPES = ("STRING", "IMAGE")
RETURN_NAMES = ("markdown", "image")
FUNCTION = "execute"
DESCRIPTION = "通过可视化表格或 Markdown 语法编辑数据,输出 Markdown 格式的表格字符串。"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"table_data": ("EASY_TABLE_EDITOR",),
},
}
def execute(self, table_data):
# 表格数据可能是纯 Markdown 字符串,也可能是序列化后的 JSON
if isinstance(table_data, str) and table_data.strip().startswith('{'):
try:
obj = json.loads(table_data)
markdown = obj.get('markdown', '')
if not markdown:
# 重新从 headers/rows 生成
headers = obj.get('headers', [])
rows = obj.get('rows', [])
col_widths = [max(len(str(h)), 3) for h in headers]
for row in rows:
for i, cell in enumerate(row):
if i < len(col_widths):
col_widths[i] = max(col_widths[i], len(str(cell)))
header_line = '| ' + ' | '.join(str(h).ljust(col_widths[i]) for i, h in enumerate(headers)) + ' |'
sep_line = '| ' + ' | '.join('-' * w for w in col_widths) + ' |'
row_lines = [
'| ' + ' | '.join(str(row[i] if i < len(row) else '').ljust(col_widths[i]) for i in range(len(headers))) + ' |'
for row in rows
]
markdown = '\n'.join([header_line, sep_line] + row_lines)
except Exception:
markdown = table_data
else:
markdown = table_data
# 将 Markdown 表格渲染为图像
font_path = os.path.join(
os.path.dirname(os.path.dirname(os.path.dirname(__file__))),
'resources', 'wenquan.ttf'
)
from ..libs.image import pil2tensor
img_tensor = pil2tensor(_markdown_table_to_image(markdown, font_path).convert("RGB"))
return (markdown, img_tensor)
NODE_CLASS_MAPPINGS = {
"easy showLoaderSettingsNames": showLoaderSettingsNames,
"easy sliderControl": sliderControl,
"easy ckptNames": setCkptName,
"easy controlnetNames": setControlName,
"easy loraNames": setLoraName,
"easy tableEditor": tableEditor,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -139,4 +420,5 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"easy ckptNames": "Ckpt Names",
"easy controlnetNames": "ControlNet Names",
"easy loraNames": "Lora Names",
"easy tableEditor": "Table Editor",
}
+41
View File
@@ -528,6 +528,45 @@ class XYplot_Checkpoint:
xy_values = {"axis": axis, "values": values, "lora_stack": optional_lora_stack}
return (xy_values,)
# Diffusion Models
class XYplot_DiffusionModel:
@classmethod
def INPUT_TYPES(cls):
models = ["None"] + folder_paths.get_filename_list("diffusion_models")
clips = ["Auto"] + folder_paths.get_filename_list("text_encoders")
vaes = ["Auto"] + folder_paths.get_filename_list("vae")
inputs = {
"required": {
"model_count": ("INT", {"default": 3, "min": 0, "max": 10, "step": 1}),
}
}
for i in range(1, 11):
inputs["required"][f"model_name_{i}"] = (models,)
inputs["required"][f"clip_name_{i}"] = (clips, {"default": "Auto"})
inputs["required"][f"vae_name_{i}"] = (vaes, {"default": "Auto"})
return inputs
RETURN_TYPES = ("X_Y",)
RETURN_NAMES = ("X or Y",)
FUNCTION = "xy_value"
CATEGORY = "EasyUse/XY Inputs"
def xy_value(self, model_count, **kwargs):
values = []
for i in range(1, model_count + 1):
model_name = kwargs.get(f"model_name_{i}")
if not model_name or model_name == "None":
continue
clip_name = kwargs.get(f"clip_name_{i}", "Auto")
vae_name = kwargs.get(f"vae_name_{i}", "Auto")
values.append(
model_name.replace(",", "*") + ","
+ clip_name.replace(",", "*") + ","
+ vae_name.replace(",", "*")
)
return ({"axis": "advanced: DiffusionModel", "values": values},)
#Loras
class XYplot_Lora:
@@ -670,6 +709,7 @@ NODE_CLASS_MAPPINGS = {
"easy XYInputs: Sampler/Scheduler": XYplot_Sampler_Scheduler,
"easy XYInputs: Denoise": XYplot_Denoise,
"easy XYInputs: Checkpoint": XYplot_Checkpoint,
"easy XYInputs: DiffusionModel": XYplot_DiffusionModel,
"easy XYInputs: Lora": XYplot_Lora,
"easy XYInputs: ModelMergeBlocks": XYplot_ModelMergeBlocks,
"easy XYInputs: PromptSR": XYplot_PromptSR,
@@ -688,6 +728,7 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"easy XYInputs: Sampler/Scheduler": "XY Inputs: Sampler/Scheduler //EasyUse",
"easy XYInputs: Denoise": "XY Inputs: Denoise //EasyUse",
"easy XYInputs: Checkpoint": "XY Inputs: Checkpoint //EasyUse",
"easy XYInputs: DiffusionModel": "XY Inputs: Diffusion Model //EasyUse",
"easy XYInputs: Lora": "XY Inputs: Lora //EasyUse",
"easy XYInputs: ModelMergeBlocks": "XY Inputs: ModelMergeBlocks //EasyUse",
"easy XYInputs: PromptSR": "XY Inputs: PromptSR //EasyUse",
+123 -39
View File
@@ -1,10 +1,16 @@
import os
import hashlib
import hmac
import sys
import json
import shutil
import secrets
import tempfile
from functools import lru_cache
from urllib.parse import urlsplit
import folder_paths
from aiohttp import web
from PIL import Image, UnidentifiedImageError
from server import PromptServer
from .config import RESOURCES_DIR, FOOOCUS_STYLES_DIR, FOOOCUS_STYLES_SAMPLES
from .libs.model import easyModelManager
@@ -25,7 +31,6 @@ def get_version(request):
def cleanGPU(request):
try:
cleanGPUUsedForce()
remove_cache('*')
return web.Response(status=200)
except Exception as e:
return web.Response(status=500)
@@ -51,8 +56,30 @@ async def translate(request):
else:
return web.json_response({"text": text})
@PromptServer.instance.routes.get("/easyuse/reboot")
def reboot(request):
_reboot_token = secrets.token_urlsafe(32)
def _same_origin_request(request):
fetch_site = request.headers.get("Sec-Fetch-Site")
if fetch_site and fetch_site not in ("same-origin", "none"):
return False
origin = request.headers.get("Origin")
return not origin or urlsplit(origin).netloc == request.host
@PromptServer.instance.routes.get("/easyuse/reboot-token")
async def get_reboot_token(request):
if not _same_origin_request(request):
return web.Response(status=403)
return web.json_response({"token": _reboot_token}, headers={"Cache-Control": "no-store"})
@PromptServer.instance.routes.post("/easyuse/reboot")
async def reboot(request):
token = request.headers.get("X-EasyUse-Reboot-Token", "")
if not _same_origin_request(request) or not hmac.compare_digest(token, _reboot_token):
return web.Response(status=403)
try:
sys.stdout.close_log()
except Exception as e:
@@ -170,9 +197,9 @@ async def getModelsList(request):
@PromptServer.instance.routes.post("/easyuse/metadata/notes/{name}")
async def save_notes(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
type, separator, name = name.partition("/")
if not separator or type not in ("checkpoints", "loras", "embeddings"):
return web.Response(status=400)
file_path = None
if type == "embeddings" or type == "loras":
@@ -190,24 +217,34 @@ async def save_notes(request):
if file_path is not None:
break
else:
file_path = folder_paths.get_full_path(
type, name)
if name in folder_paths.get_filename_list(type):
file_path = folder_paths.get_full_path(type, name)
if not file_path:
return web.Response(status=404)
file_no_ext = os.path.splitext(file_path)[0]
info_file = file_no_ext + ".txt"
with open(info_file, "w") as f:
f.write(await request.text())
staged_path = None
try:
with tempfile.NamedTemporaryFile(
mode="w", encoding="utf-8", dir=os.path.dirname(info_file),
prefix=".easyuse-notes-", delete=False
) as staged:
staged_path = staged.name
staged.write(await request.text())
os.replace(staged_path, info_file)
finally:
if staged_path and os.path.exists(staged_path):
os.unlink(staged_path)
return web.Response(status=200)
@PromptServer.instance.routes.get("/easyuse/metadata/{name}")
async def load_metadata(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
type, separator, name = name.partition("/")
if not separator or type not in ("checkpoints", "loras", "embeddings"):
return web.Response(status=400)
file_path = None
if type == "embeddings":
@@ -225,7 +262,8 @@ async def load_metadata(request):
if file_path is not None:
break
else:
file_path = folder_paths.get_full_path(type, name)
if name in folder_paths.get_filename_list(type):
file_path = folder_paths.get_full_path(type, name)
if not file_path:
return web.Response(status=404)
@@ -242,47 +280,93 @@ async def load_metadata(request):
file_no_ext = os.path.splitext(file_path)[0]
info_file = file_no_ext + ".txt"
if os.path.isfile(info_file):
if os.path.isfile(info_file) and not os.path.islink(info_file):
with open(info_file, "r") as f:
meta["easyuse.notes"] = f.read()
hash_file = file_no_ext + ".sha256"
if os.path.isfile(hash_file):
with open(hash_file, "rt") as f:
meta["easyuse.sha256"] = f.read()
else:
with open(file_path, "rb") as f:
meta["easyuse.sha256"] = hashlib.sha256(f.read()).hexdigest()
with open(hash_file, "wt") as f:
f.write(meta["easyuse.sha256"])
# Sidecar hashes are user-controlled; never use them as proof of the model's hash.
stat = os.stat(file_path)
meta["easyuse.sha256"] = _model_sha256(
file_path, stat.st_size, stat.st_mtime_ns, stat.st_ctime_ns
)
return web.json_response(meta)
@lru_cache(maxsize=128)
def _model_sha256(path, size, mtime_ns, ctime_ns):
digest = hashlib.sha256()
with open(path, "rb") as model:
for chunk in iter(lambda: model.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
_PREVIEW_FORMATS = {
".png": "PNG",
".jpg": "JPEG",
".jpeg": "JPEG",
".webp": "WEBP",
".gif": "GIF",
}
@PromptServer.instance.routes.post("/easyuse/save/{name}")
async def save_preview(request):
name = request.match_info["name"]
pos = name.index("/")
type = name[0:pos]
name = name[pos+1:]
model_type, separator, model_name = name.partition("/")
if not separator or model_type not in ("checkpoints", "loras"):
return web.Response(status=400)
if model_name not in folder_paths.get_filename_list(model_type):
return web.Response(status=404)
model_path = folder_paths.get_full_path(model_type, model_name)
if not model_path:
return web.Response(status=404)
body = await request.json()
dir = folder_paths.get_directory_by_type(body.get("type", "output"))
subfolder = body.get("subfolder", "")
full_output_folder = os.path.join(dir, os.path.normpath(subfolder))
if os.path.commonpath((dir, os.path.abspath(full_output_folder))) != dir:
filename = body.get("filename")
if (body.get("type") != "temp" or body.get("subfolder", "") != ""
or not isinstance(filename, str) or not filename
or os.path.basename(filename) != filename or filename in (".", "..")):
return web.Response(status=400)
filepath = os.path.join(full_output_folder, body.get("filename", ""))
image_path = folder_paths.get_full_path(type, name)
image_path = os.path.splitext(
image_path)[0] + os.path.splitext(filepath)[1]
extension = os.path.splitext(filename)[1].lower()
if extension not in _PREVIEW_FORMATS:
return web.Response(status=400)
shutil.copyfile(filepath, image_path)
temp_dir = folder_paths.get_directory_by_type("temp")
filepath = os.path.join(temp_dir, filename)
if (os.path.commonpath((os.path.realpath(temp_dir), os.path.realpath(filepath)))
!= os.path.realpath(temp_dir) or not os.path.isfile(filepath)):
return web.Response(status=400)
image_path = os.path.splitext(model_path)[0] + extension
if (os.path.islink(image_path)
or os.path.realpath(os.path.dirname(image_path))
!= os.path.realpath(os.path.dirname(model_path))):
return web.Response(status=400)
staged_path = None
try:
with tempfile.NamedTemporaryFile(
dir=os.path.dirname(image_path), prefix=".easyuse-preview-", delete=False
) as staged:
staged_path = staged.name
with open(filepath, "rb") as source:
shutil.copyfileobj(source, staged)
with Image.open(staged_path) as image:
if image.format != _PREVIEW_FORMATS[extension]:
return web.Response(status=400)
image.verify()
os.replace(staged_path, image_path)
except (OSError, ValueError, UnidentifiedImageError):
return web.Response(status=400)
finally:
if staged_path and os.path.exists(staged_path):
os.unlink(staged_path)
return web.json_response({
"image": type + "/" + os.path.basename(image_path)
"image": model_type + "/" + os.path.basename(image_path)
})
@PromptServer.instance.routes.post("/easyuse/model/download")
+7 -3
View File
@@ -2,6 +2,10 @@ import random
import server
from enum import Enum
# Dedicated RNG for seed generation. Many custom nodes call random.seed() while they run,
# which resets the global RNG and makes the generated seeds repeat.
_seed_rng = random.Random()
class SGmode(Enum):
FIX = 1
INCR = 2
@@ -34,7 +38,7 @@ class SeedGenerator:
if self.base_value < 0:
self.base_value = 1125899906842624
elif self.action == SGmode.RAND:
self.base_value = random.randint(0, 1125899906842624)
self.base_value = _seed_rng.randint(0, 1125899906842624)
return seed
@@ -52,7 +56,7 @@ def control_seed(v, action, seed_is_global):
if value < 0:
value = 1125899906842624
elif action == 'randomize' or action == 'randomize for each node':
value = random.randint(0, 1125899906842624)
value = _seed_rng.randint(0, 1125899906842624)
if seed_is_global:
v['inputs']['value'] = value
@@ -163,4 +167,4 @@ def onprompt(json_data):
return json_data
server.PromptServer.instance.add_on_prompt_handler(onprompt)
server.PromptServer.instance.add_on_prompt_handler(onprompt)
+3 -3
View File
@@ -1,9 +1,9 @@
[project]
name = "comfyui-easy-use"
description = "To enhance the usability of ComfyUI, optimizations and integrations have been implemented for several commonly used nodes."
version = "1.3.4"
version = "1.4.1"
license = { file = "LICENSE" }
dependencies = ["diffusers", "accelerate", "clip_interrogator>=0.6.0", "sentencepiece", "lark", "onnxruntime", "spandrel", "opencv-python-headless", "matplotlib", "peft"]
dependencies = ["diffusers", "accelerate", "clip_interrogator", "sentencepiece", "lark", "onnxruntime", "spandrel", "opencv-python-headless", "matplotlib", "peft"]
[project.urls]
Repository = "https://github.com/yolain/ComfyUI-Easy-Use"
@@ -12,4 +12,4 @@ Repository = "https://github.com/yolain/ComfyUI-Easy-Use"
[tool.comfy]
PublisherId = "yolain"
DisplayName = "ComfyUI-Easy-Use"
Icon = ""
Icon = "https://mintlify.s3.us-west-1.amazonaws.com/yolain/images/logo.svg"
Binary file not shown.
+275
View File
@@ -0,0 +1,275 @@
import importlib.util
import os
import sys
import tempfile
import types
import unittest
from contextlib import contextmanager
from pathlib import Path
import torch
PLUGIN_ROOT = Path(__file__).parents[1]
UTILS_PATH = PLUGIN_ROOT / "py" / "libs" / "utils.py"
CONFIG_PATH = PLUGIN_ROOT / "py" / "config.py"
LOADER_PATH = PLUGIN_ROOT / "py" / "libs" / "loader.py"
XYPLOT_NODE_PATH = PLUGIN_ROOT / "py" / "nodes" / "xyplot.py"
XYPLOT_LIB_PATH = PLUGIN_ROOT / "py" / "libs" / "xyplot.py"
@contextmanager
def installed_modules(modules):
added = []
for name, module in modules.items():
if name not in sys.modules:
added.append(name)
sys.modules[name] = module
try:
yield
finally:
for name in added:
sys.modules.pop(name, None)
def load_module(name, path, package=None):
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
if package is not None:
module.__package__ = package
spec.loader.exec_module(module)
return module
def make_package(name, **attrs):
package = types.ModuleType(name)
package.__path__ = []
for key, value in attrs.items():
setattr(package, key, value)
return package
def comfy_stubs():
model_management = types.ModuleType("comfy.model_management")
model_base = types.ModuleType("comfy.model_base")
model_base.BaseModel = object
supported_models_base = types.ModuleType("comfy.supported_models_base")
supported_models_base.BASE = object
supported_models = types.ModuleType("comfy.supported_models")
supported_models.supported_models_base = supported_models_base
comfy = make_package(
"comfy",
model_management=model_management,
model_base=model_base,
supported_models_base=supported_models_base,
supported_models=supported_models,
)
for name in (
"SDXL", "SDXLRefiner", "SD15", "SD20", "SVD_img2vid", "SD3",
"HunyuanDiT", "Flux", "GenmoMochi", "Anima", "Krea2",
):
setattr(supported_models, name, type(name, (), {}))
server = types.ModuleType("server")
server.PromptServer = object
return {
"comfy": comfy,
"comfy.model_management": model_management,
"comfy.model_base": model_base,
"comfy.supported_models_base": supported_models_base,
"comfy.supported_models": supported_models,
"server": server,
}
def xyplot_node_stubs():
folder_paths = types.ModuleType("folder_paths")
folder_paths.get_filename_list = lambda folder: []
return {
"comfy": make_package("comfy"),
"folder_paths": folder_paths,
"fake_py": make_package("fake_py"),
"fake_py.nodes": make_package("fake_py.nodes"),
"fake_py.libs": make_package("fake_py.libs"),
"fake_py.config": make_package("fake_py.config", RESOURCES_DIR="resources"),
"fake_py.libs.utils": make_package("fake_py.libs.utils", getMetadata=lambda *args, **kwargs: None),
}
def xyplot_lib_stubs():
fake_utils = make_package("fake_py.utils", easySave=object, get_sd_version=lambda model: "unknown")
fake_adv_encode = make_package("fake_py.libs.adv_encode", advanced_encode=object)
fake_controlnet = make_package("fake_py.libs.controlnet", easyControlnet=object)
fake_log = make_package("fake_py.libs.log", log_node_warn=lambda *args, **kwargs: None)
return {
"nodes": make_package("nodes", CLIPTextEncode=object),
"fake_py": make_package("fake_py"),
"fake_py.libs": make_package("fake_py.libs"),
"fake_py.modules": make_package("fake_py.modules"),
"fake_py.utils": fake_utils,
"fake_py.libs.utils": fake_utils,
"fake_py.libs.adv_encode": fake_adv_encode,
"fake_py.libs.controlnet": fake_controlnet,
"fake_py.libs.log": fake_log,
"fake_py.modules.layer_diffuse": make_package("fake_py.modules.layer_diffuse", LayerDiffuse=object),
"fake_py.config": make_package("fake_py.config", RESOURCES_DIR="resources"),
}
def loader_stubs():
comfy = make_package("comfy")
comfy.utils = make_package("comfy.utils")
comfy.sd = make_package("comfy.sd")
comfy.controlnet = make_package("comfy.controlnet")
comfy.model_patcher = make_package("comfy.model_patcher", ModelPatcher=type("ModelPatcher", (), {}))
folder_paths = make_package("folder_paths")
folder_paths.get_full_path = lambda folder, name: None
folder_paths.get_folder_paths = lambda folder: []
folder_paths.get_filename_list = lambda folder: []
fake_log = make_package("fake_py.libs.log", log_node_info=lambda *args, **kwargs: None, log_node_error=lambda *args, **kwargs: None)
fake_utils = make_package("fake_py.libs.utils", get_sd_version=lambda model: "unknown")
fake_config = make_package(
"fake_py.config",
DIFFUSION_MODEL_XY_DEFAULTS={},
DIFFUSION_MODEL_CLIP_TYPES={"anima": "anima", "krea2": "krea2"},
)
fake_pixart = make_package("fake_py.modules.dit.pixArt.loader", load_pixart=object)
return {
"comfy": comfy,
"comfy.utils": comfy.utils,
"comfy.sd": comfy.sd,
"comfy.controlnet": comfy.controlnet,
"comfy.model_patcher": comfy.model_patcher,
"folder_paths": folder_paths,
"nodes": make_package("nodes", NODE_CLASS_MAPPINGS={}),
"fake_py": make_package("fake_py"),
"fake_py.libs": make_package("fake_py.libs"),
"fake_py.modules": make_package("fake_py.modules"),
"fake_py.modules.dit": make_package("fake_py.modules.dit"),
"fake_py.modules.dit.pixArt": make_package("fake_py.modules.dit.pixArt"),
"fake_py.libs.log": fake_log,
"fake_py.libs.utils": fake_utils,
"fake_py.config": fake_config,
"fake_py.modules.dit.pixArt.loader": fake_pixart,
}
class FakeModelPatcher:
def __init__(self, model_config=None, latent_format=None):
self.model = types.SimpleNamespace(model_config=model_config, latent_format=latent_format)
class FakeLatentFormat:
latent_dimensions = 3
latent_channels = 16
class DiffusionXYHelperTests(unittest.TestCase):
def test_get_sd_version_anima_and_krea2(self):
with installed_modules(comfy_stubs()):
utils = load_module("diffusion_xy_test_utils", UTILS_PATH)
anima_config = utils.comfy.supported_models.Anima()
self.assertEqual(utils.get_sd_version(FakeModelPatcher(anima_config)), "anima")
krea2_config = utils.comfy.supported_models.Krea2()
self.assertEqual(utils.get_sd_version(FakeModelPatcher(krea2_config)), "krea2")
def test_diffusion_model_xy_defaults_are_complete(self):
with tempfile.TemporaryDirectory() as models_dir:
folder_paths = types.ModuleType("folder_paths")
folder_paths.models_dir = models_dir
with installed_modules({"folder_paths": folder_paths}):
config = load_module("diffusion_xy_test_config", CONFIG_PATH)
for family in ("anima", "krea2"):
defaults = config.DIFFUSION_MODEL_XY_DEFAULTS[family]
self.assertTrue(defaults["clip_name"])
self.assertTrue(defaults["clip_type"])
self.assertTrue(defaults["vae_name"])
self.assertEqual(config.DIFFUSION_MODEL_CLIP_TYPES[family], family)
def test_load_diffusion_model_required_rejects_missing_clip_and_vae(self):
with installed_modules(loader_stubs()):
loader_module = load_module("fake_py.libs.loader", LOADER_PATH, "fake_py.libs")
loader = loader_module.easyLoader.__new__(loader_module.easyLoader)
loader.load_diffusion_model = lambda model_name: ("model", model_name)
loader.load_clip = lambda clip_name, type='stable_diffusion': ("clip", clip_name, type)
loader.load_vae = lambda vae_name: ("vae", vae_name)
loader_module.get_sd_version = lambda model: "krea2"
with self.assertRaisesRegex(RuntimeError, "clip_name is required"):
loader.load_diffusion_model_required("model.safetensors", "None", "vae.safetensors")
with self.assertRaisesRegex(RuntimeError, "vae_name is required"):
loader.load_diffusion_model_required("model.safetensors", "clip.safetensors", None)
model, clip, vae, family = loader.load_diffusion_model_required(
"model.safetensors", "clip.safetensors", "vae.safetensors"
)
self.assertEqual(family, "krea2")
self.assertEqual(clip[2], "krea2")
loader_module.get_sd_version = lambda model: "flux"
with self.assertRaisesRegex(RuntimeError, "unsupported diffusion model family: flux"):
loader.load_diffusion_model_required("model.safetensors", "clip.safetensors", "vae.safetensors")
def test_xyplot_diffusion_model_value_format(self):
with installed_modules(xyplot_node_stubs()):
node_module = load_module("fake_py.nodes.xyplot", XYPLOT_NODE_PATH, "fake_py.nodes")
node = node_module.XYplot_DiffusionModel()
result = node.xy_value(
2,
model_name_1="waiANIMA_v10Base10.safetensors",
clip_name_1="qwen_3_06b_base.safetensors",
vae_name_1="qwen_image_vae.safetensors",
model_name_2="moodyKrea2Mix,v70.safetensors",
clip_name_2="Auto",
vae_name_2="Auto",
)
self.assertEqual(result[0]["axis"], "advanced: DiffusionModel")
self.assertEqual(
result[0]["values"],
[
"waiANIMA_v10Base10.safetensors,qwen_3_06b_base.safetensors,qwen_image_vae.safetensors",
"moodyKrea2Mix*v70.safetensors,Auto,Auto",
],
)
model_name, clip_name, vae_name = result[0]["values"][0].split(",")
self.assertEqual(model_name.replace("*", ","), "waiANIMA_v10Base10.safetensors")
self.assertEqual(clip_name.replace("*", ","), "qwen_3_06b_base.safetensors")
self.assertEqual(vae_name.replace("*", ","), "qwen_image_vae.safetensors")
model_name, clip_name, vae_name = result[0]["values"][1].split(",")
self.assertEqual(model_name.replace("*", ","), "moodyKrea2Mix,v70.safetensors")
self.assertEqual(clip_name, "Auto")
self.assertEqual(vae_name, "Auto")
def test_ensure_latent_raises_for_nonempty_4d_latent_without_image(self):
with installed_modules(xyplot_lib_stubs()):
xyplot_module = load_module("fake_py.libs.xyplot", XYPLOT_LIB_PATH, "fake_py.libs")
model = FakeModelPatcher(latent_format=FakeLatentFormat())
vae = types.SimpleNamespace(encode=lambda pixels: torch.zeros([pixels.shape[0], 16, 1, pixels.shape[2], pixels.shape[3]]))
samples = {"samples": torch.ones([1, 4, 64, 64])}
with self.assertRaisesRegex(RuntimeError, "requires an input image"):
xyplot_module.easyXYPlot._ensure_latent_for_model(model, vae, samples, {})
def test_ensure_latent_expands_empty_4d_latent_to_5d(self):
with installed_modules(xyplot_lib_stubs()):
xyplot_module = load_module("fake_py.libs.xyplot", XYPLOT_LIB_PATH, "fake_py.libs")
model = FakeModelPatcher(latent_format=FakeLatentFormat())
vae = types.SimpleNamespace(encode=lambda pixels: torch.zeros([pixels.shape[0], 16, 1, pixels.shape[2], pixels.shape[3]]))
samples = {"samples": torch.zeros([1, 4, 64, 64])}
result = xyplot_module.easyXYPlot._ensure_latent_for_model(model, vae, samples, {})
self.assertEqual(result["samples"].shape, torch.Size([1, 16, 1, 64, 64]))
if __name__ == "__main__":
unittest.main()
+71
View File
@@ -0,0 +1,71 @@
import importlib.util
import os
from pathlib import Path
import tempfile
import unittest
MODULE_PATH = Path(__file__).parents[1] / "py" / "libs" / "path_utils.py"
SPEC = importlib.util.spec_from_file_location("easyuse_path_utils", MODULE_PATH)
path_utils = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(path_utils)
class ResolveOutputFilePathTests(unittest.TestCase):
def test_relative_subdirectory_is_resolved_under_output_root(self):
with tempfile.TemporaryDirectory() as output_root:
result = path_utils.resolve_output_file_path(
output_root, "metadata", "prompt", "txt"
)
self.assertEqual(
result,
os.path.join(os.path.realpath(output_root), "metadata", "prompt.txt"),
)
def test_absolute_directory_inside_output_root_is_allowed(self):
with tempfile.TemporaryDirectory() as output_root:
inside = os.path.join(output_root, "metadata")
result = path_utils.resolve_output_file_path(
output_root, inside, "prompt", "txt"
)
self.assertEqual(result, os.path.join(inside, "prompt.txt"))
def test_absolute_directory_outside_output_root_is_rejected(self):
with tempfile.TemporaryDirectory() as output_root:
with tempfile.TemporaryDirectory() as outside:
with self.assertRaises(ValueError):
path_utils.resolve_output_file_path(
output_root, outside, "marker", "txt"
)
def test_output_directory_traversal_is_rejected(self):
with tempfile.TemporaryDirectory() as output_root:
with self.assertRaises(ValueError):
path_utils.resolve_output_file_path(
output_root, "../outside", "marker", "txt"
)
def test_file_name_traversal_is_rejected(self):
with tempfile.TemporaryDirectory() as output_root:
with self.assertRaises(ValueError):
path_utils.resolve_output_file_path(
output_root, ".", "../../marker", "txt"
)
@unittest.skipUnless(hasattr(os, "symlink"), "symlinks are unavailable")
def test_symlink_escape_is_rejected(self):
with tempfile.TemporaryDirectory() as output_root:
with tempfile.TemporaryDirectory() as outside:
os.symlink(outside, os.path.join(output_root, "linked"))
with self.assertRaises(ValueError):
path_utils.resolve_output_file_path(
output_root, "linked", "marker", "txt"
)
if __name__ == "__main__":
unittest.main()
+174
View File
@@ -0,0 +1,174 @@
import ast
import asyncio
import hashlib
import inspect
import json
import os
from pathlib import Path
import shutil
import sys
import tempfile
import unittest
from functools import lru_cache
from types import SimpleNamespace
from urllib.parse import urlsplit
from unittest.mock import patch
from aiohttp import web
from aiohttp.test_utils import TestClient, TestServer
from PIL import Image, UnidentifiedImageError
ROUTES_PATH = Path(__file__).parents[1] / "py" / "routes.py"
def load_handlers(folder_paths, get_metadata):
"""Load the actual handlers without importing ComfyUI's GPU dependencies."""
names = {
"_same_origin_request", "get_reboot_token", "reboot", "_model_sha256",
"load_metadata", "save_notes", "save_preview",
}
tree = ast.parse(ROUTES_PATH.read_text())
functions = []
for node in tree.body:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in names:
node.decorator_list = []
functions.append(node)
namespace = {
"os": os, "sys": sys, "hashlib": hashlib, "hmac": __import__("hmac"),
"json": json, "shutil": shutil, "tempfile": tempfile,
"lru_cache": lru_cache, "urlsplit": urlsplit, "web": web,
"Image": Image, "UnidentifiedImageError": UnidentifiedImageError,
"folder_paths": folder_paths, "getMetadata": get_metadata,
"_reboot_token": "test-reboot-token",
"_PREVIEW_FORMATS": {
".png": "PNG", ".jpg": "JPEG", ".jpeg": "JPEG",
".webp": "WEBP", ".gif": "GIF",
},
}
exec(compile(ast.Module(body=functions, type_ignores=[]), str(ROUTES_PATH), "exec"), namespace)
return SimpleNamespace(**namespace)
class SecurityRouteTests(unittest.TestCase):
def setUp(self):
self.workspace = tempfile.TemporaryDirectory()
self.addCleanup(self.workspace.cleanup)
self.root = Path(self.workspace.name)
self.model_dir = self.root / "models"
self.temp_dir = self.root / "temp"
self.model_dir.mkdir()
self.temp_dir.mkdir()
self.model_path = self.model_dir / "sample.safetensors"
self.model_path.write_bytes(b"model data")
paths = SimpleNamespace(
get_filename_list=lambda kind: [self.model_path.name],
get_full_path=lambda kind, name: str(self.model_path),
get_directory_by_type=lambda kind: str(self.temp_dir),
)
self.handlers = load_handlers(
paths,
lambda path: json.dumps({"__metadata__": {"easyuse.notes": "<img onerror=alert(1)>"}}),
)
def request(self, name="loras/sample.safetensors", filename="preview.png", **body):
payload = {"type": "temp", "filename": filename, **body}
return SimpleNamespace(
match_info={"name": name},
json=lambda: asyncio.sleep(0, result=payload),
headers={}, host="localhost:8188",
)
def test_save_rejects_script_and_custom_node_target(self):
(self.temp_dir / "payload.py").write_text("print('sentinel')")
response = asyncio.run(self.handlers.save_preview(self.request(filename="payload.py")))
self.assertEqual(response.status, 400)
response = asyncio.run(self.handlers.save_preview(
self.request(name="custom_nodes/package/__init__.py", filename="payload.py")
))
self.assertEqual(response.status, 400)
def test_save_accepts_real_image_and_rejects_disguised_script(self):
Image.new("RGB", (1, 1)).save(self.temp_dir / "preview.png")
response = asyncio.run(self.handlers.save_preview(self.request()))
self.assertEqual(response.status, 200)
with Image.open(self.model_dir / "sample.png") as saved:
self.assertEqual(saved.format, "PNG")
(self.temp_dir / "preview.png").write_text("print('sentinel')")
response = asyncio.run(self.handlers.save_preview(self.request()))
self.assertEqual(response.status, 400)
with Image.open(self.model_dir / "sample.png") as saved:
self.assertEqual(saved.format, "PNG")
@unittest.skipUnless(hasattr(os, "symlink"), "symlinks are unavailable")
def test_save_does_not_follow_preview_symlink(self):
Image.new("RGB", (1, 1)).save(self.temp_dir / "preview.png")
protected = self.root / "protected.txt"
protected.write_text("untouched")
os.symlink(protected, self.model_dir / "sample.png")
response = asyncio.run(self.handlers.save_preview(self.request()))
self.assertEqual(response.status, 400)
self.assertEqual(protected.read_text(), "untouched")
def test_metadata_ignores_forged_hash_sidecar(self):
(self.model_dir / "sample.sha256").write_text("0" * 64)
response = asyncio.run(self.handlers.load_metadata(self.request()))
self.assertEqual(
json.loads(response.text)["easyuse.sha256"],
hashlib.sha256(self.model_path.read_bytes()).hexdigest(),
)
@unittest.skipUnless(hasattr(os, "symlink"), "symlinks are unavailable")
def test_notes_reject_custom_nodes_and_do_not_follow_symlinks(self):
request = self.request(name="custom_nodes/package/__init__.py")
request.text = lambda: asyncio.sleep(0, result="new notes")
self.assertEqual(asyncio.run(self.handlers.save_notes(request)).status, 400)
protected = self.root / "protected.txt"
protected.write_text("untouched")
os.symlink(protected, self.model_dir / "sample.txt")
request = self.request()
request.text = lambda: asyncio.sleep(0, result="new notes")
self.assertEqual(asyncio.run(self.handlers.save_notes(request)).status, 200)
self.assertEqual(protected.read_text(), "untouched")
self.assertEqual((self.model_dir / "sample.txt").read_text(), "new notes")
def test_reboot_requires_token_and_same_origin(self):
self.assertTrue(inspect.iscoroutinefunction(self.handlers.get_reboot_token))
self.assertTrue(inspect.iscoroutinefunction(self.handlers.reboot))
request = self.request()
request.headers = {"Sec-Fetch-Site": "cross-site"}
self.assertEqual(asyncio.run(self.handlers.get_reboot_token(request)).status, 403)
request.headers = {"Sec-Fetch-Site": "same-origin"}
self.assertEqual(json.loads(asyncio.run(self.handlers.get_reboot_token(request)).text)["token"], "test-reboot-token")
request.headers = {}
with patch.object(self.handlers.os, "execv", return_value="restarted") as restart:
self.assertEqual(asyncio.run(self.handlers.reboot(request)).status, 403)
request.headers = {"X-EasyUse-Reboot-Token": "test-reboot-token", "Origin": "http://other.test"}
self.assertEqual(asyncio.run(self.handlers.reboot(request)).status, 403)
restart.assert_not_called()
request.headers["Origin"] = "http://localhost:8188"
request.headers["Sec-Fetch-Site"] = "same-site"
self.assertEqual(asyncio.run(self.handlers.reboot(request)).status, 403)
request.headers["Sec-Fetch-Site"] = "same-origin"
self.assertEqual(asyncio.run(self.handlers.reboot(request)), "restarted")
restart.assert_called_once()
def test_reboot_routes_return_http_responses(self):
async def exercise_routes():
app = web.Application()
app.router.add_get("/easyuse/reboot-token", self.handlers.get_reboot_token)
app.router.add_post("/easyuse/reboot", self.handlers.reboot)
async with TestClient(TestServer(app)) as client:
token_response = await client.get("/easyuse/reboot-token")
self.assertEqual(token_response.status, 200)
self.assertEqual((await token_response.json())["token"], "test-reboot-token")
reboot_response = await client.post("/easyuse/reboot")
self.assertEqual(reboot_response.status, 403)
asyncio.run(exercise_routes())
if __name__ == "__main__":
unittest.main()
-50
View File
@@ -1,50 +0,0 @@
# 开发人员使用(请勿运行)
# 将 https://github.com/AIGODLIKE/AIGODLIKE-ComfyUI-Translation 的翻译文件转换格式以适配 ComfyUI locales
import json
import os
import pathlib
old_json_path = 'ComfyUI-Easy-Use.json'
root_path = pathlib.Path(__file__).parent.parent
new_json_path = os.path.join(root_path,'locales/zh/nodeDefs.json')
def transform_dict(data):
new_dict = {}
for k, v in data.items():
new_dict[k] = {
"display_name": "",
"inputs": {}
}
if isinstance(v, dict):
for key, value in v.items():
if key == 'title':
new_dict[k]['display_name'] = value
elif key in ['inputs','widgets']:
for _key, _value in value.items():
new_dict[k]['inputs'] = {
**new_dict[k]['inputs'],
_key: {"name": _value}
}
elif key == 'outputs':
if not new_dict[k].get('outputs'):
new_dict[k]['outputs'] = {}
for idx, (out_key, out_value) in enumerate(value.items()):
new_dict[k]['outputs'][idx] = {"name": out_value}
return new_dict
def main():
# 读取原始JSON文件
with open(old_json_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# 转换数据
transformed_data = transform_dict(data)
# 写入新的JSON文件
with open(new_json_path, 'w', encoding='utf-8') as f:
json.dump(transformed_data, f, ensure_ascii=False, indent=2)
if __name__ == '__main__':
main()
+14 -8
View File
@@ -114,10 +114,10 @@ export class ModelInfoDialog extends ComfyDialog {
let pre = this.customNotes.substring(end, pos);
if (pre) {
pre = pre.replaceAll("\n", "<br>");
notes.push(
$el("span", {
innerHTML: pre,
textContent: pre,
style: { whiteSpace: "pre-line" },
})
);
}
@@ -127,6 +127,7 @@ export class ModelInfoDialog extends ComfyDialog {
href: m[0],
textContent: m[0],
target: "_blank",
rel: "noopener noreferrer",
})
);
}
@@ -335,7 +336,9 @@ export class ModelInfoDialog extends ComfyDialog {
const blob = await (await fetch(cate.url)).blob();
// Store it in temp
const name = "temp_preview." + new URL(cate.url).pathname.split(".")[1];
const extension = ({"image/png": "png", "image/jpeg": "jpg", "image/webp": "webp", "image/gif": "gif"})[blob.type]
|| new URL(cate.url).pathname.split(".").pop().toLowerCase();
const name = "temp_preview." + extension;
const body = new FormData();
body.append("image", new File([blob], name));
body.append("overwrite", "true");
@@ -365,10 +368,13 @@ export class ModelInfoDialog extends ComfyDialog {
headers: {
"content-type": "application/json",
},
}).then(_=>{
toast.success($t('Saving Succeed'))
toast.hideLoading()
});
}).then(response => {
if (!response.ok) throw new Error(`Error saving preview (${response.status})`);
toast.success($t('Saving Succeed'));
}).catch(error => {
console.error(error);
toast.error($t('Saving Failed'));
}).finally(() => toast.hideLoading());
this.isSaving = false
app.refreshComboInNodes();
},
@@ -680,4 +686,4 @@ export class LoraInfoDialog extends ModelInfoDialog {
return btns;
}
}
}
+9 -3
View File
@@ -486,10 +486,16 @@ app.registerExtension({
// Only show the reboot option if the server is running on a local network 仅在本地或局域网环境可重启服务
isLocalNetwork(window.location.host) ? {
content: rebootIcon.replace('currentColor','var(--error-color)') + ' '+ $t('Reboot ComfyUI') + ' (EasyUse)',
callback: _ =>{
callback: async _ =>{
if (confirm($t("Are you sure you'd like to reboot the server?"))){
try {
api.fetchApi("/easyuse/reboot");
const tokenResponse = await api.fetchApi("/easyuse/reboot-token");
if (!tokenResponse.ok) throw new Error("Could not get reboot token");
const {token} = await tokenResponse.json();
await api.fetchApi("/easyuse/reboot", {
method: "POST",
headers: {"X-EasyUse-Reboot-Token": token},
});
} catch (exception) {}
}
}
@@ -607,4 +613,4 @@ app.registerExtension({
};
}
},
});
});
+1 -1
View File
@@ -1149,7 +1149,7 @@ app.registerExtension({
for (const list of text) {
const w = ComfyWidgets["STRING"](this, "text", ["STRING", { multiline: true }], app).widget;
w.inputEl.readOnly = true;
// w.inputEl.readOnly = true;
w.inputEl.style.opacity = 0.6;
w.value = list;
}
+18 -7
View File
@@ -1,4 +1,5 @@
import { api } from "../../../scripts/api.js";
import { app } from "../../../scripts/app.js";
// 全局Seed
function globalSeedHandler(event) {
@@ -27,21 +28,31 @@ function globalSeedHandler(event) {
api.addEventListener("easyuse-global-seed", globalSeedHandler);
const original_queuePrompt = api.queuePrompt;
async function queuePrompt_with_seed(number, { output, workflow }) {
function addSeedWidgetsToWorkflow(prompt, graph) {
const workflow = prompt?.workflow;
if (!workflow || typeof workflow !== 'object') return prompt;
workflow.seed_widgets = {};
for(let i in app.graph._nodes_by_id) {
let widgets = app.graph._nodes_by_id[i].widgets;
const nodes = graph?._nodes_by_id || {};
for(let i in nodes) {
let widgets = nodes[i].widgets;
if(widgets) {
for(let j in widgets) {
if((widgets[j].name == 'seed_num' || widgets[j].name == 'seed' || widgets[j].name == 'noise_seed') && widgets[j].type != 'converted-widget')
workflow.seed_widgets[i] = parseInt(j);
}
}
}
}
return await original_queuePrompt.call(api, number, { output, workflow });
return prompt;
}
api.queuePrompt = queuePrompt_with_seed;
// Keep ComfyUI's queuePrompt untouched so its validation errors and evolving
// execution options remain owned by the core frontend.
const original_graphToPrompt = app.graphToPrompt;
app.graphToPrompt = function graphToPrompt_with_seed(...args) {
const graph = args[0] || app.rootGraph || app.graph;
return Promise.resolve(original_graphToPrompt.apply(this, args))
.then(prompt => addSeedWidgetsToWorkflow(prompt, graph));
};
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long