Compare commits

..
Author SHA1 Message Date
Mel Massadian d2da2949b4 feat: ✨ improve the I/O sidebar
- better options (sort, count)
- uses the new toast api instead of MTB.notify
2024-11-20 22:41:04 +01:00
Mel Massadian 9120d3ec42 chore: 🧹 add worktree to gitignores
for the experimental doc site at:
https://melmass.github.io/comfy_mtb/
2024-11-20 22:40:16 +01:00
Mel Massadian 2c8b7d790d feat: ✨ add UpscaleBBoxBy 2024-11-20 22:40:16 +01:00
Mel Massadian 540c8c9fa9 chore 🧹: add deprecations and experimental 2024-11-20 22:40:16 +01:00
Mel Massadian 88d51e5774 chore: 🧹 remove dupe code 2024-11-20 22:40:16 +01:00
Mel Massadian 8f47810b79 feat: ✨ simplified sidebar and backend
If you have a LoadImage selected,
clicking on images in the "input" mode will set the image on the
selected nodes
2024-11-20 22:40:16 +01:00
Mel Massadian ae1ef0f914 feat: ✨ add Interpolate Condition 2024-11-20 22:40:06 +01:00
Mel Massadian b0e234b7ee feat: ✨ dump of wip things... 2024-11-20 22:39:23 +01:00
Mel Massadian b6fa571fd2 fix: 🐛 category for settings 2024-11-20 21:41:57 +01:00
Mel Massadian f272526bfc fix: 🐛 new UI issues
- Fixes the "edit icon cannot be clicked"
- Changed the parser to add support for more non std markdown
- Markdown links now always open a new tab instead of replacing current
- New optional shiki support for code blocks (check #211 for details)
2024-11-20 21:41:57 +01:00
Mel Massadian 4e593bb30b feat: ✨ use the new parser for documentations
- might also fix #210
2024-11-20 21:41:57 +01:00
Mel Massadian 097ca33b8e feat: ✨ add @mtb/markdown-parser bundles
- the standard one is half the size of showdown
- the enhanced one (add shiki with most of its features) is 1.5mb
2024-11-20 21:41:57 +01:00
Mel Massadian 784fb0145b chore: 🧹 update externs
- remove showdown
- update dompurify
2024-11-20 21:41:57 +01:00
Chenlei Hu dbcca15a21 fix 🐛: input type on MTB_AnyToString (#204) 2024-10-10 02:13:13 +02:00
Mel Massadian bc41576fac docs 📚: fix wiki link
closes #202
2024-09-29 00:57:44 +02:00
Mel Massadian 8596b8184e fix: 🐛 disable old BOOL widget (legacy)
This can break if a pack declares a BOOL type

fixes #201
2024-09-27 13:33:01 +02:00
Mel Massadian 896a025006 feat: ✨ add VitMatte nodes
Basic implementation hardcoded for cuda
https://huggingface.co/melmass/pytorch-scripts
2024-09-22 00:16:23 +02:00
Mel Massadian 43092e44a4 fix: 🐛 pass ONNX providers explicitely
see #199
2024-09-08 19:58:35 +02:00
bymyself 80b5a0ca74 fix: 🐛 typo in mtb_widgets error catch (#197) 2024-09-05 14:50:48 +02:00
Mel Massadian 81b3bc1651 fix: 🐛 doc widget sidebar offset in the new ui 2024-08-18 14:26:02 +02:00
Mel Massadian a825504bdd chore: 🧹 add pathlibed inputs to utils 2024-08-18 14:07:42 +02:00
Mel Massadian 22190cd25e chore: 🧹 disable Constant
Removing as this doesn't work without my PR
2024-08-16 00:03:25 +02:00
Mel Massadian a976adbb39 chore: 🧹 new ui is default, flag for old ui 2024-08-16 00:02:18 +02:00
Mel Massadian 997d2fb13a fix: 🐛 don't fallback to eval
addresses legitimate concerns raised in #190
This limits the use a bit, SimpleMath from:
https://github.com/cubiq/ComfyUI_essentials
Is a better alternative
2024-08-08 22:34:03 +02:00
Mel Massadian f8829fcb37 chore: 🧹 add methods to shared 2024-08-08 16:57:10 +02:00
Mel Massadian 9651a70341 feat: ✨ add ColorCorrectGPU
Alternative to my ColorCorrect using only torch.
Also added Mask input for both (optional so this is not a breaking change)
2024-08-01 19:33:13 +02:00
Mel Massadian 57683c3c7d feat: ✨ add Swap FG/BG colors to MaskToImage 2024-08-01 18:38:58 +02:00
Mel Massadian f99f92e8f7 feat: ✨ add Extract coordinates
wip meant mainly for SAM2
2024-08-01 18:36:12 +02:00
Mel Massadian 5bc125d2f0 docs: 📚 remove link
still in issues
2024-08-01 17:32:23 +02:00
Mel Massadian c99b0812ab fix: 🐛 rework main utils
A whole gymnastic because comfy masks are (B,H,W).
maybe unsqueezing first is better but some nodes seems to still output
(B,H,W,C), IIRC there is an upstream PR about that
2024-08-01 17:31:18 +02:00
Mel Massadian 333f646ab1 docs: 📚 clean readme 2024-08-01 17:28:33 +02:00
Mel Massadian dbdf27664c chore: 🧹 add an old_ui flag to my launcher
this is dev related to easily test both UIs
see: https://github.com/melMass/CosyVoice-ComfyUI/commit/29510c36f0f8c1e4e5209148d14fe038947728c1
2024-07-31 04:59:36 +02:00
Mel Massadian 7d5569e5c1 chore: 🧹 move qrcode to his own file
Each files in `./nodes` can fail, but this means all nodes in the
file are skipped... `Generate` has "too important" nodes to fail
and doesn't require any extra dependencies.

This change allow qrcode to fail on its own
2024-07-31 04:59:17 +02:00
Mel Massadian 5681b464ad feat: ✨ add AudioCut
and make AudioSequence able to get negative "silence"
which would effectively "overlap" the joining sections
2024-07-31 04:42:40 +02:00
Mel Massadian 8d0fcee2f3 feat: ✨ add AudioStack
To stack/overlay audios.
2024-07-28 20:11:31 +02:00
Mel Massadian 1078fc6f0f feat: ✨ add AudioSequence node 2024-07-28 17:20:01 +02:00
Mel Massadian 821a0ef427 fix: 🐛 MaskToImage
also remove style debug
2024-07-07 20:40:18 +02:00
Mel Massadian 9007a70aa0 feat: ✨ add Split Bbox node 2024-07-06 18:30:14 +02:00
Mel Massadian 1a0ebd5173 feat: ✨ update lerp example 2024-07-06 18:26:45 +02:00
39 changed files with 4646 additions and 2589 deletions
+3
View File
@@ -6,3 +6,6 @@ node_modules/
compose.yaml
comfy_mtb.wsb
Dockerfile
# I store the gh-pages worktrees (src & build) there
.worktrees
-93
View File
@@ -1,93 +0,0 @@
# 安装
- [安装](#安装)
- [自动安装(推荐)](#自动安装推荐)
- [ComfyUI 管理器](#comfyui-管理器)
- [虚拟环境](#虚拟环境)
- [模型下载](#模型下载)
- [网络扩展](#网络扩展)
- [旧的安装方法 (MANUAL)](#旧的安装方法-manual)
- [依赖关系](#依赖关系)
### 自动安装(推荐)
### ComfyUI 管理器
从 0.1.0 版开始,该扩展将使用 [ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager) 进行安装,这对处理各种环境下的各种安装问题大有帮助。
### 虚拟环境
还有一种试验性的单行安装方法,即在 ComfyUI 根目录下使用以下命令进行安装。它将下载代码、安装依赖项并运行安装脚本:
```bash
curl -sSL "https://raw.githubusercontent.com/username/repo/main/install.py" | python3 -
```
## 模型下载
某些节点需要下载额外的模型,您可以使用与上述相同的 python 环境以交互方式完成下载:
```bash
python scripts/download_models.py
```
然后根据提示或直接按回车键下载每个模型。
> **Note**
> 您可以使用以下方法下载所有型号,无需提示:
```bash
python scripts/download_models.py -y
```
#### 网络扩展
首次运行时,脚本会尝试将 [网络扩展](https://github.com/melMass/comfy_mtb/tree/main/web)链接到你的 "web/extensions "文件夹,[请参阅](https://github.com/melMass/comfy_mtb/blob/d982b69a58c05ccead9c49370764beaa4549992a/__init__.py#L45-L61)。
<img alt="color widget preview" src="https://github.com/melMass/comfy_mtb/assets/7041726/cff7e66a-4cc4-4866-b35b-10af0bb2d110" width=450>
### 旧的安装方法 (MANUAL)
### 依赖关系
<details><summary><h4>Custom Virtualenv(我主要用这个)</h4></summary
1. 确保您处于用于 ComfyUI 的 Python 环境中。
2. 运行以下命令安装所需的依赖项:
```bash
pip install -r comfy_mtb/reqs.txt
```
</details>
<details><summary><h4>Comfy 便携式/单机版(来自 ComfyUI 版本)</h4></summary>
如果您使用 ComfyUI 单机版中的 `python-embeded `,那么当二进制文件没有轮子时,您就无法使用 pip 安装二进制文件的依赖项,在这种情况下,请查看最近的 [发布](https://github.com/melMass/comfy_mtb/releases),那里有一个预编译轮子的 linux 和 windows 捆绑包(只有那些需要从源代码编译的轮子),请查看 [此问题 (#1)](https://github.com/melMass/comfy_mtb/issues/1) 以获取更多信息。
![image](https://github.com/melMass/comfy_mtb/assets/7041726/2934fa14-3725-427c-8b9e-2b4f60ba1b7b)
</details>
<details><summary><h4>Google Colab</h4></summary>
在 **Run ComfyUI with localtunnel (Recommended Way)** 标题之后(代码单元格之前)添加一个新的代码单元格
![preview of where to add it on colab](https://github.com/melMass/comfy_mtb/assets/7041726/35df2ef1-14f9-44cd-aa65-353829188cd7)
```python
# download the nodes
!git clone --recursive https://github.com/melMass/comfy_mtb.git custom_nodes/comfy_mtb
# download all models
!python custom_nodes/comfy_mtb/scripts/download_models.py -y
# install the dependencies
!pip install -r custom_nodes/comfy_mtb/reqs.txt -f https://download.openmmlab.com/mmcv/dist/cu118/torch2.0/index.html
```
如果运行后 colab 抱怨需要重新启动运行时,请重新启动,然后不要重新运行之前的单元格,只运行运行本地隧道的单元格。(可能需要先添加一个包含 `%cd ComfyUI` 的单元格)
> **Note**:
> If you don't need all models, remove the `-y` as collab actually supports user input: ![image](https://github.com/melMass/comfy_mtb/assets/7041726/40fc3602-f1d4-432a-98fd-ce2240f5ad06)
> **Preview**
> ![image](https://github.com/melMass/comfy_mtb/assets/7041726/b5b2b2d9-f1e8-4c43-b1db-7dfc5e07be86)
</details>
-93
View File
@@ -1,93 +0,0 @@
# インストール
- [インストール](#インストール)
- [自動インストール (推奨)](#自動インストール-推奨)
- [ComfyUI マネージャ](#comfyui-マネージャ)
- [仮想環境](#仮想環境)
- [モデルのダウンロード](#モデルのダウンロード)
- [ウェブ拡張機能](#ウェブ拡張機能)
- [旧インストール方法 (MANUAL)](#旧インストール方法-manual)
- [依存関係](#依存関係)
## 自動インストール (推奨)
### ComfyUI マネージャ
バージョン0.1.0では、この拡張機能は[ComfyUI-Manager](https://github.com/ltdrdata/ComfyUI-Manager)と一緒にインストールすることを想定しています。これは、様々な環境で直面する様々なインストール問題を処理するのに非常に役立ちます。
### 仮想環境
また、ComfyUIのルートから以下のコマンドを使用する実験的なワンライナー・インストールもあります。これはコードをダウンロードし、依存関係をインストールし、インストールスクリプトを実行します:
```bash
curl -sSL "https://raw.githubusercontent.com/username/repo/main/install.py" | python3 -
```
## モデルのダウンロード
ノードによっては、追加モデルのダウンロードが必要な場合があるので、上記と同じ python 環境を使って対話的に行うことができる:
```bash
python scripts/download_models.py
```
プロンプトに従うか、Enterを押すだけで全てのモデルをダウンロードできます。
> **Note**
> プロンプトを出さずに全てのモデルをダウンロードするには、以下のようにします:
```bash
python scripts/download_models.py -y
```
### ウェブ拡張機能
初回実行時にスクリプトは[web extensions](https://github.com/melMass/comfy_mtb/tree/main/web)をあなたの快適な `web/extensions` フォルダに[シンボリックリンク](https://github.com/melMass/comfy_mtb/blob/d982b69a58c05ccead9c49370764beaa4549992a/__init__.py#L45-L61)しようとします。万が一失敗した場合は、mtbフォルダを手動で`ComfyUI/web/extensions`にコピーしてください:
<img alt="color widget preview" src="https://github.com/melMass/comfy_mtb/assets/7041726/cff7e66a-4cc4-4866-b35b-10af0bb2d110" width=450>
## 旧インストール方法 (MANUAL)
### 依存関係
<details><summary><h4>カスタム Virtualenv (私は主にこれを使っています)</h4></summary>
1. ComfyUIで使用しているPython環境であることを確認してください。
2. 以下のコマンドを実行して、必要な依存関係をインストールします:
```bash
pip install -r comfy_mtb/reqs.txt
```
</details>
<details><summary><h4>Comfy-portable / standalone (ComfyUI リリースより)</h4></summary>。
もしあなたがComfyUIスタンドアロンから`python-embeded`を使用している場合、バイナリがホイールを持っていない場合、依存関係をpipでインストールすることができません。この場合、最後の[リリース](https://github.com/melMass/comfy_mtb/releases)をチェックしてください。(ソースからのビルドが必要なもののみ)あらかじめビルドされたホイールがあるlinuxとwindows用のバンドルがあります。詳細は[この問題(#1)](https://github.com/melMass/comfy_mtb/issues/1)をチェックしてください。
![image](https://github.com/melMass/comfy_mtb/assets/7041726/2934fa14-3725-427c-8b9e-2b4f60ba1b7b)
</details>
<details><summary><h4>Google Colab</h4></summary>
ComfyUI with localtunnel (Recommended Way)**ヘッダーのすぐ後(コードセルの前)に、新しいコードセルを追加してください。
![colabに追加する場所のプレビュー](https://github.com/melMass/comfy_mtb/assets/7041726/35df2ef1-14f9-44cd-aa65-353829188cd7)
```python
# download the nodes
!git clone --recursive https://github.com/melMass/comfy_mtb.git custom_nodes/comfy_mtb
# download all models
!python custom_nodes/comfy_mtb/scripts/download_models.py -y
# install the dependencies
!pip install -r custom_nodes/comfy_mtb/reqs.txt -f https://download.openmmlab.com/mmcv/dist/cu118/torch2.0/index.html
```
これを実行した後、colabがランタイムを再起動する必要があると文句を言ったら、それを実行し、それ以前のセルは再実行せず、localtunnelを実行するセルだけを再実行してください。(最初に`%cd ComfyUI`のセルを追加する必要があるかもしれません...)
> **Note**:
> すべてのモデルが必要でない場合は、`-y`を削除してください : ![image](https://github.com/melMass/comfy_mtb/assets/7041726/40fc3602-f1d4-432a-98fd-ce2240f5ad06)
> **プレビュー**
> ![image](https://github.com/melMass/comfy_mtb/assets/7041726/b5b2b2d9-f1e8-4c43-b1db-7dfc5e07be86)
</details>
-99
View File
@@ -1,99 +0,0 @@
# MTB Nodes
<a href="https://www.buymeacoffee.com/melmass" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 32px !important;width: 140px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
[** 安装指南**](./INSTALL-CN.md) | [** 示例**](https://github.com/melMass/comfy_mtb/wiki/Examples)
欢迎使用 MTB Nodes 项目!这个代码库是开放的,您可以自由地探索和利用。它的主要目的是构建用于 [MLOPs](https://github.com/Bismuth-Consultancy-BV/MLOPs) 中的概念验证(POCs)。该项目中的许多节点都是受到现有社区贡献或内置功能的启发而创建的。
在继续之前,请注意与此项目中使用的某些库相关的许可证。例如,`deepbump` 库采用 [GPLv3](https://github.com/HugoTini/DeepBump/blob/master/LICENSE) 许可证。
- [节点列表](#节点列表)
- [bbox](#bbox)
- [colors](#colors)
- [人脸检测/交换](#人脸检测交换)
- [图像插值(动画)](#图像插值动画)
- [图像操作](#图像操作)
- [潜在变量工具](#潜在变量工具)
- [其他工具](#其他工具)
- [纹理](#纹理)
- [Comfy 资源](#comfy-资源)
# 节点列表
## bbox
- `Bounding Box`: BBox 构造函数(自定义类型)
- `BBox From Mask`: 从遮罩中提取边界框
- `Crop`: 根据边界框裁剪图像
- `Uncrop`: 根据边界框还原图像
## colors
- `Colored Image`: 给定尺寸的纯色图像
- `RGB to HSV`: -
- `HSV to RGB`: -
- `Color Correct`: 基本颜色校正工具
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/7c20ac83-31ff-40ea-a1a0-06c2acefb2ef" width=345/>
## 人脸检测/交换
- `Face Swap`: 使用 deepinsight/insightface 模型进行人脸交换(该节点在早期版本中称为 `Roop`,功能相同,`Roop` 只是使用这些模型的应用程序)
> **注意**
> 人脸索引允许您选择要替换的人脸,如下所示:
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/2e9d6066-c466-4a01-bd6c-315f7f1e8b42" width=320/>
- `Load Face Swap Model`: 加载 insightface 模型用于人脸交换
- `Restore Face`: 使用 [GFPGan](https://github.com/TencentARC/GFPGAN) 还原人脸,与 `Face Swap` 配合使用效果很好,并支持 `bg_upscaler` 的 Comfy 原生放大器
## 图像插值(动画)
- `Load Film Model`: 加载 [FILM](https://github.com/google-research/frame-interpolation) 模型
- `Film Interpolation`: 使用 [FILM](https://github.com/google-research/frame-interpolation) 处理输入帧
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/3afd1647-6634-4b92-a34b-51432e6a9834" width=400/>
- `Export to Prores (experimental)`: 将输入帧导出为 ProRes 4444 mov 文件。这使用 ffmpeg stdin 发送原始的 NumPy 数组,与 `Film Interpolation` 一起使用,目前很简单,但可以进一步扩展。
## 图像操作
- `Blur`: 使用高斯滤波器对图像进行模糊处理。
- `Deglaze Image`: 从 [FN16](https://github.com/Fannovel16/FN16-ComfyUI-nodes/blob/main/DeglazeImage.py) 中提取
- `Denoise`: 对输入图像进行降噪处理
- `Image Compare`: 比较两个图像并返回差异图像
- `Image Premultiply`: 使用掩码对图像进行预乘处理
- `Image Remove Background Rembg`: 使用 [RemBG](https://github.com/danielgatis/rembg) 进行背景去除
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/e69253b4-c03c-45e9-92b5-aa46fb887be8" width=320/>
- `Image Resize Factor`: 大部分提取自 [WAS Node Suite](https://github.com/WASasquatch/was-node-suite-comfyui),经过一些编辑(特别是支持多个图像)和较少的功能。
- `Mask To Image`: 将遮罩(Alpha)转换为带有颜色和背景的 RGB 图像
- `Save Image Grid`: 将输入批次中的所有图像保存为图像网格。
## 潜在变量工具
- `Latent Lerp`: 两个潜在变量之间的线性插值(混合)
## 其他工具
- `Concat Images`: 接受两个图像流,并将它们合并为其他 Comfy 管道支持的图像批次。
- `Image Resize Factor`: **已弃用**,因为我后来发现了内
置的图像调整大小功能。
- `Text To Image`: 使用字体将文本转换为图像的工具
- `Styles Loader`: 加载 csv 文件并从行中填充下拉列表(类似于 A111)
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/02fe3211-18ee-4e54-a029-931388f5fde8" width=320/>
- `Smart Step`: 一个非常基本的节点,用于获取在 KSampler 高级中使用的步骤百分比
- `Qr Code`: 基本的 QR Code 生成器
- `Save Tensors`: 调试节点,将来可能会被删除
- `Int to Number`: 用于 WASSuite 数字节点的补充
- `Smart Step`: 使用百分比来控制 `KAdvancedSampler` 的步骤(开始/停止)
## 纹理
- `DeepBump`: 从单张图片生成法线图和高度图
# Comfy 资源
**指南**:
- [官方示例(英文)](https://comfyanonymous.github.io/ComfyUI_examples/)
- @BlenderNeko 的[ComfyUI 社区手册(英文)](https://blenderneko.github.io/ComfyUI-docs/)
- @tjhayasaka 的[Tomoaki 个人 Wiki(日文)](https://comfyui.creamlab.net/guides/)
**扩展和自定义节点**:
- @WASasquatch 的[Comfy 列表插件(英文)](https://github.com/WASasquatch/comfyui-plugins)
- [CivitAI 上的 ComfyUI 标签(英文)](https://civitai.com/tag/comfyui)
-96
View File
@@ -1,96 +0,0 @@
# MTB Nodes
<a href="https://www.buymeacoffee.com/melmass" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 32px !important;width: 140px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
[**インストールガイド**](./INSTALL-JP.md) | [**サンプル**](https://github.com/melMass/comfy_mtb/wiki/Examples)
MTB Nodesプロジェクトへようこそ!このコードベースは、自由に探索し、利用することができます。主な目的は、[MLOPs](https://github.com/Bismuth-Consultancy-BV/MLOPs)の実装のための概念実証(POC)を構築することです。このプロジェクトの多くのノードは、既存のコミュニティの貢献や組み込みの機能に触発されています。
続行する前に、このプロジェクトで使用されている特定のライブラリに関連するライセンスに注意してください。たとえば、「deepbump」ライブラリは、[GPLv3](https://github.com/HugoTini/DeepBump/blob/master/LICENSE)の下でライセンスされています。
- [ノードリスト](#ノードリスト)
- [bbox](#bbox)
- [colors](#colors)
- [顔検出 / スワッピング](#顔検出--スワッピング)
- [画像補間(アニメーション)](#画像補間アニメーション)
- [画像操作](#画像操作)
- [潜在的なユーティリティ](#潜在的なユーティリティ)
- [その他のユーティリティ](#その他のユーティリティ)
- [テクスチャ](#テクスチャ)
- [Comfyリソース](#comfyリソース)
# ノードリスト
## bbox
- `Bounding Box`: BBoxコンストラクタ(カスタムタイプ)
- `BBox From Mask`: マスクからバウンディングボックスを抽出
- `Crop`: BBoxから画像を切り抜く
- `Uncrop`: BBoxから画像を元に戻す
## colors
- `Colored Image`: 指定されたサイズの一定の色の画像
- `RGB to HSV`: -
- `HSV to RGB`: -
- `Color Correct`: 基本的なカラーコレクションツール
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/7c20ac83-31ff-40ea-a1a0-06c2acefb2ef" width=345/>
## 顔検出 / スワッピング
- `Face Swap`: deepinsight/insightfaceモデルを使用した顔の入れ替え(このノードは初期バージョンでは「Roop」と呼ばれていましたが、同じ機能を提供します。Roopは単にこれらのモデルを使用するアプリです)
> **注意**
> 顔のインデックスを使用して置き換える顔を選択できます。以下を参照してください:
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/2e9d6066-c466-4a01-bd6c-315f7f1e8b42" width=320/>
- `Load Face Swap Model`: 顔の交換のためのinsightfaceモデルを読み込む
- `Restore Face`: [GFPGan](https://github.com/TencentARC/GFPGAN)を使用して顔を復元し、`Face Swap`と組み合わせて使用すると非常に効果的であり、`bg_upscaler`のComfyネイティブアップスケーラーもサポートしています。
## 画像補間(アニメーション)
- `Load Film Model`: [FILM](https://github.com/google-research/frame-interpolation)モデルを読み込む
- `Film Interpolation`: [FILM](https://github.com/google-research/frame-interpolation)を使用して入力フレームを処理する
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/3afd1647-6634-4b92-a34b-51432e6a9834" width=400/>
- `Export to Prores (experimental)`: 入力フレームをProRes 4444 movファイルにエクスポートします。これは現在は単純なものですが、`Film Interpolation`と組み合わせて使用するためのffmpegのstdinを使用して生のNumPy配列を送信するもので、拡張することもできます。
## 画像操作
- `Blur`: ガウスフィルタを使用して画像をぼかす
- `Deglaze Image`: [FN16](https://github.com/Fannovel16/FN16-ComfyUI-nodes/blob/main/DeglazeImage.py)から取得
- `Denoise`: 入力画像のノイズを除去する
- `Image Compare`: 2つの画像を比較し、差分画像を返す
- `Image Premultiply`: 画像をマスクで乗算
- `Image Remove Background Rembg`: [RemBG](https://github.com/danielgatis/rembg)を使用した背景除去
<img src="https://github.com/melMass/comfy_mtb/assets/704172
6/e69253b4-c03c-45e9-92b5-aa46fb887be8" width=320/>
- `Image Resize Factor`: [WAS Node Suite](https://github.com/WASasquatch/was-node-suite-comfyui)から抽出され、いくつかの編集(特に複数の画像のサポート)と機能の削減が行われました。
- `Mask To Image`: マスク(アルファ)をカラーと背景を持つRGBイメージに変換します。
- `Save Image Grid`: 入力バッチのすべての画像を画像グリッドとして保存します。
## 潜在的なユーティリティ
- `Latent Lerp`: 2つの潜在的なベクトルの間の線形補間(ブレンド)
## その他のユーティリティ
- `Concat Images`: 2つの画像ストリームを取り、他のComfyパイプラインでサポートされている画像のバッチとしてマージします。
- `Image Resize Factor`: **非推奨**。組み込みの画像リサイズ機能を発見したため、削除される予定です。
- `Text To Image`: フォントを使用してテキストを画像に変換するためのユーティリティ
- `Styles Loader`: csvファイルをロードし、行からドロップダウンを作成します(A111のようなもの)
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/02fe3211-18ee-4e54-a029-931388f5fde8" width=320/>
- `Smart Step`: KSamplerの高度な使用に使用するステップパーセントを取得する非常に基本的なノード
- `Qr Code`: 基本的なQRコード生成器
- `Save Tensors`: 将来的に削除される可能性のあるデバッグノード
- `Int to Number`: WASSuiteの数値ノードの補完
- `Smart Step`: `KAdvancedSampler`のステップ(開始/停止)を制御するための非常に基本的なツールで、パーセンテージを使用します。
## テクスチャ
- `DeepBump`: 1枚の画像から法線マップと高さマップを生成します。
# Comfyリソース
**ガイド**:
- [公式の例(英語)](https://comfyanonymous.github.io/ComfyUI_examples/)
- @BlenderNekoによる[ComfyUIコミュニティマニュアル(英語)](https://blenderneko.github.io/ComfyUI-docs/)
- @tjhayasakaによる[Tomoakiの個人Wiki(日本語)](https://comfyui.creamlab.net/guides/)
**拡張機能とカスタムノード**:
- @WASasquatchによる[Comfyリスト用のプラグイン(英語)](https://github.com/WASasquatch/comfyui-plugins)
- [CivitAIのComfyUIタグ(英語)](https://civitai.com/tag/comfyui)
+1 -170
View File
@@ -4,177 +4,8 @@
![home](https://repository-images.githubusercontent.com/649047066/a3eef9a7-20dd-4ef9-b839-884502d4e873)
<!-- omit in toc -->
**Translated Readme (using DeepTranslate, PRs are welcome)**:
![image](https://github.com/melMass/comfy_mtb/assets/7041726/f8429c14-3521-4e28-82a3-863d781976c0)
[日本語による説明](./README-JP.md)
![image](https://github.com/melMass/comfy_mtb/assets/7041726/d5cc1fdd-2820-4a5c-b2d7-482f1c222063)
[中文说明](./README-CN.md)
<a href="https://www.buymeacoffee.com/melmass" target="_blank"><img src="https://www.buymeacoffee.com/assets/img/custom_images/orange_img.png" alt="Buy Me A Coffee" style="height: 32px !important;width: 140px !important;box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;-webkit-box-shadow: 0px 3px 2px 0px rgba(190, 190, 190, 0.5) !important;" ></a>
[**Install Guide**](./INSTALL.md) | [**Examples**](https://github.com/melMass/comfy_mtb/wiki/Examples)
There is now a dedicated `#mtb-nodes` channel on the Banodoco discord:
[![](https://dcbadge.vercel.app/api/server/AXhsabmDhn?style=flat)](https://discord.gg/IAXhsabmDhn)
---
Welcome to the MTB Nodes project! This codebase is open for you to explore and utilize as you wish. Its primary purpose is to build proof-of-concepts (POCs) for implementation in [MLOPs](https://github.com/Bismuth-Consultancy-BV/MLOPs). Many nodes in this project are inspired by existing community contributions or built-in functionalities.
Before proceeding, please be aware of the licenses associated with certain libraries used in this project. For example, the `deepbump` library is licensed under [GPLv3](https://github.com/HugoTini/DeepBump/blob/master/LICENSE).
- [Web Extensions](#web-extensions)
- [Node List](#node-list)
- [Animation](#animation)
- [bbox](#bbox)
- [colors](#colors)
- [image ops](#image-ops)
- [latent utils](#latent-utils)
- [textures](#textures)
- [misc utils](#misc-utils)
- [Optional nodes](#optional-nodes)
- [face detection / swapping](#face-detection--swapping)
- [image interpolation (animation)](#image-interpolation-animation)
- [Comfy Resources](#comfy-resources)
# Web Extensions
mtb add a few widgets like `COLOR`
<img alt="color widget preview" src="https://github.com/melMass/comfy_mtb/assets/7041726/cff7e66a-4cc4-4866-b35b-10af0bb2d110" width=450>
A few nodes have the concept of "dynamic" inputs:
<img alt="dynamic inputs" width=450 src="https://github.com/melMass/comfy_mtb/assets/7041726/10b3976e-b212-4968-91eb-f34c02bb80c3" />
<!-- NOTE: Here it should just be some examples and warnings, move the rest to the wiki -->
# Node List
## Animation
- `Animation Builder`: Convenient way to manage basic animation maths at the core of many of my workflows (both worflows for the following GIFs are in the [examples](https://github.com/melMass/comfy_mtb/wiki/Examples))
**[Example lerping two conditions (blue car -> yellow car)](https://github.com/melMass/comfy_mtb/blob/main/examples/03-animation_builder-condition-lerp.json)**
<img width=300 src="https://user-images.githubusercontent.com/7041726/260258970-d6d66d96-fb34-40d0-9038-cbabf0714c5d.gif"/>
**[Example using image transforms a feedback for a fake deforum effect](https://github.com/melMass/comfy_mtb/blob/main/examples/04-animation_builder-deforum.json)**
<img width=300 src="https://user-images.githubusercontent.com/7041726/260261504-303a1037-60d3-4b31-a589-b15d549752f6.gif"/>
- `Batch Float`: Generates a batch of float values with interpolation.
- `Batch Shape`: Generates a batch of 2D shapes with optional shading (experimental).
- `Batch Transform`: Transform a batch of images using a batch of keyframes.
<img width=400 src="https://github.com/melMass/comfy_mtb/assets/7041726/3f217de1-79aa-49b0-a66a-35cf29dd8f01"/>
- `Export With Ffmpeg`: Export with FFmpeg, it used to be export to Proress and is still tailored for YUV
- `Fit Number` : Fit the input float using a source and target range, you can also control the interpolation curve from a list of presets (default to linear)
## bbox
- `Bounding Box`: BBox constructor (custom type),
- `BBox From Mask`: From a mask extract the bounding box
- `Crop`: Crop image from BBox
- `Uncrop`: Uncrop image from BBox
## colors
- `Colored Image`: Constant color image of given size
- `RGB to HSV`: -,
- `HSV to RGB`: -,
- `Color Correct`: Basic color correction tools
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/7c20ac83-31ff-40ea-a1a0-06c2acefb2ef" width=400/>
## image ops
- `Blur`: Blur an image using a Gaussian filter.
- `Deglaze Image`: taken from [FN16](https://github.com/Fannovel16/FN16-ComfyUI-nodes/blob/main/DeglazeImage.py),
- `Denoise`: Denoise the input image,
- `Image Compare`: Compare two images and return a difference image
- `Image Premultiply`: Premultiply image with mask
- `Image Remove Background Rembg`: [RemBG](https://github.com/danielgatis/rembg) powered background removal.
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/e69253b4-c03c-45e9-92b5-aa46fb887be8" width=320/>
- `Image Resize Factor`: Extracted mostly from [WAS Node Suite](https://github.com/WASasquatch/was-node-suite-comfyui), with a few edits (most notably multiple image support) and less features.
- `Mask To Image`: Converts a mask (alpha) to an RGB image with a color and background
- `Save Image Grid`: Save all the images in the input batch as a grid of images.
## latent utils
- `Latent Lerp`: Linear interpolation (blend) between two latent
## textures
- `Model Patch Seamless`: Use the [seamless diffusion "hack"](https://gitlab.com/-/snippets/2395088) to patch any model to infere seamless images, check the [examples](https://github.com/melMass/comfy_mtb/wiki/Examples) to see how to use all those textures node together
<img width=500 src="https://user-images.githubusercontent.com/7041726/272970506-9db516b5-45d2-4389-b904-b3a94660f24c.png"/>
- `DeepBump`: Normal & height maps generation from single pictures
<img width=500 src="https://user-images.githubusercontent.com/7041726/272970715-7e4477f6-8e18-4839-9864-83d07d6690a1.png"/>
- `Image Tile Offset`: Mimics an old photoshop technique to check for seamless textures by offsetting tiles of the image.
<img width=600 src="https://github.com/melMass/comfy_mtb/assets/7041726/cbcc51fb-922f-433f-acf1-c6c6c2a7ffc4" />
## misc utils
- `Any To String`: Tries to take any input and convert it to a string.
- `Concat Images`: Takes two image stream and merge them as a batch of images supported by other Comfy pipelines.
- `Image Resize Factor`: **Deprecated**, I since discovered the builtin image resize.
- `Text To Image`: Utils to convert text to image using a font
- `Styles Loader`: Load csv files and populate a dropdown from the rows (à la A111)
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/02fe3211-18ee-4e54-a029-931388f5fde8" width=320/>
- `Smart Step`: A very basic node to get step percent to use in KSampler advanced,
- `Qr Code`: Basic QR Code generator
- `Save Tensors`: Debug node that will probably be removed in the future
- `Int to Number`: Supplement for WASSuite number nodes
- `Smart Step`: A very basic tool to control the steps (start/stop) of the `KAdvancedSampler` using percentage
- `Load Image From Url`: Load an image from the given URL
[**Wiki**](https://github.com/melMass/comfy_mtb/wiki) | [**Install Guide**](./INSTALL.md) | [**Examples**](https://github.com/melMass/comfy_mtb/wiki/Examples)
## Optional nodes
These nodes are still bundled in mtb, but moving forward (>0.2.0) they won't
be setup by the install script and their dependencies won't install either.
The reason is mostly that they all have a better alternatives available and tensorflow on windows was not a fun experience and since Python 3.11 not an experience at all.
For linux and mac users though these nodes didn't cause any issue and I personally still use them, these are the extra requirements needed:
```console
.venv/python -m pip install tensorflow facexlib insightface basicsr
```
### face detection / swapping
> **Warning**
> Those nodes were among the first to be implemented they do work, but on windows the installation is still not properly handled for everyone
> As alternatives you can use [reactor](https://github.com/Gourieff/comfyui-reactor-node) for face swap and [facerestore](https://github.com/Haidra-Org/hordelib/tree/main/hordelib/nodes/facerestore) for restoration
> You can check [this video](https://www.youtube.com/watch?v=FShlpMxbU0E) for a tutorial by Ferniclestix using these alternatives
- `Face Swap`: Face swap using deepinsight/insightface models (this node used to be called `Roop` in early versions, it does the same, roop is *just* an app that uses those model)
<img width=320 src="https://user-images.githubusercontent.com/7041726/260261217-54e33446-183f-4dda-88b3-d38a1e6de980.gif"/>
- `Load Face Swap Model`: Load an insightface model for face swapping
- `Restore Face`: Using [GFPGan](https://github.com/TencentARC/GFPGAN) to restore faces, works great in conjunction with `Face Swap` and supports Comfy native upscalers for the `bg_upscaler`
### image interpolation (animation)
> **Warning**
> The FILM nodes will be deprecated at some point after 0.2.0, [Fannovel16](https://github.com/Fannovel16/ComfyUI-Frame-Interpolation)'s interpolation nodes implement it and they rely on a pytorch implementation of FILM
> which solves the issues related to the ones included in mtb. They will probably remain available if your system meet the requirements and ignored otherwise.
<details><summary>Why?</summary>
> **Windows only issue**: This requires tensorflow-gpu that is unfortunately not a thing anymore on Windows since 2.10.1 (unless you use a complex WSL passthrough setup but it's still not "Windows")
> Using this old version is quite clunky and require some patching that install.py does automatically, but the main issue is that no wheels are available for python > 3.10
> Comfy-nightly is already using Python 11 so installing this old tf version won't work there.
> You can in any case install the normal up to date tensorflow but that will run on CPU and is much MUCH slower for FILM inference.
</details>
- `Load Film Model`: Loads a [FILM](https://github.com/google-research/frame-interpolation) model
- `Film Interpolation`: Process input frames using [FILM](https://github.com/google-research/frame-interpolation)
<img width=400 src="https://github.com/melMass/comfy_mtb/assets/7041726/3afd1647-6634-4b92-a34b-51432e6a9834"/>
<img width=400 src="https://user-images.githubusercontent.com/7041726/260259079-c0f04a63-960c-43a7-ba78-a45cd5ac7514.gif"/>
- `Export to Prores (experimental)`: Exports the input frames to a ProRes 4444 mov file. This is using ffmpeg stdin to send raw numpy arrays, used with `Film Interpolation` and very simple for now but could be expanded upon.
# Comfy Resources
**Misc**
- [Slick ComfyUI by NoCrypt](https://colab.research.google.com/drive/1ZMvLWEiYITmBJngtqeIQToeNuiydwI0z#scrollTo=1fWMaexXS188): A colab notebook with batteries included!
**Guides**:
- [Official Examples (eng)](https://comfyanonymous.github.io/ComfyUI_examples/)
- [ComfyUI Community Manual (eng)](https://blenderneko.github.io/ComfyUI-docs/) by @BlenderNeko
- [Tomoaki's personal Wiki (jap)](https://comfyui.creamlab.net/guides/) by @tjhayasaka
**Extensions and Custom Nodes**:
- [Plugins for Comfy List (eng)](https://github.com/WASasquatch/comfyui-plugins) by @WASasquatch
- [ComfyUI tag on CivitAI (eng)](https://civitai.com/tag/comfyui)
+170 -36
View File
@@ -11,6 +11,8 @@ __version__ = "0.1.6"
import os
from aiohttp.web_request import Request
# TODO: don't override this if the user has that setup already
if not os.environ.get("TF_FORCE_GPU_ALLOW_GROWTH"):
os.environ["TF_FORCE_GPU_ALLOW_GROWTH"] = "true"
@@ -31,24 +33,20 @@ from pathlib import Path
from aiohttp import web
from server import PromptServer
import nodes
from .endpoint import endlog
from .log import blue_text, cyan_text, get_label, get_summary, log
from .utils import comfy_dir, here
NODE_CLASS_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_CLASS_MAPPINGS_DEBUG = {}
NODE_CLASS_MAPPINGS: dict[str, type] = {}
NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {}
NODE_CLASS_MAPPINGS_DEBUG: dict[str, str | None] = {}
WEB_DIRECTORY = "./web"
def extract_nodes_from_source(filename: Path):
source_code = ""
source_code = filename.read_text(encoding="utf-8")
nodes = []
nodes: list[str] = []
try:
parsed = ast.parse(source_code)
@@ -57,14 +55,15 @@ def extract_nodes_from_source(filename: Path):
target = node.targets[0]
if isinstance(target, ast.Name) and target.id == "__nodes__":
value = ast.get_source_segment(source_code, node.value)
node_value = ast.parse(value).body[0].value
if isinstance(node_value, (ast.List, ast.Tuple)):
nodes.extend(
element.id
for element in node_value.elts
if isinstance(element, ast.Name)
)
break
if value:
node_value = ast.parse(value).body[0].value
if isinstance(node_value, ast.List | ast.Tuple):
nodes.extend(
str(element.id)
for element in node_value.elts
if isinstance(element, ast.Name)
)
break
except SyntaxError:
log.error("Failed to parse")
return nodes
@@ -72,8 +71,8 @@ def extract_nodes_from_source(filename: Path):
def load_nodes():
errors: list[str] = []
nodes = []
nodes_failed = []
nodes: list[type] = []
nodes_failed: list[str] = []
for filename in (here / "nodes").iterdir():
if filename.suffix == ".py":
@@ -124,7 +123,8 @@ def uninstall_old_web_extensions():
shutil.rmtree(web_mtb)
except Exception as e:
log.warning(
f"Failed to remove web mtb directory: {e}\nPlease manually remove it from disk ({web_mtb}) and restart the server."
f"""Failed to remove web mtb directory: {e}
Please manually remove it from disk ({web_mtb}) and restart the server."""
)
@@ -141,7 +141,7 @@ def wiki_to_classname(s: str):
def classname_to_wiki(s: str):
classname = s.replace("MTB_", "")
parts = []
parts: list[str] = []
start = 0
for i in range(1, len(classname)):
if classname[i].isupper():
@@ -161,8 +161,6 @@ if wiki.exists() and wiki.is_dir():
# - REGISTER NODES
MTB_EXPORT = os.environ.get("MTB_EXPORT")
nodes, failed = load_nodes()
@@ -179,7 +177,7 @@ for node_class in nodes:
node_class.DESCRIPTION = node_class.__doc__
if MTB_EXPORT:
wiki_name = classname_to_wiki(class_name)
(wiki / "nodes" / (wiki_name + ".md")).write_text(
_ = (wiki / "nodes" / (wiki_name + ".md")).write_text(
node_class.__doc__, encoding="utf-8"
)
@@ -192,12 +190,15 @@ for node_class in nodes:
NODE_CLASS_MAPPINGS[node_label] = node_class
NODE_DISPLAY_NAME_MAPPINGS[class_name] = node_label
NODE_CLASS_MAPPINGS_DEBUG[node_label] = node_class.__doc__
# TODO: I removed this, I find it more convenient to write without spaces, but it breaks every of my workflows
# TODO (cont): and until I find a way to automate the conversion, I'll leave it like this
# TODO: I removed this, I find it more convenient to write without spaces
# but it breaks every of my workflows
# TODO (cont): and until I find a way to automate the conversion
# I'll leave it like this
if os.environ.get("MTB_EXPORT"):
with open(here / "node_list.json", "w") as f:
f.write(
_ = f.write(
json.dumps(
{
k: NODE_CLASS_MAPPINGS_DEBUG[k]
@@ -215,19 +216,27 @@ log.debug(
)
)
log.info(f"loaded {cyan_text(len(nodes))} nodes successfuly")
log.info(f"loaded {cyan_text(str(len(nodes)))} nodes successfuly")
if failed:
with contextlib.suppress(Exception):
base_url, port = utils.get_server_info()
log.info(
f"Some nodes ({len(failed)}) could not be loaded. This can be ignored, but go to http://{base_url}:{port}/mtb if you want more information."
)
log.debug(failed)
# - ENDPOINT
if hasattr(PromptServer, "instance"):
img_cache = None
with contextlib.suppress(ImportError):
from cachetools import TTLCache
img_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
restore_deps = ["basicsr"]
onnx_deps = ["onnxruntime"]
swap_deps = ["insightface"] + onnx_deps
@@ -307,8 +316,8 @@ if hasattr(PromptServer, "instance"):
)
@PromptServer.instance.routes.post("/mtb/debug")
async def set_debug(request):
json_data = await request.json()
async def set_debug(request: Request):
json_data: dict[str, bool] = await request.json()
enabled = json_data.get("enabled")
if enabled:
os.environ["MTB_DEBUG"] = "true"
@@ -317,7 +326,7 @@ if hasattr(PromptServer, "instance"):
elif "MTB_DEBUG" in os.environ:
# del os.environ["MTB_DEBUG"]
os.environ.pop("MTB_DEBUG")
_ = os.environ.pop("MTB_DEBUG")
log.setLevel(logging.INFO)
return web.json_response(
@@ -325,10 +334,10 @@ if hasattr(PromptServer, "instance"):
)
@PromptServer.instance.routes.get("/mtb")
async def get_home(request):
async def get_home(request: Request):
from . import endpoint
reload(endpoint)
_ = reload(endpoint)
# Check if the request prefers HTML content
if "text/html" in request.headers.get("Accept", ""):
# # Return an HTML page
@@ -347,11 +356,136 @@ if hasattr(PromptServer, "instance"):
# Return JSON for other requests
return web.json_response({"message": "Welcome to MTB!"})
import asyncio
import os
from io import BytesIO
from aiohttp import web
from PIL import Image
def get_cached_image(file_path: str, preview_params=None, channel=None):
cache_key = (file_path, preview_params, channel)
if img_cache and (cache_key in img_cache):
return img_cache[cache_key]
with Image.open(file_path) as img:
if preview_params:
img = process_preview(img, preview_params)
if channel:
img = process_channel(img, channel)
if img_cache:
img_cache[cache_key] = img.getvalue()
return img_cache[cache_key]
return img.getvalue()
def process_preview(img: Image, preview_params):
image_format, quality, width = preview_params
quality = int(quality)
if width:
width = int(width)
img.thumbnail((width, int(width * img.height / img.width)))
buffer = BytesIO()
img.save(buffer, format=image_format, quality=quality)
buffer.seek(0)
return buffer
def process_channel(img: Image.Image, channel: str):
if channel == "rgb":
if img.mode == "RGBA":
r, g, b, _ = img.split()
img = Image.merge("RGB", (r, g, b))
else:
img = img.convert("RGB")
elif channel == "a":
if img.mode == "RGBA":
_, _, _, a = img.split()
else:
a = Image.new("L", img.size, 255)
img = Image.new("RGBA", img.size)
img.putalpha(a)
buffer = BytesIO()
img.save(buffer, format="PNG")
_ = buffer.seek(0)
return buffer
async def get_image_response(
file, filename: str, preview_info=None, channel=None
):
img = await asyncio.to_thread(
get_cached_image, file, preview_info, channel
)
return web.Response(
body=img,
content_type="image/webp" if preview_info else "image/png",
headers={"Content-Disposition": f'filename="{filename}"'},
)
@PromptServer.instance.routes.get("/mtb/view")
async def view_image(request: Request):
import folder_paths
filename = request.rel_url.query.get("filename")
if not filename:
return web.Response(status=404)
filename, output_dir = folder_paths.annotated_filepath(filename)
if filename[0] == "/" or ".." in filename:
return web.Response(status=400)
if output_dir is None:
rtype = request.rel_url.query.get("type", "output")
output_dir = folder_paths.get_directory_by_type(rtype)
if output_dir is None:
return web.Response(status=400)
if "subfolder" in request.rel_url.query:
full_output_dir = os.path.join(
output_dir, request.rel_url.query["subfolder"]
)
if (
os.path.commonpath(
(os.path.abspath(full_output_dir), output_dir)
)
!= output_dir
):
return web.Response(status=403)
output_dir = full_output_dir
filename = os.path.basename(filename)
file = os.path.join(output_dir, filename)
if not os.path.isfile(file):
return web.Response(status=404)
preview_info = None
if "preview" in request.rel_url.query:
preview_params = request.rel_url.query["preview"].split(";")
image_format = (
preview_params[0]
if preview_params[0] in ["webp", "jpeg"]
else "webp"
)
quality = (
int(preview_params[1])
if len(preview_params) > 1 and preview_params[1].isdigit()
else 90
)
width = request.rel_url.query.get("width")
preview_info = (image_format, quality, width)
channel = request.rel_url.query.get("channel")
return await get_image_response(file, filename, preview_info, channel)
@PromptServer.instance.routes.get("/mtb/debug")
async def get_debug(request):
async def get_debug(request: Request):
from . import endpoint
reload(endpoint)
_ = reload(endpoint)
enabled = "MTB_DEBUG" in os.environ
# Check if the request prefers HTML content
if "text/html" in request.headers.get("Accept", ""):
@@ -368,7 +502,7 @@ if hasattr(PromptServer, "instance"):
return web.json_response({"enabled": enabled})
@PromptServer.instance.routes.get("/mtb/actions")
async def no_route(request):
async def no_route(request: Request):
from . import endpoint
if "text/html" in request.headers.get("Accept", ""):
@@ -382,7 +516,7 @@ if hasattr(PromptServer, "instance"):
return web.json_response({"message": "actions has no get for now"})
@PromptServer.instance.routes.post("/mtb/actions")
async def do_action(request):
async def do_action(request: Request):
from . import endpoint
reload(endpoint)
+28 -19
View File
@@ -1,22 +1,31 @@
{
"$schema": "https://biomejs.dev/schemas/1.6.1/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"formatter": {
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded",
"indentWidth": 2
}
"$schema": "https://biomejs.dev/schemas/1.6.1/schema.json",
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true,
"suspicious": {
"noConsoleLog": "warn"
},
"style": {
"noParameterAssign": "off",
"noShoutyConstants": "warn",
"useNamingConvention": "off"
}
}
},
"formatter": {
"indentStyle": "space",
"indentWidth": 2,
"lineEnding": "lf"
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
}
}
}
+97 -19
View File
@@ -1,4 +1,9 @@
import csv
import os
import secrets
import sys
from pathlib import Path
from typing import Any
from aiohttp import web
@@ -6,6 +11,8 @@ from .log import mklog
from .utils import (
backup_file,
import_install,
input_dir,
output_dir,
reqs_map,
run_command,
styles_dir,
@@ -14,15 +21,14 @@ from .utils import (
endlog = mklog("mtb endpoint")
# - ACTIONS
import sys
from pathlib import Path
import_install("requirements")
def ACTIONS_installDependency(dependency_names=None):
if dependency_names is None:
# return web.Response(text="No dependency name provided", status=400)
return {"error": "No dependency name provided"}
endlog.debug(f"Received Install Dependency request for {dependency_names}")
# reqs = []
resolved_names = [reqs_map.get(name, name) for name in dependency_names]
@@ -50,6 +56,63 @@ def ACTIONS_installDependency(dependency_names=None):
# break
def ACTIONS_getUserImages(
mode: str,
count=200,
offset=0,
sort: str | None = None,
include_subfolders: bool = False,
):
# TODO: find a better name :s
enabled = "MTB_EXPOSE" in os.environ
if not enabled:
return {"error": "Session not authorized to getInputs"}
imgs = {}
entry_dir = input_dir if mode == "input" else output_dir
pattern = "**/*.png" if include_subfolders else "*.png"
entry_gen = entry_dir.glob(pattern)
entries = {}
if sort:
sort = sort.lower()
if sort == "none":
entries = entry_gen
elif sort == "modified":
entries = sorted(
entry_gen, key=lambda x: x.stat().st_mtime, reverse=True
)
elif sort == "modified-reverse":
entries = sorted(entry_gen, key=lambda x: x.stat().st_mtime)
elif sort == "name":
entries = sorted(entry_gen, key=lambda x: x.name)
elif sort == "name-reverse":
entries = sorted(entry_gen, key=lambda x: x.name, reverse=True)
else:
endlog.warning(f"Sort mode {sort} not supported")
entries = entry_gen
else:
entries = entry_gen
for i, img in enumerate(entries):
if i < offset:
continue
subfolder = (
img.parent.relative_to(entry_dir) if include_subfolders else ""
)
imgs[img.stem] = (
f"/mtb/view?filename={img.name}&width=512&type={mode}&subfolder="
f"{subfolder}"
f"&preview=&rand={secrets.randbelow(424242)}"
)
if i >= count + offset - 1:
break
return imgs
def ACTIONS_getStyles(style_name=None):
from .nodes.conditions import MTB_StylesLoader
@@ -97,7 +160,7 @@ def ACTIONS_saveStyle(data):
csv_writer.writerow(row)
async def do_action(request) -> web.Response:
async def do_action(request: web.Request) -> web.Response:
endlog.debug("Init action request")
request_data = await request.json()
name = request_data.get("name")
@@ -109,7 +172,12 @@ async def do_action(request) -> web.Response:
method = globals().get(method_name)
if callable(method):
result = method(args) if args else method()
result = None
if args:
result = method(*args) if isinstance(args, list) else method(args)
else:
result = method()
endlog.debug(f"Action result: {result}")
return web.json_response({"result": result})
@@ -130,10 +198,13 @@ async def do_action(request) -> web.Response:
# - HTML UTILS
def dependencies_button(name, dependencies):
def dependencies_button(name: str, dependencies: list[str]) -> str:
deps = ",".join([f"'{x}'" for x in dependencies])
return f"""
<button class="dependency-button" onclick="window.mtb_action('installDependency',[{deps}])">Install {name} deps</button>
<button
class="dependency-button"
onclick="window.mtb_action('installDependency',[{deps}])"
>Install {name} deps</button>
"""
@@ -153,7 +224,7 @@ def csv_editor():
html_out = """
<div id="style-editor">
<h1>Style Editor</h1>
"""
for current, styles in style_files.items():
current_out = f"<h3>{current}</h3>"
@@ -215,11 +286,14 @@ def render_tab_view(**kwargs):
"""
def add_foldable_region(title, content):
def add_foldable_region(title: str, content: str):
symbol_id = f"{title}-symbol"
return f"""
<div class='foldable'>
<div class='foldable-title' onclick="toggleFoldable('{title}', '{symbol_id}')">
<div
class='foldable-title'
onclick="toggleFoldable('{title}', '{symbol_id}')"
>
<span id='{symbol_id}' class='foldable-symbol'>&#9655;</span>
{title}
</div>
@@ -231,7 +305,9 @@ def add_foldable_region(title, content):
"""
def add_split_pane(left_content, right_content, vertical=True):
def add_split_pane(
left_content: str, right_content: str, *, vertical: bool = True
):
orientation = "vertical" if vertical else "horizontal"
return f"""
<div class="split-pane {orientation}">
@@ -250,7 +326,7 @@ def add_split_pane(left_content, right_content, vertical=True):
"""
def add_dropdown(title, options):
def add_dropdown(title: str, options: list[str]):
option_str = "\n".join(
[f"<option value='{opt}'>{opt}</option>" for opt in options]
)
@@ -262,13 +338,13 @@ def add_dropdown(title, options):
"""
def render_table(table_dict, sort=True, title=None):
table_dict = sorted(
def render_table(table_dict: dict[str, Any], sort=True, title=None):
table_list = sorted(
table_dict.items(), key=lambda item: item[0]
) # Sort the dictionary by keys
table_rows = ""
for name, item in table_dict:
for name, item in table_list:
if isinstance(item, dict):
if "dependencies" in item:
table_rows += f"<tr><td>{name}</td><td>"
@@ -299,12 +375,12 @@ def render_table(table_dict, sort=True, title=None):
<tbody>
{table_rows}
</tbody>
</table>
</table>
</div>
"""
def render_base_template(title, content):
def render_base_template(title: str, content: str):
github_icon_svg = """<svg xmlns="http://www.w3.org/2000/svg" fill="whitesmoke" height="3em" viewBox="0 0 496 512"><path d="M165.9 397.4c0 2-2.3 3.6-5.2 3.6-3.3.3-5.6-1.3-5.6-3.6 0-2 2.3-3.6 5.2-3.6 3-.3 5.6 1.3 5.6 3.6zm-31.1-4.5c-.7 2 1.3 4.3 4.3 4.9 2.6 1 5.6 0 6.2-2s-1.3-4.3-4.3-5.2c-2.6-.7-5.5.3-6.2 2.3zm44.2-1.7c-2.9.7-4.9 2.6-4.6 4.9.3 2 2.9 3.3 5.9 2.6 2.9-.7 4.9-2.6 4.6-4.6-.3-1.9-3-3.2-5.9-2.9zM244.8 8C106.1 8 0 113.3 0 252c0 110.9 69.8 205.8 169.5 239.2 12.8 2.3 17.3-5.6 17.3-12.1 0-6.2-.3-40.4-.3-61.4 0 0-70 15-84.7-29.8 0 0-11.4-29.1-27.8-36.6 0 0-22.9-15.7 1.6-15.4 0 0 24.9 2 38.6 25.8 21.9 38.6 58.6 27.5 72.9 20.9 2.3-16 8.8-27.1 16-33.7-55.9-6.2-112.3-14.3-112.3-110.5 0-27.5 7.6-41.3 23.6-58.9-2.6-6.5-11.1-33.3 2.6-67.9 20.9-6.5 69 27 69 27 20-5.6 41.5-8.5 62.8-8.5s42.8 2.9 62.8 8.5c0 0 48.1-33.6 69-27 13.7 34.7 5.2 61.4 2.6 67.9 16 17.7 25.8 31.5 25.8 58.9 0 96.5-58.9 104.2-114.8 110.5 9.2 7.9 17 22.9 17 46.4 0 33.7-.3 75.4-.3 83.6 0 6.5 4.6 14.4 17.3 12.1C428.2 457.8 496 362.9 496 252 496 113.3 383.5 8 244.8 8zM97.2 352.9c-1.3 1-1 3.3.7 5.2 1.6 1.6 3.9 2.3 5.2 1 1.3-1 1-3.3-.7-5.2-1.6-1.6-3.9-2.3-5.2-1zm-10.8-8.1c-.7 1.3.3 2.9 2.3 3.9 1.6 1 3.6.7 4.3-.7.7-1.3-.3-2.9-2.3-3.9-2-.6-3.6-.3-4.3.7zm32.4 35.6c-1.6 1.3-1 4.3 1.3 6.2 2.3 2.3 5.2 2.6 6.5 1 1.3-1.3.7-4.3-1.3-6.2-2.2-2.3-5.2-2.6-6.5-1zm-11.4-14.7c-1.6 1-1.6 3.6 0 5.9 1.6 2.3 4.3 3.3 5.6 2.3 1.6-1.3 1.6-3.9 0-6.2-1.4-2.3-4-3.3-5.6-2z"/></svg>"""
return f"""
<!DOCTYPE html>
@@ -340,7 +416,9 @@ def render_base_template(title, content):
<header>
<a href="/">Back to Comfy</a>
<div class="mtb_logo">
<img src="https://repository-images.githubusercontent.com/649047066/a3eef9a7-20dd-4ef9-b839-884502d4e873" alt="Comfy MTB Logo" height="70" width="128">
<img
src="https://repository-images.githubusercontent.com/649047066/a3eef9a7-20dd-4ef9-b839-884502d4e873"
alt="Comfy MTB Logo" height="70" width="128">
<span class="title">Comfy MTB</span></div>
<a style="width:128px;text-align:center" href="https://www.github.com/melmass/comfy_mtb">
{github_icon_svg}
@@ -355,6 +433,6 @@ def render_base_template(title, content):
<!-- Shared footer content here -->
</footer>
</body>
</html>
"""
+7 -4
View File
@@ -1,7 +1,5 @@
# NOTE: This file is only use for development you can ignore it
use path.nu *
def get_root [--clean] {
if $clean {
$env.COMFY_CLEAN_ROOT
@@ -25,10 +23,11 @@ export def "comfy dev-web" [] {
# start the comfy server
export def "comfy start" [--clean, --listen] {
export def "comfy start" [--clean,--old-ui, --listen] {
let root = get_root --clean=($clean)
cd $root
MTB_DEBUG=true python main.py --port 3000 --preview-method auto ...(if $listen {["--listen"]} else {[]})
MTB_DEBUG=true python main.py --port 3000 ...(if $old_ui { ["--front-end-version", "Comfy-Org/ComfyUI_legacy_frontend@latest"]} else {[]}) --preview-method auto ...(if $listen {["--listen"]} else {[]})
}
# update comfy itself and merge master in current branch
@@ -139,6 +138,10 @@ export def "comfy update_extensions" [--clean] {
git multipull .
}
def --env path-add [pth] {
$env.PATH = ($env.PATH | append ($pth | path expand))
}
export-env {
File diff suppressed because one or more lines are too long
+235
View File
@@ -0,0 +1,235 @@
from typing import TypedDict
import torch
import torchaudio
class AudioDict(TypedDict):
"""Comfy's representation of AUDIO data."""
sample_rate: int
waveform: torch.Tensor
AudioData = AudioDict | list[AudioDict]
class MtbAudio:
"""Base class for audio processing."""
@classmethod
def is_stereo(
cls,
audios: AudioData,
) -> bool:
if isinstance(audios, list):
return any(cls.is_stereo(audio) for audio in audios)
else:
return audios["waveform"].shape[1] == 2
@staticmethod
def resample(audio: AudioDict, common_sample_rate: int) -> AudioDict:
if audio["sample_rate"] != common_sample_rate:
resampler = torchaudio.transforms.Resample(
orig_freq=audio["sample_rate"], new_freq=common_sample_rate
)
return {
"sample_rate": common_sample_rate,
"waveform": resampler(audio["waveform"]),
}
else:
return audio
@staticmethod
def to_stereo(audio: AudioDict) -> AudioDict:
if audio["waveform"].shape[1] == 1:
return {
"sample_rate": audio["sample_rate"],
"waveform": torch.cat(
[audio["waveform"], audio["waveform"]], dim=1
),
}
else:
return audio
@classmethod
def preprocess_audios(
cls, audios: list[AudioDict]
) -> tuple[list[AudioDict], bool, int]:
max_sample_rate = max([audio["sample_rate"] for audio in audios])
resampled_audios = [
cls.resample(audio, max_sample_rate) for audio in audios
]
is_stereo = cls.is_stereo(audios)
if is_stereo:
audios = [cls.to_stereo(audio) for audio in resampled_audios]
return (audios, is_stereo, max_sample_rate)
class MTB_AudioCut(MtbAudio):
"""Basic audio cutter, values are in ms."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"audio": ("AUDIO",),
"length": (
("FLOAT"),
{
"default": 1000.0,
"min": 0.0,
"max": 999999.0,
"step": 1,
},
),
"offset": (
("FLOAT"),
{"default": 0.0, "min": 0.0, "max": 999999.0, "step": 1},
),
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("cut_audio",)
CATEGORY = "mtb/audio"
FUNCTION = "cut"
def cut(self, audio: AudioDict, length: float, offset: float):
sample_rate = audio["sample_rate"]
start_idx = int(offset * sample_rate / 1000)
end_idx = min(
start_idx + int(length * sample_rate / 1000),
audio["waveform"].shape[-1],
)
cut_waveform = audio["waveform"][:, :, start_idx:end_idx]
return (
{
"sample_rate": sample_rate,
"waveform": cut_waveform,
},
)
class MTB_AudioStack(MtbAudio):
"""Stack/Overlay audio inputs (dynamic inputs).
- pad audios to the longest inputs.
- resample audios to the highest sample rate in the inputs.
- convert them all to stereo if one of the inputs is.
"""
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("stacked_audio",)
CATEGORY = "mtb/audio"
FUNCTION = "stack"
def stack(self, **kwargs: AudioDict) -> tuple[AudioDict]:
audios, is_stereo, max_rate = self.preprocess_audios(
list(kwargs.values())
)
max_length = max([audio["waveform"].shape[-1] for audio in audios])
padded_audios: list[torch.Tensor] = []
for audio in audios:
padding = torch.zeros(
(
1,
2 if is_stereo else 1,
max_length - audio["waveform"].shape[-1],
)
)
padded_audio = torch.cat([audio["waveform"], padding], dim=-1)
padded_audios.append(padded_audio)
stacked_waveform = torch.stack(padded_audios, dim=0).sum(dim=0)
return (
{
"sample_rate": max_rate,
"waveform": stacked_waveform,
},
)
class MTB_AudioSequence(MtbAudio):
"""Sequence audio inputs (dynamic inputs).
- adding silence_duration between each segment
can now also be negative to overlap the clips, safely bound
to the the input length.
- resample audios to the highest sample rate in the inputs.
- convert them all to stereo if one of the inputs is.
"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"silence_duration": (
("FLOAT"),
{"default": 0.0, "min": -999.0, "max": 999, "step": 0.01},
)
},
}
RETURN_TYPES = ("AUDIO",)
RETURN_NAMES = ("sequenced_audio",)
CATEGORY = "mtb/audio"
FUNCTION = "sequence"
def sequence(self, silence_duration: float, **kwargs: AudioDict):
audios, is_stereo, max_rate = self.preprocess_audios(
list(kwargs.values())
)
sequence: list[torch.Tensor] = []
for i, audio in enumerate(audios):
if i > 0:
if silence_duration > 0:
silence = torch.zeros(
(
1,
2 if is_stereo else 1,
int(silence_duration * max_rate),
)
)
sequence.append(silence)
elif silence_duration < 0:
overlap = int(abs(silence_duration) * max_rate)
previous_audio = sequence[-1]
overlap = min(
overlap,
previous_audio.shape[-1],
audio["waveform"].shape[-1],
)
if overlap > 0:
overlap_part = (
previous_audio[:, :, -overlap:]
+ audio["waveform"][:, :, :overlap]
)
sequence[-1] = previous_audio[:, :, :-overlap]
sequence.append(overlap_part)
audio["waveform"] = audio["waveform"][:, :, overlap:]
sequence.append(audio["waveform"])
sequenced_waveform = torch.cat(sequence, dim=-1)
return (
{
"sample_rate": max_rate,
"waveform": sequenced_waveform,
},
)
__nodes__ = [MTB_AudioSequence, MTB_AudioStack, MTB_AudioCut]
+124 -2
View File
@@ -3,10 +3,127 @@ import shutil
from pathlib import Path
import folder_paths
import torch
from ..log import log
from ..utils import here
Conditioning = list[tuple[torch.Tensor, dict[str, torch.Tensor]]]
def check_condition(conditioning: Conditioning):
has_cn = False
if len(conditioning) > 1:
log.warn(
"More than one conditioning was provided. Only the first one will be used."
)
first = conditioning[0]
cond, kwargs = first
log.debug("Conditioning Shape")
log.debug(cond.shape)
log.debug("Conditioning keys")
log.debug([f"\t{k} - {type(kwargs[k])}" for k in kwargs])
if "control" in kwargs:
log.debug("Conditioning contains a controlnet")
has_cn = True
if "pooled_output" not in kwargs:
raise ValueError(
"Conditioning is not valid. Missing 'pooled_output' key."
)
return has_cn
class MTB_InterpolateCondition:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"blend": (
"FLOAT",
{"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01},
),
},
}
RETURN_TYPES = ("CONDITIONING",)
CATEGORY = "mtb/conditioning"
FUNCTION = "execute"
def execute(
self, blend: float, **kwargs: Conditioning
) -> tuple[Conditioning]:
blend = max(0.0, min(1.0, blend))
conditions: list[Conditioning] = list(kwargs.values())
num_conditions = len(conditions)
if num_conditions < 2:
raise ValueError("At least two conditioning inputs are required.")
segment_length = 1.0 / (num_conditions - 1)
segment_index = min(int(blend // segment_length), num_conditions - 2)
local_blend = (
blend - (segment_index * segment_length)
) / segment_length
cond_from = conditions[segment_index]
cond_to = conditions[segment_index + 1]
from_cn = check_condition(cond_from)
to_cn = check_condition(cond_to)
if from_cn and to_cn:
raise ValueError(
"Interpolating conditions cannot both contain ControlNets"
)
try:
interpolated_condition = [
(1.0 - local_blend) * c_from + local_blend * c_to
for c_from, c_to in zip(
cond_from[0][0], cond_to[0][0], strict=False
)
]
except Exception as e:
print(f"Error during interpolation: {e}")
raise
pooled_from = cond_from[0][1].get(
"pooled_output",
torch.zeros_like(
next(iter(cond_from[0][1].values()), torch.tensor([]))
),
)
pooled_to = cond_to[0][1].get(
"pooled_output",
torch.zeros_like(
next(iter(cond_from[0][1].values()), torch.tensor([]))
),
)
interpolated_pooled = (
1.0 - local_blend
) * pooled_from + local_blend * pooled_to
res = {"pooled_output": interpolated_pooled}
if from_cn:
res["control"] = cond_from[0][1]["control"]
res["control_apply_to_uncond"] = cond_from[0][1][
"control_apply_to_uncond"
]
if to_cn:
res["control"] = cond_to[0][1]["control"]
res["control_apply_to_uncond"] = cond_to[0][1][
"control_apply_to_uncond"
]
return ([(torch.stack(interpolated_condition), res)],)
class MTB_InterpolateClipSequential:
@classmethod
@@ -177,7 +294,7 @@ class MTB_StylesLoader:
with open(file, encoding="utf8") as f:
parsed = csv.reader(f)
for i, row in enumerate(parsed):
log.debug(f"Adding style {row[0]}")
# log.debug(f"Adding style {row[0]}")
try:
name, positive, negative = (row + [None] * 3)[:3]
positive = positive or ""
@@ -213,4 +330,9 @@ class MTB_StylesLoader:
return (self.options[style_name][0], self.options[style_name][1])
__nodes__ = [MTB_SmartStep, MTB_StylesLoader, MTB_InterpolateClipSequential]
__nodes__ = [
MTB_SmartStep,
MTB_StylesLoader,
MTB_InterpolateClipSequential,
MTB_InterpolateCondition,
]
+1 -1
View File
@@ -24,4 +24,4 @@ class MTB_Constant:
return (kwargs.get("Value"),)
__nodes__ = [MTB_Constant]
# __nodes__ = [MTB_Constant]
+56 -1
View File
@@ -41,6 +41,54 @@ class MTB_Bbox:
return ((x, y, width, height),)
class MTB_SplitBbox:
"""Split the components of a bbox"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {"bbox": ("BBOX",)},
}
CATEGORY = "mtb/crop"
FUNCTION = "split_bbox"
RETURN_TYPES = ("INT", "INT", "INT", "INT")
RETURN_NAMES = ("x", "y", "width", "height")
def split_bbox(self, bbox):
return (bbox[0], bbox[1], bbox[2], bbox[3])
class MTB_UpscaleBboxBy:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"bbox": ("BBOX",),
"scale": ("FLOAT", {"default": 1.0}),
},
}
CATEGORY = "mtb/crop"
RETURN_TYPES = ("BBOX",)
FUNCTION = "upscale"
def upscale(
self, bbox: tuple[int, int, int, int], scale: float
) -> tuple[tuple[int, int, int, int]]:
x, y, width, height = bbox
# scaled = (x * scale, y * scale, width * scale, height * scale)
scaled = (
int(x * scale),
int(y * scale),
int(width * scale),
int(height * scale),
)
return (scaled,)
class MTB_BboxFromMask:
"""From a mask extract the bounding box"""
@@ -324,4 +372,11 @@ class MTB_Uncrop:
return (pil2tensor(out_images),)
__nodes__ = [MTB_BboxFromMask, MTB_Bbox, MTB_Crop, MTB_Uncrop]
__nodes__ = [
MTB_BboxFromMask,
MTB_Bbox,
MTB_Crop,
MTB_Uncrop,
MTB_SplitBbox,
MTB_UpscaleBboxBy,
]
+20 -1
View File
@@ -69,7 +69,26 @@ def color_to_normals(
if not model or not model.exists():
raise ModelNotFound(f"deepbump ({model})")
ort_session = ort.InferenceSession(model)
providers = [
"TensorrtExecutionProvider",
"CUDAExecutionProvider",
"CoreMLProvider",
"CPUExecutionProvider",
]
available_providers = [
provider
for provider in providers
if provider in ort.get_available_providers()
]
if not available_providers:
raise RuntimeError(
"No valid ONNX Runtime providers available on this machine."
)
log.debug(f"Using ONNX providers: {available_providers}")
ort_session = ort.InferenceSession(
model.as_posix(), providers=available_providers
)
# Predict normal map for each tile
log.debug("DeepBump Color → Normals : generating")
+2
View File
@@ -78,6 +78,7 @@ class MTB_LoadFaceEnhanceModel:
RETURN_NAMES = ("model",)
FUNCTION = "load_model"
CATEGORY = "mtb/facetools"
DEPRECATED = True
def load_model(self, model_name, upscale=2, bg_upsampler=None):
from gfpgan import GFPGANer
@@ -163,6 +164,7 @@ class MTB_RestoreFace:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "restore"
CATEGORY = "mtb/facetools"
DEPRECATED = True
@classmethod
def INPUT_TYPES(cls):
+3
View File
@@ -40,6 +40,7 @@ class MTB_LoadFaceAnalysisModel:
RETURN_TYPES = ("FACE_ANALYSIS_MODEL",)
FUNCTION = "load_model"
CATEGORY = "mtb/facetools"
DEPRECATED = True
def load_model(self, faceswap_model: str):
if faceswap_model == "antelopev2":
@@ -77,6 +78,7 @@ class MTB_LoadFaceSwapModel:
RETURN_TYPES = ("FACESWAP_MODEL",)
FUNCTION = "load_model"
CATEGORY = "mtb/facetools"
DEPRECATED = True
def load_model(self, faceswap_model: str):
model_path = get_model_path("insightface", faceswap_model)
@@ -126,6 +128,7 @@ class MTB_FaceSwap:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "swap"
CATEGORY = "mtb/facetools"
DEPRECATED = True
def swap(
self,
+1 -73
View File
@@ -1,4 +1,3 @@
import qrcode
from PIL import Image
from ..log import log
@@ -113,76 +112,6 @@ class MTB_UnsplashImage:
return (None,)
class MTB_QrCode:
"""Basic QR Code generator"""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"url": ("STRING", {"default": "https://www.github.com"}),
"width": (
"INT",
{"default": 256, "max": 8096, "min": 0, "step": 1},
),
"height": (
"INT",
{"default": 256, "max": 8096, "min": 0, "step": 1},
),
"error_correct": (("L", "M", "Q", "H"), {"default": "L"}),
"box_size": (
"INT",
{"default": 10, "max": 8096, "min": 0, "step": 1},
),
"border": (
"INT",
{"default": 4, "max": 8096, "min": 0, "step": 1},
),
"invert": (("BOOLEAN",), {"default": False}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "do_qr"
CATEGORY = "mtb/generate"
def do_qr(
self, url, width, height, error_correct, box_size, border, invert
):
log.warning(
"This node will soon be deprecated, there are much better alternatives like https://github.com/coreyryanhanson/comfy-qr"
)
if error_correct == "L" or error_correct not in ["M", "Q", "H"]:
error_correct = qrcode.constants.ERROR_CORRECT_L
elif error_correct == "M":
error_correct = qrcode.constants.ERROR_CORRECT_M
elif error_correct == "Q":
error_correct = qrcode.constants.ERROR_CORRECT_Q
else:
error_correct = qrcode.constants.ERROR_CORRECT_H
qr = qrcode.QRCode(
version=1,
error_correction=error_correct,
box_size=box_size,
border=border,
)
qr.add_data(url)
qr.make(fit=True)
back_color = (255, 255, 255) if invert else (0, 0, 0)
fill_color = (0, 0, 0) if invert else (255, 255, 255)
code = img = qr.make_image(
back_color=back_color, fill_color=fill_color
)
# that we now resize without filtering
code = code.resize((width, height), Image.NEAREST)
return (pil2tensor(code),)
def bbox_dim(bbox):
left, upper, right, lower = bbox
width = right - left
@@ -202,7 +131,7 @@ class MTB_TextToImage:
fonts = {}
DESCRIPTION = """# Text to Image
This node look for any font files in comfy_dir/fonts.
This node look for any font files in comfy_dir/fonts.
by default it fallsback to a default font.
![img](https://i.imgur.com/3GT92hy.gif)
@@ -364,7 +293,6 @@ by default it fallsback to a default font.
__nodes__ = [
MTB_QrCode,
MTB_UnsplashImage,
MTB_TextToImage,
# MtbExamples,
+8 -13
View File
@@ -417,7 +417,7 @@ class MTB_AnyToString:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {"input": ("*")},
"required": {"input": ("*",)},
}
RETURN_TYPES = ("STRING",)
@@ -491,14 +491,14 @@ class MTB_MathExpression:
RETURN_NAMES = ("result (float)", "result (int)")
CATEGORY = "mtb/math"
DESCRIPTION = (
"evaluate a simple math expression string (!! Fallsback to eval)"
"evaluate a simple math expression string, only supports literal_eval"
)
def eval_expression(self, expression, **kwargs):
def eval_expression(self, expression: str, **kwargs):
from ast import literal_eval
for key, value in kwargs.items():
print(f"Replacing placeholder <{key}> with value {value}")
log.debug(f"Replacing placeholder <{key}> with value {value}")
expression = expression.replace(f"<{key}>", str(value))
result = -1
@@ -509,15 +509,10 @@ class MTB_MathExpression:
f"The expression syntax is wrong '{expression}': {e}"
) from e
except ValueError:
try:
expression = expression.replace("^", "**")
result = eval(expression)
except Exception as e:
# Handle any other exceptions and provide a meaningful error message
raise ValueError(
f"Error evaluating expression '{expression}': {e}"
) from e
except Exception as e:
raise ValueError(
f"Math expression only support literal_eval now: {e}"
)
return (result, int(result))
+11 -4
View File
@@ -1,5 +1,4 @@
from pathlib import Path
from typing import List
import comfy
import comfy.model_management as model_management
@@ -15,10 +14,13 @@ from ..utils import get_model_path
class MTB_LoadFilmModel:
"""Loads a FILM model"""
"""Loads a FILM model
[DEPRECATED] Use ComfyUI-FrameInterpolation instead
"""
@staticmethod
def get_models() -> List[Path]:
def get_models() -> list[Path]:
models_paths = get_model_path("FILM").iterdir()
return [x for x in models_paths if x.suffix in [".onnx", ".pth"]]
@@ -37,6 +39,7 @@ class MTB_LoadFilmModel:
RETURN_TYPES = ("FILM_MODEL",)
FUNCTION = "load_model"
CATEGORY = "mtb/frame iterpolation"
DEPRECATED = True
def load_model(self, film_model: str):
model_path = get_model_path("FILM", film_model)
@@ -56,7 +59,10 @@ class MTB_LoadFilmModel:
class MTB_FilmInterpolation:
"""Google Research FILM frame interpolation for large motion"""
"""Google Research FILM frame interpolation for large motion
[DEPRECATED] Use ComfyUI-FrameInterpolation instead
"""
@classmethod
def INPUT_TYPES(cls):
@@ -71,6 +77,7 @@ class MTB_FilmInterpolation:
RETURN_TYPES = ("IMAGE",)
FUNCTION = "do_interpolation"
CATEGORY = "mtb/frame iterpolation"
DEPRECATED = True
def do_interpolation(
self,
+444 -87
View File
@@ -3,6 +3,7 @@ import json
import math
import os
import comfy.model_management as model_management
import folder_paths
import numpy as np
import torch
@@ -13,7 +14,7 @@ from skimage.filters import gaussian
from skimage.util import compare_images
from ..log import log
from ..utils import np2tensor, pil2tensor, tensor2np, tensor2pil
from ..utils import np2tensor, pil2tensor, tensor2pil
# try:
# from cv2.ximgproc import guidedFilter
@@ -35,6 +36,343 @@ def gaussian_kernel(
return g / g.sum()
class MTB_CoordinatesToString:
RETURN_TYPES = ("STRING",)
FUNCTION = "convert"
CATEGORY = "mtb/coordinates"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"coordinates": ("BATCH_COORDINATES",),
"frame": ("INT",),
}
}
def convert(
self, coordinates: list[list[tuple[int, int]]], frame: int
) -> tuple[str]:
frame = max(frame, len(coordinates) - 1)
coords = coordinates[frame]
output: list[dict[str, int]] = []
for x, y in coords:
output.append({"x": x, "y": y})
return (json.dumps(output),)
class MTB_ExtractCoordinatesFromImage:
"""Extract 2D points from a batch of images based on a threshold."""
RETURN_TYPES = ("BATCH_COORDINATES", "IMAGE")
FUNCTION = "extract"
CATEGORY = "mtb/coordinates"
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"threshold": ("FLOAT",),
"max_points": ("INT", {"default": 50, "min": 0}),
},
"optional": {"image": ("IMAGE",), "mask": ("MASK",)},
}
def extract(
self,
threshold: float,
max_points: int,
image: torch.Tensor | None = None,
mask: torch.Tensor | None = None,
) -> tuple[list[list[tuple[int, int]]], torch.Tensor]:
if image is not None:
batch_count, height, width, channel_count = image.shape
imgs = image
else:
if mask is None:
raise ValueError("Must provide either image or mask")
batch_count, height, width = mask.shape
channel_count = 1
imgs = mask
if channel_count not in [1, 2, 3, 4]:
raise ValueError(f"Incorrect channel count: {channel_count}")
all_points: list[list[tuple[int, int]]] = []
debug_images = torch.zeros(
(batch_count, height, width, 3),
dtype=torch.uint8,
device=imgs.device,
)
for i, img in enumerate(imgs):
if channel_count == 1:
alpha_channel = img if len(img.shape) == 2 else img[:, :, 0]
elif channel_count == 2:
alpha_channel = img[:, :, 1]
elif channel_count == 4:
alpha_channel = img[:, :, 3]
else:
# get intensity
alpha_channel = img[:, :, :3].max(dim=2)[0]
points = (alpha_channel > threshold).nonzero(as_tuple=False)
if len(points) > max_points:
indices = torch.randperm(points.size(0), device=img.device)[
:max_points
]
points = points[indices]
points = [(int(y.item()), int(x.item())) for x, y in points]
all_points.append(points)
for x, y in points:
self._draw_circle(debug_images[i], (x, y), 5)
return (all_points, debug_images)
@staticmethod
def _draw_circle(
image: torch.Tensor, center: tuple[int, int], radius: int
):
"""Draw a 5px circle on the image."""
x0, y0 = center
for x in range(-radius, radius + 1):
for y in range(-radius, radius + 1):
in_radius = x**2 + y**2 <= radius**2
in_bounds = (
0 <= x0 + x < image.shape[1]
and 0 <= y0 + y < image.shape[0]
)
if in_radius and in_bounds:
image[y0 + y, x0 + x] = torch.tensor(
[255, 255, 255],
dtype=torch.uint8,
device=image.device,
)
class MTB_ColorCorrectGPU:
"""Various color correction methods using only Torch."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"image": ("IMAGE",),
"force_gpu": ("BOOLEAN", {"default": True}),
"clamp": ([True, False], {"default": True}),
"gamma": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.01},
),
"contrast": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.01},
),
"exposure": (
"FLOAT",
{"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01},
),
"offset": (
"FLOAT",
{"default": 0.0, "min": -5.0, "max": 5.0, "step": 0.01},
),
"hue": (
"FLOAT",
{"default": 0.0, "min": -0.5, "max": 0.5, "step": 0.01},
),
"saturation": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.01},
),
"value": (
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.01},
),
},
"optional": {"mask": ("MASK",)},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "correct"
CATEGORY = "mtb/image processing"
@staticmethod
def get_device(tensor: torch.Tensor, force_gpu: bool):
if force_gpu:
if torch.cuda.is_available():
return torch.device("cuda")
elif (
hasattr(torch.backends, "mps")
and torch.backends.mps.is_available()
):
return torch.device("mps")
elif hasattr(torch, "hip") and torch.hip.is_available():
return torch.device("hip")
return (
tensor.device
) # model_management.get_torch_device() # torch.device("cpu")
@staticmethod
def rgb_to_hsv(image: torch.Tensor):
r, g, b = image.unbind(-1)
max_rgb, argmax_rgb = image.max(-1)
min_rgb, _ = image.min(-1)
diff = max_rgb - min_rgb
h = torch.empty_like(max_rgb)
s = diff / (max_rgb + 1e-7)
v = max_rgb
h[argmax_rgb == 0] = (g - b)[argmax_rgb == 0] / (diff + 1e-7)[
argmax_rgb == 0
]
h[argmax_rgb == 1] = (
2.0 + (b - r)[argmax_rgb == 1] / (diff + 1e-7)[argmax_rgb == 1]
)
h[argmax_rgb == 2] = (
4.0 + (r - g)[argmax_rgb == 2] / (diff + 1e-7)[argmax_rgb == 2]
)
h = (h / 6.0) % 1.0
h = h.unsqueeze(-1)
s = s.unsqueeze(-1)
v = v.unsqueeze(-1)
return torch.cat((h, s, v), dim=-1)
@staticmethod
def hsv_to_rgb(hsv: torch.Tensor):
h, s, v = hsv.unbind(-1)
h = h * 6.0
i = torch.floor(h)
f = h - i
p = v * (1.0 - s)
q = v * (1.0 - s * f)
t = v * (1.0 - s * (1.0 - f))
i = i.long() % 6
mask = torch.stack(
(i == 0, i == 1, i == 2, i == 3, i == 4, i == 5), -1
)
rgb = torch.stack(
(
torch.where(
mask[..., 0],
v,
torch.where(
mask[..., 1],
q,
torch.where(
mask[..., 2],
p,
torch.where(
mask[..., 3],
p,
torch.where(mask[..., 4], t, v),
),
),
),
),
torch.where(
mask[..., 0],
t,
torch.where(
mask[..., 1],
v,
torch.where(
mask[..., 2],
v,
torch.where(
mask[..., 3],
q,
torch.where(mask[..., 4], p, p),
),
),
),
),
torch.where(
mask[..., 0],
p,
torch.where(
mask[..., 1],
p,
torch.where(
mask[..., 2],
t,
torch.where(
mask[..., 3],
v,
torch.where(mask[..., 4], v, q),
),
),
),
),
),
dim=-1,
)
return rgb
def correct(
self,
image: torch.Tensor,
force_gpu: bool,
clamp: bool,
gamma: float = 1.0,
contrast: float = 1.0,
exposure: float = 0.0,
offset: float = 0.0,
hue: float = 0.0,
saturation: float = 1.0,
value: float = 1.0,
mask: torch.Tensor | None = None,
):
device = self.get_device(image, force_gpu)
image = image.to(device)
if mask is not None:
if mask.shape[0] != image.shape[0]:
mask = mask.expand(image.shape[0], -1, -1)
mask = mask.unsqueeze(-1).expand(-1, -1, -1, 3)
mask = mask.to(device)
model_management.throw_exception_if_processing_interrupted()
adjusted = image.pow(1 / gamma) * (2.0**exposure) * contrast + offset
model_management.throw_exception_if_processing_interrupted()
hsv = self.rgb_to_hsv(adjusted)
hsv[..., 0] = (hsv[..., 0] + hue) % 1.0 # Hue
hsv[..., 1] = hsv[..., 1] * saturation # Saturation
hsv[..., 2] = hsv[..., 2] * value # Value
adjusted = self.hsv_to_rgb(hsv)
model_management.throw_exception_if_processing_interrupted()
if clamp:
adjusted = torch.clamp(adjusted, 0.0, 1.0)
# apply mask
result = (
adjusted
if mask is None
else torch.where(mask > 0, adjusted, image)
)
if not force_gpu:
result = result.cpu()
return (result,)
class MTB_ColorCorrect:
"""Various color correction methods"""
@@ -72,7 +410,8 @@ class MTB_ColorCorrect:
"FLOAT",
{"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.01},
),
}
},
"optional": {"mask": ("MASK",)},
}
RETURN_TYPES = ("IMAGE",)
@@ -87,13 +426,13 @@ class MTB_ColorCorrect:
@staticmethod
def contrast_adjustment_tensor(image, contrast):
r, g, b = image.unbind(-1)
# Using Adobe RGB luminance weights.
luminance_image = 0.33 * r + 0.71 * g + 0.06 * b
luminance_mean = torch.mean(luminance_image.unsqueeze(-1))
# Blend original with mean luminance using contrast factor as blend ratio.
contrasted = image * contrast + (1.0 - contrast) * luminance_mean
contrasted = image * contrast + (1.0 - contrast) * luminance_mean
return torch.clamp(contrasted, 0.0, 1.0)
@staticmethod
@@ -188,18 +527,31 @@ class MTB_ColorCorrect:
hue: float = 0.0,
saturation: float = 1.0,
value: float = 1.0,
mask: torch.Tensor | None = None,
):
if mask is not None:
if mask.shape[0] != image.shape[0]:
mask = mask.expand(image.shape[0], -1, -1)
mask = mask.unsqueeze(-1).expand(-1, -1, -1, 3)
# Apply color correction operations
image = self.gamma_correction_tensor(image, gamma)
image = self.contrast_adjustment_tensor(image, contrast)
image = self.exposure_adjustment_tensor(image, exposure)
image = self.offset_adjustment_tensor(image, offset)
image = self.hsv_adjustment(image, hue, saturation, value)
adjusted = self.gamma_correction_tensor(image, gamma)
adjusted = self.contrast_adjustment_tensor(adjusted, contrast)
adjusted = self.exposure_adjustment_tensor(adjusted, exposure)
adjusted = self.offset_adjustment_tensor(adjusted, offset)
adjusted = self.hsv_adjustment(adjusted, hue, saturation, value)
if clamp:
image = torch.clamp(image, 0.0, 1.0)
adjusted = torch.clamp(image, 0.0, 1.0)
return (image,)
result = (
adjusted
if mask is None
else torch.where(mask > 0, adjusted, image)
)
return (result,)
class MTB_ImageCompare:
@@ -475,7 +827,10 @@ class MTB_MaskToImage:
"mask": ("MASK",),
"color": ("COLOR",),
"background": ("COLOR", {"default": "#000000"}),
}
},
"optional": {
"invert": ("BOOLEAN", {"default": False}),
},
}
CATEGORY = "mtb/generate"
@@ -484,11 +839,12 @@ class MTB_MaskToImage:
FUNCTION = "render_mask"
def render_mask(self, mask, color, background):
masks = tensor2np(mask)[0]
def render_mask(self, mask, color, background, invert=False):
masks = tensor2pil(1.0 - mask) if invert else tensor2pil(mask)
images = []
for m in masks:
_mask = Image.fromarray(m).convert("L")
_mask = m.convert("L")
log.debug(
f"Converted mask to PIL Image format, size: {_mask.size}"
@@ -526,6 +882,11 @@ class MTB_ColoredImage:
"optional": {
"foreground_image": ("IMAGE",),
"foreground_mask": ("MASK",),
"invert": ("BOOLEAN", {"default": False}),
"mask_opacity": (
"FLOAT",
{"default": 1.0, "step": 0.1, "min": 0},
),
},
}
@@ -535,28 +896,19 @@ class MTB_ColoredImage:
FUNCTION = "render_img"
def resize_and_crop(self, img, target_size):
# Calculate scaling factors for both dimensions
scale_x = target_size[0] / img.width
scale_y = target_size[1] / img.height
# Use the smaller scaling factor to maintain aspect ratio
scale = max(scale_x, scale_y)
# Resize the image based on calculated scale
def resize_and_crop(self, img: Image.Image, target_size: tuple[int, int]):
scale = max(target_size[0] / img.width, target_size[1] / img.height)
new_size = (int(img.width * scale), int(img.height * scale))
img = img.resize(new_size, Image.LANCZOS)
left = (img.width - target_size[0]) // 2
top = (img.height - target_size[1]) // 2
return img.crop(
(left, top, left + target_size[0], top + target_size[1])
)
# Calculate cropping coordinates
left = (img.width - target_size[0]) / 2
top = (img.height - target_size[1]) / 2
right = (img.width + target_size[0]) / 2
bottom = (img.height + target_size[1]) / 2
# Crop and return the image
return img.crop((left, top, right, bottom))
def resize_and_crop_thumbnails(self, img, target_size):
def resize_and_crop_thumbnails(
self, img: Image.Image, target_size: tuple[int, int]
):
img.thumbnail(target_size, Image.LANCZOS)
left = (img.width - target_size[0]) / 2
top = (img.height - target_size[1]) / 2
@@ -564,69 +916,71 @@ class MTB_ColoredImage:
bottom = (img.height + target_size[1]) / 2
return img.crop((left, top, right, bottom))
@staticmethod
def process_mask(
mask: torch.Tensor | None,
invert: bool,
# opacity: float,
batch_size: int,
) -> list[Image.Image] | None:
if mask is None:
return [None] * batch_size
masks = tensor2pil(mask if not invert else 1.0 - mask)
if len(masks) == 1 and batch_size > 1:
masks = masks * batch_size
if len(masks) != batch_size:
raise ValueError(
"Foreground image and mask must have the same batch size"
)
return masks
def render_img(
self,
color,
width,
height,
color: str,
width: int,
height: int,
foreground_image: torch.Tensor | None = None,
foreground_mask: torch.Tensor | None = None,
):
image = Image.new("RGBA", (width, height), color=color)
output = []
if foreground_image is not None:
fg_masks = [None] * foreground_image.size()[0]
invert: bool = False,
mask_opacity: float = 1.0,
) -> tuple[torch.Tensor]:
background = Image.new("RGBA", (width, height), color=color)
if foreground_mask is not None:
fg_size = foreground_image.size()[0]
mask_size = foreground_mask.size()[0]
if foreground_image is None:
return (pil2tensor([background.convert("RGB")]),)
if fg_size == 1 and mask_size > fg_size:
foreground_image = foreground_image.repeat(
mask_size, 1, 1, 1
)
fg_images = tensor2pil(foreground_image)
fg_masks = self.process_mask(foreground_mask, invert, len(fg_images))
if foreground_image.size()[0] != foreground_mask.size()[0]:
output: list[Image.Image] = []
for fg_image, fg_mask in zip(fg_images, fg_masks, strict=False):
fg_image = self.resize_and_crop(fg_image, background.size)
if fg_mask:
fg_mask = self.resize_and_crop(fg_mask, background.size)
fg_mask_array = np.array(fg_mask)
fg_mask_array = (fg_mask_array * mask_opacity).astype(np.uint8)
fg_mask = Image.fromarray(fg_mask_array)
output.append(
Image.composite(
fg_image.convert("RGBA"), background, fg_mask
).convert("RGB")
)
else:
if fg_image.mode != "RGBA":
raise ValueError(
"Foreground image and mask must have same batch size"
f"Foreground image must be in 'RGBA' mode when no mask is provided, got {fg_image.mode}"
)
fg_masks = tensor2pil(foreground_mask.unsqueeze(-1))
output.append(
Image.alpha_composite(background, fg_image).convert("RGB")
)
fg_images = tensor2pil(foreground_image)
for fg_image, fg_mask in zip(fg_images, fg_masks):
# Resize and crop if dimensions mismatch
if fg_image.size != image.size:
fg_image = self.resize_and_crop(fg_image, image.size)
if fg_mask:
fg_mask = self.resize_and_crop(fg_mask, image.size)
if fg_mask:
output.append(
Image.composite(
fg_image.convert("RGBA"),
image,
fg_mask,
).convert("RGB")
)
else:
if fg_image.mode != "RGBA":
raise ValueError(
"Foreground image must be in 'RGBA' mode "
f"when no mask is provided, got {fg_image.mode}"
)
output.append(
Image.alpha_composite(image, fg_image).convert("RGB")
)
else:
if foreground_mask is not None:
log.warn("Mask ignored because no foreground image is given")
output.append(image.convert("RGB"))
output = pil2tensor(output)
return (output,)
return (pil2tensor(output),)
class MTB_ImagePremultiply:
@@ -924,6 +1278,7 @@ class MTB_ImageTileOffset:
__nodes__ = [
MTB_ColorCorrect,
MTB_ColorCorrectGPU,
MTB_ImageCompare,
MTB_ImageTileOffset,
MTB_Blur,
@@ -935,4 +1290,6 @@ __nodes__ = [
MTB_SaveImageGrid,
MTB_LoadImageFromUrl,
MTB_Sharpen,
MTB_ExtractCoordinatesFromImage,
MTB_CoordinatesToString,
]
+55 -19
View File
@@ -2,9 +2,9 @@ import json
import subprocess
import uuid
from pathlib import Path
from typing import List, Optional
import comfy.model_management as model_management
import comfy.utils
import folder_paths
import numpy as np
import torch
@@ -41,6 +41,7 @@ class MTB_ReadPlaylist:
RETURN_TYPES = ("PLAYLIST",)
FUNCTION = "read_playlist"
CATEGORY = "mtb/IO"
EXPERIMENTAL = True
def read_playlist(
self,
@@ -83,6 +84,7 @@ class MTB_AddToPlaylist:
OUTPUT_NODE = True
FUNCTION = "add_to_playlist"
CATEGORY = "mtb/IO"
EXPERIMENTAL = True
def add_to_playlist(
self,
@@ -117,7 +119,10 @@ class MTB_AddToPlaylist:
class MTB_ExportWithFfmpeg:
"""Export with FFmpeg (Experimental)"""
"""Export with FFmpeg (Experimental).
[DEPRACATED] Use VHS nodes instead
"""
@classmethod
def INPUT_TYPES(cls):
@@ -143,6 +148,7 @@ class MTB_ExportWithFfmpeg:
RETURN_TYPES = ("VIDEO",)
OUTPUT_NODE = True
FUNCTION = "export_prores"
DEPRECATED = True
CATEGORY = "mtb/IO"
def export_prores(
@@ -151,10 +157,9 @@ class MTB_ExportWithFfmpeg:
prefix: str,
format: str,
codec: str,
images: Optional[torch.Tensor] = None,
playlist: Optional[List[str]] = None,
images: torch.Tensor | None = None,
playlist: list[str] | None = None,
):
pix_fmt = "rgb48le" if codec == "prores_ks" else "yuv420p"
file_ext = format
file_id = f"{prefix}_{uuid.uuid4()}.{file_ext}"
@@ -208,9 +213,11 @@ class MTB_ExportWithFfmpeg:
frames = tensor2np(images)
log.debug(f"Frames type {type(frames[0])}")
log.debug(f"Exporting {len(frames)} frames")
height, width, channels = frames[0].shape
has_alpha = channels == 4
out_path = (output_dir / file_id).as_posix()
if codec == "gif":
out_path = (output_dir / file_id).as_posix()
command = [
"ffmpeg",
"-f",
@@ -233,12 +240,28 @@ class MTB_ExportWithFfmpeg:
process.stdin.close()
process.wait()
return (out_path,)
else:
frames = [frame.astype(np.uint16) * 257 for frame in frames]
height, width, _ = frames[0].shape
out_path = (output_dir / file_id).as_posix()
if has_alpha:
if codec in ["prores_ks", "libx264", "libx265"]:
pix_fmt = (
"yuva444p" if codec == "prores_ks" else "yuva420p"
)
frames = [
frame.astype(np.uint16) * 257 for frame in frames
]
else:
log.warning(
f"Alpha channel not supported for codec {codec}. Alpha will be ignored."
)
frames = [
frame[:, :, :3].astype(np.uint16) * 257
for frame in frames
]
pix_fmt = "rgb48le" if codec == "prores_ks" else "yuv420p"
else:
pix_fmt = "rgb48le" if codec == "prores_ks" else "yuv420p"
frames = [frame.astype(np.uint16) * 257 for frame in frames]
# Prepare the FFmpeg command
command = [
@@ -258,17 +281,26 @@ class MTB_ExportWithFfmpeg:
"-",
"-c:v",
codec,
"-r",
str(fps),
"-y",
out_path,
]
if codec == "prores_ks":
command.extend(["-profile:v", "4444"])
command.extend(
[
"-r",
str(fps),
"-y",
out_path,
]
)
process = subprocess.Popen(command, stdin=subprocess.PIPE)
pbar = comfy.utils.ProgressBar(len(frames))
for frame in frames:
model_management.throw_exception_if_processing_interrupted()
process.stdin.write(frame.tobytes())
pbar.update(1)
process.stdin.close()
process.wait()
@@ -280,9 +312,9 @@ def prepare_animated_batch(
batch: torch.Tensor,
pingpong=False,
resize_by=1.0,
resample_filter: Optional[Image.Resampling] = None,
resample_filter: Image.Resampling | None = None,
image_type=np.uint8,
) -> List[Image.Image]:
) -> list[Image.Image]:
images = tensor2np(batch)
images = [frame.astype(image_type) for frame in images]
@@ -308,7 +340,10 @@ def prepare_animated_batch(
# todo: deprecate for apng
class MTB_SaveGif:
"""Save the images from the batch as a GIF"""
"""Save the images from the batch as a GIF.
[DEPRACATED] Use VHS nodes instead
"""
@classmethod
def INPUT_TYPES(cls):
@@ -328,6 +363,7 @@ class MTB_SaveGif:
OUTPUT_NODE = True
CATEGORY = "mtb/IO"
FUNCTION = "save_gif"
DEPRECATED = True
def save_gif(
self,
+85
View File
@@ -0,0 +1,85 @@
import qrcode
import torch
from PIL import Image
from ..log import log
from ..utils import pil2tensor
class MTB_QrCode:
"""Basic QR Code generator."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"url": ("STRING", {"default": "https://www.github.com"}),
"width": (
"INT",
{"default": 256, "max": 8096, "min": 0, "step": 1},
),
"height": (
"INT",
{"default": 256, "max": 8096, "min": 0, "step": 1},
),
"error_correct": (("L", "M", "Q", "H"), {"default": "L"}),
"box_size": (
"INT",
{"default": 10, "max": 8096, "min": 0, "step": 1},
),
"border": (
"INT",
{"default": 4, "max": 8096, "min": 0, "step": 1},
),
"invert": (("BOOLEAN",), {"default": False}),
}
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "do_qr"
CATEGORY = "mtb/generate"
def do_qr(
self,
*,
url: str,
width: int,
height: int,
error_correct: str,
box_size: int,
border: int,
invert: bool,
) -> tuple[torch.Tensor]:
log.warning(
"This node will soon be deprecated, there are much better alternatives like https://github.com/coreyryanhanson/comfy-qr"
)
if error_correct == "L" or error_correct not in ["M", "Q", "H"]:
error_correct = qrcode.constants.ERROR_CORRECT_L
elif error_correct == "M":
error_correct = qrcode.constants.ERROR_CORRECT_M
elif error_correct == "Q":
error_correct = qrcode.constants.ERROR_CORRECT_Q
else:
error_correct = qrcode.constants.ERROR_CORRECT_H
qr = qrcode.QRCode(
version=1,
error_correction=error_correct,
box_size=box_size,
border=border,
)
qr.add_data(url)
qr.make(fit=True)
back_color = (255, 255, 255) if invert else (0, 0, 0)
fill_color = (0, 0, 0) if invert else (255, 255, 255)
code = qr.make_image(back_color=back_color, fill_color=fill_color)
# that we now resize without filtering
code = code.resize((width, height), Image.NEAREST)
return (pil2tensor(code),)
__nodes__ = [MTB_QrCode]
+141
View File
@@ -0,0 +1,141 @@
import cv2
import numpy as np
import torch
from huggingface_hub import hf_hub_download
from ..utils import models_dir, np2tensor
# TODO: check if I can make a torch script device independant
# for now I forced it to use cuda.
class MTB_LoadVitMatteModel:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"kind": (("Composition-1K", "Distinctions-646"),),
"autodownload": ("BOOLEAN", {"default": True}),
},
}
RETURN_TYPES = ("VITMATTE_MODEL",)
RETURN_NAMES = ("torch_script",)
CATEGORY = "mtb/vitmatte"
FUNCTION = "execute"
def execute(self, *, kind: str, autodownload: bool):
dest = models_dir / "vitmatte"
dest.mkdir(exist_ok=True)
name = "dist" if kind == "Distinctions-646" else "com"
file = hf_hub_download(
repo_id="melmass/pytorch-scripts",
filename=f"vitmatte_b_{name}.pt",
local_dir=dest.as_posix(),
local_files_only=not autodownload,
)
model = torch.jit.load(file).to("cuda")
return (model,)
class MTB_GenerateTrimap:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
# "image": ("IMAGE",),
"mask": ("MASK",),
"erode": ("INT", {"default": 10}),
"dilate": ("INT", {"default": 10}),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("trimap",)
CATEGORY = "mtb/vitmatte"
FUNCTION = "execute"
def execute(
self,
# image:torch.Tensor,
mask: torch.Tensor,
erode: int = 10,
dilate: int = 10,
):
# TODO: not sure what's the most practical between IMAGE or MASK
# image = image.to("cuda").half()
mask = mask.to("cuda").half()
trimaps = []
for m in mask:
mask_arr = m.squeeze(0).to(torch.uint8).cpu().numpy() * 255
erode_kernel = np.ones((erode, erode), np.uint8)
dilate_kernel = np.ones((dilate, dilate), np.uint8)
eroded = cv2.erode(mask_arr, erode_kernel, iterations=5)
dilated = cv2.dilate(mask_arr, dilate_kernel, iterations=5)
trimap = np.zeros_like(mask_arr)
trimap[dilated == 255] = 128
trimap[eroded == 255] = 255
trimaps.append(trimap)
return (np2tensor(trimaps),)
class MTB_ApplyVitMatte:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("VITMATTE_MODEL",),
"image": ("IMAGE",),
"trimap": ("IMAGE",),
"returns": (("RGB", "RGBA"),),
},
}
RETURN_TYPES = ("IMAGE", "MASK")
RETURN_NAMES = ("image (rgba)", "mask")
CATEGORY = "mtb/utils"
FUNCTION = "execute"
def execute(
self, model, image: torch.Tensor, trimap: torch.Tensor, returns: str
):
im_count = image.shape[0]
tm_count = trimap.shape[0]
if im_count != tm_count:
raise ValueError("image and trimap must have the same batch size")
outputs_m: list[torch.Tensor] = []
outputs_i: list[torch.Tensor] = []
for i, im in enumerate(image):
tm = trimap[i].half().unsqueeze(2).permute(2, 0, 1).to("cuda")
im = im.half().permute(2, 0, 1).to("cuda")
inputs = {"image": im.unsqueeze(0), "trimap": tm.unsqueeze(0)}
fine_mask = model(inputs)
foreground = im * fine_mask + (1 - fine_mask)
if returns == "RGBA":
rgba_image = torch.cat(
(foreground, fine_mask.unsqueeze(0)), dim=0
)
outputs_i.append(rgba_image.unsqueeze(0))
else:
outputs_i.append(foreground.unsqueeze(0))
outputs_m.append(fine_mask.unsqueeze(0))
result_m = torch.cat(outputs_m, dim=0)
result_i = torch.cat(outputs_i, dim=0)
return (result_i.permute(0, 2, 3, 1), result_m)
__nodes__ = [MTB_LoadVitMatteModel, MTB_GenerateTrimap, MTB_ApplyVitMatte]
+1
View File
@@ -8,3 +8,4 @@ rich
rich_argparse
matplotlib
pillow
cachetools
+84 -43
View File
@@ -9,12 +9,14 @@ import socket
import subprocess
import sys
import uuid
from collections.abc import Callable, Sequence
from enum import Enum
from pathlib import Path
from typing import TypeVar
import folder_paths
import numpy as np
import numpy.typing as npt
import requests
import torch
from PIL import Image
@@ -161,9 +163,9 @@ class IPChecker:
def __init__(self):
self.ips = list(self.get_local_ips())
log.debug(f"Found {len(self.ips)} local ips")
self.checked_ips = set()
self.checked_ips: set[str] = set()
def get_working_ip(self, test_url_template):
def get_working_ip(self, test_url_template: str):
for ip in self.ips:
if ip not in self.checked_ips:
self.checked_ips.add(ip)
@@ -173,7 +175,7 @@ class IPChecker:
return None
@staticmethod
def get_local_ips(prefix="192.168."):
def get_local_ips(prefix: str = "192.168."):
hostname = socket.gethostname()
log.debug(f"Getting local ips for {hostname}")
for info in socket.getaddrinfo(hostname, None):
@@ -183,9 +185,9 @@ class IPChecker:
if info[0] == socket.AF_INET and info[4][0].startswith(prefix):
yield info[4][0]
def _test_url(self, url):
def _test_url(self, url: str):
try:
response = requests.get(url)
response = requests.get(url, timeout=10)
return response.status_code == 200
except Exception:
return False
@@ -196,7 +198,7 @@ def get_server_info():
from comfy.cli_args import args
ip_checker = IPChecker()
base_url = args.listen
base_url: str = args.listen
if base_url == "0.0.0.0":
log.debug("Server set to 0.0.0.0, we will try to resolve the host IP")
base_url = ip_checker.get_working_ip(
@@ -464,6 +466,7 @@ here = Path(__file__).parent.absolute()
comfy_dir = Path(folder_paths.base_path)
models_dir = Path(folder_paths.models_dir)
output_dir = Path(folder_paths.output_directory)
input_dir = Path(folder_paths.input_directory)
styles_dir = comfy_dir / "styles"
session_id = str(uuid.uuid4())
# - Construct the path to the font file
@@ -501,54 +504,92 @@ PIL_FILTER_MAP = {
# region TENSOR Utilities
def tensor2pil(image: torch.Tensor) -> list[Image.Image]:
batch_count = image.size(0) if len(image.shape) > 3 else 1
if batch_count > 1:
out = []
for i in range(batch_count):
out.extend(tensor2pil(image[i]))
return out
return [
Image.fromarray(
np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(
np.uint8
)
)
]
def to_numpy(image: torch.Tensor) -> npt.NDArray[np.uint8]:
"""Converts a tensor to a ndarray with proper scaling and type conversion."""
log.debug(f"Converting tensor to numpy array with shape {image.shape}")
np_array = np.clip(255.0 * image.cpu().numpy(), 0, 255).astype(np.uint8)
log.debug(f"Numpy array shape after conversion: {np_array.shape}")
return np_array
def pil2tensor(image: Image.Image | list[Image.Image]) -> torch.Tensor:
if isinstance(image, list):
return torch.cat([pil2tensor(img) for img in image], dim=0)
def handle_batch(
tensor: torch.Tensor,
func: Callable[[torch.Tensor], Image.Image | npt.NDArray[np.uint8]],
) -> list[Image.Image] | list[npt.NDArray[np.uint8]]:
"""Handles batch processing for a given tensor and conversion function."""
return [func(tensor[i]) for i in range(tensor.shape[0])]
return torch.from_numpy(
np.array(image).astype(np.float32) / 255.0
).unsqueeze(0)
def tensor2pil(tensor: torch.Tensor) -> list[Image.Image]:
"""Converts a batch of tensors to a list of PIL Images."""
def single_tensor2pil(t: torch.Tensor) -> Image.Image:
np_array = to_numpy(t)
if np_array.ndim == 2: # (H, W) for masks
return Image.fromarray(np_array, mode="L")
elif np_array.ndim == 3: # (H, W, C) for RGB/RGBA
if np_array.shape[2] == 3:
return Image.fromarray(np_array, mode="RGB")
elif np_array.shape[2] == 4:
return Image.fromarray(np_array, mode="RGBA")
raise ValueError(f"Invalid tensor shape: {t.shape}")
return handle_batch(tensor, single_tensor2pil)
def pil2tensor(images: Image.Image | list[Image.Image]) -> torch.Tensor:
"""Converts a PIL Image or a list of PIL Images to a tensor."""
def single_pil2tensor(image: Image.Image) -> torch.Tensor:
np_image = np.array(image).astype(np.float32) / 255.0
if np_image.ndim == 2: # Grayscale
return torch.from_numpy(np_image).unsqueeze(0) # (1, H, W)
else: # RGB or RGBA
return torch.from_numpy(np_image).unsqueeze(0) # (1, H, W, C)
if isinstance(images, Image.Image):
return single_pil2tensor(images)
else:
return torch.cat([single_pil2tensor(img) for img in images], dim=0)
def np2tensor(
img_np: np.ndarray | list[np.ndarray[np.float32]],
np_array: npt.NDArray[np.float32] | Sequence[npt.NDArray[np.float32]],
) -> torch.Tensor:
if isinstance(img_np, list):
return torch.cat([np2tensor(img) for img in img_np], dim=0)
"""Converts a NumPy array or a list of NumPy arrays to a tensor."""
return torch.from_numpy(img_np.astype(np.float32) / 255.0).unsqueeze(0)
def single_np2tensor(array: npt.NDArray[np.float32]) -> torch.Tensor:
if array.ndim == 2: # (H, W) for masks
return torch.from_numpy(
array.astype(np.float32) / 255.0
).unsqueeze(0) # (1, H, W)
elif array.ndim == 3: # (H, W, C) for RGB/RGBA
return torch.from_numpy(
array.astype(np.float32) / 255.0
).unsqueeze(0) # (1, H, W, C)
raise ValueError(f"Invalid array shape: {array.shape}")
if isinstance(np_array, np.ndarray):
return single_np2tensor(np_array)
else:
return torch.cat([single_np2tensor(arr) for arr in np_array], dim=0)
def tensor2np(tensor: torch.Tensor) -> list[np.ndarray[np.float32]]:
batch_count = tensor.size(0) if len(tensor.shape) > 3 else 1
if batch_count > 1:
out = []
for i in range(batch_count):
out.extend(tensor2np(tensor[i]))
return out
def tensor2np(tensor: torch.Tensor) -> list[npt.NDArray[np.uint8]]:
"""Converts a batch of tensors to a list of NumPy arrays."""
return [
np.clip(255.0 * tensor.cpu().numpy().squeeze(), 0, 255).astype(
np.uint8
)
]
def single_tensor2np(t: torch.Tensor) -> npt.NDArray[np.uint8]:
t = t.squeeze() # Remove any singleton dimensions
if t.ndim == 2: # (H, W) for masks
return to_numpy(t)
elif t.ndim == 3: # (C, H, W) for RGB/RGBA
if t.shape[0] in [1, 3, 4]: # Channel-first format
t = t.permute(1, 2, 0)
return to_numpy(t)
else:
raise ValueError(f"Invalid tensor shape: {t.shape}")
return handle_batch(tensor, single_tensor2np)
def pad(img, left, right, top, bottom):
+193 -123
View File
@@ -11,6 +11,7 @@
/// <reference path="../types/typedefs.js" />
import { app } from '../../scripts/app.js'
import { api } from '../../scripts/api.js'
// #region base utils
@@ -18,7 +19,7 @@ import { app } from '../../scripts/app.js'
export function makeUUID() {
let dt = new Date().getTime()
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
const r = (dt + Math.random() * 16) % 16 | 0
const r = ((dt + Math.random() * 16) % 16) | 0
dt = Math.floor(dt / 16)
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
})
@@ -358,15 +359,17 @@ export function getWidgetType(config) {
// #region dynamic connections
/**
* @param {NodeType} nodeType
* @param {str} prefix
* @param {str | [str]} inputType
* @param {NodeType} nodeType The nodetype to attach the documentation to
* @param {str} prefix A prefix added to each dynamic inputs
* @param {str | [str]} inputType The datatype(s) of those dynamic inputs
* @param {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}?} opts
* @returns
*/
export const setupDynamicConnections = (nodeType, prefix, inputType, opts) => {
infoLogger('Setting up dynamic connections for', nodeType)
infoLogger(
'Setting up dynamic connections for',
Object.getOwnPropertyDescriptors(nodeType).title.value,
)
/** @type {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} */
const options = opts || {}
@@ -628,39 +631,6 @@ export const loadScript = (
})
}
export function defineClass(className, classStyles) {
const styleSheets = document.styleSheets
// Helper function to check if the class exists in a style sheet
function classExistsInStyleSheet(styleSheet) {
const rules = styleSheet.rules || styleSheet.cssRules
for (const rule of rules) {
if (rule.selectorText === `.${className}`) {
return true
}
}
return false
}
// Check if the class is already defined in any of the style sheets
let classExists = false
for (const styleSheet of styleSheets) {
if (classExistsInStyleSheet(styleSheet)) {
classExists = true
break
}
}
// If the class doesn't exist, add the new class definition to the first style sheet
if (!classExists) {
if (styleSheets[0].insertRule) {
styleSheets[0].insertRule(`.${className} { ${classStyles} }`, 0)
} else if (styleSheets[0].addRule) {
styleSheets[0].addRule(`.${className}`, classStyles, 0)
}
}
}
// #endregion
// #region documentation widget
@@ -736,10 +706,84 @@ const create_documentation_stylesheet = () => {
document.head.appendChild(styleTag)
}
}
let documentationConverter
let parserPromise
const callbackQueue = []
function runQueuedCallbacks() {
while (callbackQueue.length) {
const cb = callbackQueue.shift()
cb(window.MTB.mdParser)
}
}
function loadParser(shiki) {
if (!parserPromise) {
parserPromise = import(
shiki
? '/mtb_async/mtb_markdown_plus.umd.js'
: '/mtb_async/mtb_markdown.umd.js'
)
.then((_module) =>
shiki ? MTBMarkdownPlus.getParser() : MTBMarkdown.getParser(),
)
.then((instance) => {
window.MTB.mdParser = instance
runQueuedCallbacks()
return instance
})
.catch((error) => {
console.error('Error loading the parser:', error)
})
}
return parserPromise
}
export const ensureMarkdownParser = async (callback) => {
infoLogger('Ensuring md parser')
let use_shiki = false
try {
use_shiki = await api.getSetting('mtb.Use Shiki')
} catch (e) {
console.warn('Option not available yet', e)
}
if (window.MTB?.mdParser) {
infoLogger('Markdown parser found')
callback?.(window.MTB.mdParser)
return window.MTB.mdParser
}
if (!parserPromise) {
infoLogger('Running promise to fetch parser')
try {
loadParser(use_shiki) //.then(() => {
// callback?.(window.MTB.mdParser)
// })
} catch (error) {
console.error('Error loading the parser:', error)
}
} else {
infoLogger('A similar promise is already running, waiting for it to finish')
}
if (callback) {
callbackQueue.push(callback)
}
await parserPromise
await parserPromise
return window.MTB.mdParser
}
/**
* Add documentation widget to the selected node
* Add documentation widget to the given node.
*
* This method will add a `docCtrl` property to the node
* that contains the AbortController that manages all the events
* defined inside it (global and instance ones) without explicit
* cleanup method for each.
*
* @param {NodeData} nodeData
* @param {NodeType} nodeType
* @param {DocumentationOptions} opts
@@ -756,25 +800,10 @@ export const addDocumentation = (
return
}
if (!documentationConverter) {
infoLogger('Initializing our mardown converter')
documentationConverter = new showdown.Converter({
tables: true,
strikethrough: true,
emoji: true,
ghCodeBlocks: true,
tasklists: true,
ghMentions: true,
smoothLivePreview: true,
simplifiedAutoLink: true,
parseImgDimensions: true,
openLinksInNewWindow: true,
})
}
const options = opts || {}
const iconSize = options.icon_size || 14
const iconMargin = options.icon_margin || 4
let docElement = null
let wrapper = null
@@ -820,80 +849,87 @@ export const addDocumentation = (
wrapper = document.createElement('div')
wrapper.classList.add('documentation-wrapper')
wrapper.innerHTML = documentationConverter.makeHtml(nodeData.description)
docElement.appendChild(wrapper)
// resize handle
resizeHandle = document.createElement('div')
resizeHandle.style.width = '0'
resizeHandle.style.height = '0'
resizeHandle.style.position = 'absolute'
resizeHandle.style.bottom = '0'
resizeHandle.style.right = '0'
// wrapper.innerHTML = documentationConverter.makeHtml(nodeData.description)
resizeHandle.style.cursor = 'se-resize'
resizeHandle.style.userSelect = 'none'
ensureMarkdownParser().then(() => {
MTB.mdParser.parse(nodeData.description).then((e) => {
wrapper.innerHTML = e
// resize handle
resizeHandle = document.createElement('div')
resizeHandle.classList.add('doc-resize-handle')
resizeHandle.style.width = '0'
resizeHandle.style.height = '0'
resizeHandle.style.position = 'absolute'
resizeHandle.style.bottom = '0'
resizeHandle.style.right = '0'
resizeHandle.style.borderWidth = '15px'
resizeHandle.style.borderStyle = 'solid'
resizeHandle.style.cursor = 'se-resize'
resizeHandle.style.userSelect = 'none'
resizeHandle.style.borderColor =
'transparent var(--border-color) var(--border-color) transparent'
resizeHandle.style.borderWidth = '15px'
resizeHandle.style.borderStyle = 'solid'
wrapper.appendChild(resizeHandle)
let isResizing = false
resizeHandle.style.borderColor =
'transparent var(--border-color) var(--border-color) transparent'
let startX
let startY
let startWidth
let startHeight
wrapper.appendChild(resizeHandle)
let isResizing = false
resizeHandle.addEventListener(
'mousedown',
(e) => {
e.stopPropagation()
isResizing = true
startX = e.clientX
startY = e.clientY
startWidth = Number.parseInt(
document.defaultView.getComputedStyle(docElement).width,
10,
let startX
let startY
let startWidth
let startHeight
resizeHandle.addEventListener(
'mousedown',
(e) => {
e.stopPropagation()
isResizing = true
startX = e.clientX
startY = e.clientY
startWidth = Number.parseInt(
document.defaultView.getComputedStyle(docElement).width,
10,
)
startHeight = Number.parseInt(
document.defaultView.getComputedStyle(docElement).height,
10,
)
},
{ signal: this.docCtrl.signal },
)
startHeight = Number.parseInt(
document.defaultView.getComputedStyle(docElement).height,
10,
document.addEventListener(
'mousemove',
(e) => {
if (!isResizing) return
const scale = app.canvas.ds.scale
const newWidth = startWidth + (e.clientX - startX) / scale
const newHeight = startHeight + (e.clientY - startY) / scale
docElement.style.width = `${newWidth}px`
docElement.style.height = `${newHeight}px`
this.docPos = {
width: `${newWidth}px`,
height: `${newHeight}px`,
}
},
{ signal: this.docCtrl.signal },
)
},
{ signal: this.docCtrl.signal },
)
document.addEventListener(
'mousemove',
(e) => {
if (!isResizing) return
const scale = app.canvas.ds.scale
const newWidth = startWidth + (e.clientX - startX) / scale
const newHeight = startHeight + (e.clientY - startY) / scale
docElement.style.width = `${newWidth}px`
docElement.style.height = `${newHeight}px`
this.docPos = {
width: `${newWidth}px`,
height: `${newHeight}px`,
}
},
{ signal: this.docCtrl.signal },
)
document.addEventListener(
'mouseup',
() => {
isResizing = false
},
{ signal: this.docCtrl.signal },
)
document.addEventListener(
'mouseup',
() => {
isResizing = false
},
{ signal: this.docCtrl.signal },
)
})
})
} else if (!this.show_doc && docElement !== null) {
docElement.remove()
docElement = null
@@ -917,8 +953,8 @@ export const addDocumentation = (
Object.assign(docElement.style, {
transformOrigin: '0 0',
transform: scale,
left: `${transform.a + transform.e}px`,
top: `${transform.d + transform.f}px`,
left: `${transform.a + rect.x + transform.e}px`,
top: `${transform.d + rect.y + transform.f}px`,
width: this.docPos ? this.docPos.width : `${this.size[0] * 1.5}px`,
height: this.docPos?.height,
})
@@ -1049,7 +1085,41 @@ export const addDeprecation = (nodeType, reason) => {
// #endregion
// #region graph utilities
// #region API / graph utilities
export const getAPIInputs = () => {
const inputs = {}
let counter = 1
for (const node of getNodes(true)) {
const widgets = node.widgets
if (node.properties.mtb_api && node.properties.useAPI) {
if (node.properties.mtb_api.inputs) {
for (const currentName in node.properties.mtb_api.inputs) {
const current = node.properties.mtb_api.inputs[currentName]
if (current.enabled) {
const inputName = current.name || currentName
const widget = widgets.find((w) => w.name === currentName)
if (!widget) continue
if (!(inputName in inputs)) {
inputs[inputName] = {
...current,
id: counter,
name: inputName,
type: current.type,
node_id: node.id,
widgets: [],
}
}
inputs[inputName].widgets.push(widget)
counter = counter + 1
}
}
}
}
}
return inputs
}
export const getNodes = (skip_unused) => {
const nodes = []
for (const outerNode of app.graph.computeExecutionOrder(false)) {
+3 -3
View File
File diff suppressed because one or more lines are too long
-3
View File
File diff suppressed because one or more lines are too long
+296 -295
View File
@@ -13,40 +13,40 @@ import { api } from '../../scripts/api.js'
import { app } from '../../scripts/app.js'
import { LocalStorageManager } from './comfy_shared.js'
const styles = {
lighbox: {
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0,0,0,0.5)',
display: 'none',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
},
lightboxBtn: (extra) => ({
position: 'absolute',
top: '50%',
background: 'none',
border: 'none',
color: '#fff',
zIndex: 1000,
fontSize: '30px',
cursor: 'pointer',
pointerEvents: 'auto',
...extra,
}),
img_list: {
minHeight: '30px',
maxHeight: '300px',
width: '100vw',
position: 'absolute',
bottom: 0,
zIndex: 10,
background: '#333',
overflow: 'auto',
},
lighbox: {
position: 'fixed',
top: 0,
left: 0,
width: '100vw',
height: '100vh',
background: 'rgba(0,0,0,0.5)',
display: 'none',
justifyContent: 'center',
alignItems: 'center',
zIndex: 999,
},
lightboxBtn: (extra) => ({
position: 'absolute',
top: '50%',
background: 'none',
border: 'none',
color: '#fff',
zIndex: 1000,
fontSize: '30px',
cursor: 'pointer',
pointerEvents: 'auto',
...extra,
}),
img_list: {
minHeight: '30px',
maxHeight: '300px',
width: '100vw',
position: 'absolute',
bottom: 0,
zIndex: 10,
background: '#333',
overflow: 'auto',
},
}
let currentImageIndex = 0
@@ -58,298 +58,299 @@ const storage = new LocalStorageManager('mtb')
let activated = storage.get('image_feed', false)
app.registerExtension({
name: 'mtb.ImageFeed',
setup: () => {
app.ui.settings.addSetting({
id: 'mtb.imageFeed.enabled',
name: '[⚡mtb] Enable image feed',
type: 'boolean',
defaultValue: true,
attrs: {
style: {
fontFamily: 'monospace',
},
},
async onChange(value) {
storage.set('image_feed', value)
activated = value
},
})
},
init: async () => {
if (!activated) {
return
}
const pythongossFeed = app.extensions.find(
(e) => e.name === 'pysssss.ImageFeed',
)
if (pythongossFeed) {
console.warn(
"[mtb] - Aborting the loading of mtb's imageFeed in favor of pysssss.ImageFeed",
)
activated = false // just in case other methods are added later on
return
}
// - HTML & CSS
//- lightbox
const lightboxContainer = document.createElement('div')
Object.assign(lightboxContainer.style, styles.lighbox)
name: 'mtb.ImageFeed',
setup: () => {
app.ui.settings.addSetting({
id: 'mtb.Main.image-feed-enabled',
category: ['mtb', 'Main', 'image-feed-enabled'],
name: 'Enable Image Feed',
type: 'boolean',
defaultValue: false,
attrs: {
style: {
fontFamily: 'monospace',
},
},
async onChange(value) {
storage.set('image_feed', value)
activated = value
},
})
},
init: async () => {
if (!activated) {
return
}
const pythongossFeed = app.extensions.find(
(e) => e.name === 'pysssss.ImageFeed',
)
if (pythongossFeed) {
console.warn(
"[mtb] - Aborting the loading of mtb's imageFeed in favor of pysssss.ImageFeed",
)
activated = false // just in case other methods are added later on
return
}
// - HTML & CSS
//- lightbox
const lightboxContainer = document.createElement('div')
Object.assign(lightboxContainer.style, styles.lighbox)
const lightboxImage = document.createElement('img')
Object.assign(lightboxImage.style, {
maxHeight: '100%',
maxWidth: '100%',
borderRadius: '5px',
})
const lightboxImage = document.createElement('img')
Object.assign(lightboxImage.style, {
maxHeight: '100%',
maxWidth: '100%',
borderRadius: '5px',
})
// previous and next buttons
const lightboxPrevBtn = document.createElement('button')
const lightboxNextBtn = document.createElement('button')
// previous and next buttons
const lightboxPrevBtn = document.createElement('button')
const lightboxNextBtn = document.createElement('button')
lightboxPrevBtn.textContent = '❮'
lightboxNextBtn.textContent = '❯'
lightboxPrevBtn.textContent = '❮'
lightboxNextBtn.textContent = '❯'
Object.assign(lightboxPrevBtn.style, styles.lightboxBtn({ left: '0%' }))
Object.assign(lightboxNextBtn.style, styles.lightboxBtn({ right: '0%' }))
Object.assign(lightboxPrevBtn.style, styles.lightboxBtn({ left: '0%' }))
Object.assign(lightboxNextBtn.style, styles.lightboxBtn({ right: '0%' }))
// close button
const lightboxCloseBtn = document.createElement('button')
Object.assign(
lightboxCloseBtn.style,
styles.lightboxBtn({ right: '0', top: '0' }),
)
lightboxCloseBtn.textContent = '❌'
// close button
const lightboxCloseBtn = document.createElement('button')
Object.assign(
lightboxCloseBtn.style,
styles.lightboxBtn({ right: '0', top: '0' }),
)
lightboxCloseBtn.textContent = '❌'
const lightboxButtons = document.createElement('div')
Object.assign(lightboxButtons.style, {
position: 'absolute',
top: '0%',
right: '0%',
// transform: "translate(50%, -50%)",
height: '100%',
width: '100%',
background: 'none',
border: 'none',
color: '#fff',
fontSize: '30px',
cursor: 'pointer',
pointerEvents: 'none',
})
const lightboxButtons = document.createElement('div')
Object.assign(lightboxButtons.style, {
position: 'absolute',
top: '0%',
right: '0%',
// transform: "translate(50%, -50%)",
height: '100%',
width: '100%',
background: 'none',
border: 'none',
color: '#fff',
fontSize: '30px',
cursor: 'pointer',
pointerEvents: 'none',
})
lightboxButtons.append(lightboxPrevBtn, lightboxNextBtn, lightboxCloseBtn)
lightboxContainer.append(lightboxButtons, lightboxImage)
lightboxButtons.append(lightboxPrevBtn, lightboxNextBtn, lightboxCloseBtn)
lightboxContainer.append(lightboxButtons, lightboxImage)
//- image list
const imageListContainer = document.createElement('div')
Object.assign(imageListContainer.style, styles.img_list)
//- image list
const imageListContainer = document.createElement('div')
Object.assign(imageListContainer.style, styles.img_list)
const createImgListBtn = (text, style) => {
const btn = document.createElement('button')
btn.type = 'button'
btn.textContent = text
Object.assign(btn.style, {
...style,
border: 'none',
color: '#fff',
background: 'none',
height: '20px',
cursor: 'pointer',
position: 'absolute',
top: '5px',
fontSize: '12px',
lineHeight: '12px',
})
imageListContainer.append(btn)
return btn
}
const showBtn = document.createElement('button')
const closeBtn = createImgListBtn('❌', {
width: '20px',
textIndent: '-4px',
right: '5px',
})
const loadButton = createImgListBtn('Load Session History', {
right: '90px',
})
const clearButton = createImgListBtn('Clear', {
right: '30px',
})
const createImgListBtn = (text, style) => {
const btn = document.createElement('button')
btn.type = 'button'
btn.textContent = text
Object.assign(btn.style, {
...style,
border: 'none',
color: '#fff',
background: 'none',
height: '20px',
cursor: 'pointer',
position: 'absolute',
top: '5px',
fontSize: '12px',
lineHeight: '12px',
})
imageListContainer.append(btn)
return btn
}
const showBtn = document.createElement('button')
const closeBtn = createImgListBtn('❌', {
width: '20px',
textIndent: '-4px',
right: '5px',
})
const loadButton = createImgListBtn('Load Session History', {
right: '90px',
})
const clearButton = createImgListBtn('Clear', {
right: '30px',
})
//- tools popup button
showBtn.classList.add('comfy-settings-btn')
Object.assign(showBtn.style, {
right: '16px',
cursor: 'pointer',
display: 'none',
})
//- tools popup button
showBtn.classList.add('comfy-settings-btn')
Object.assign(showBtn.style, {
right: '16px',
cursor: 'pointer',
display: 'none',
})
//- append to DOM
document.body.append(imageListContainer)
//- append to DOM
document.body.append(imageListContainer)
showBtn.textContent = '🖼'
showBtn.onclick = () => {
imageListContainer.style.display = 'block'
showBtn.style.display = 'none'
}
document.querySelector('.comfy-settings-btn').after(showBtn)
document.querySelector('.comfy-settings-btn').after(lightboxContainer)
showBtn.textContent = '🖼'
showBtn.onclick = () => {
imageListContainer.style.display = 'block'
showBtn.style.display = 'none'
}
document.querySelector('.comfy-settings-btn').after(showBtn)
document.querySelector('.comfy-settings-btn').after(lightboxContainer)
// for (const { output } of history) {
// if (output?.images) {
// for (const src of output.images) {
// const img = document.createElement("img");
// const but = document.createElement("button");
// for (const { output } of history) {
// if (output?.images) {
// for (const src of output.images) {
// const img = document.createElement("img");
// const but = document.createElement("button");
//- callbacks
closeBtn.onclick = () => {
imageListContainer.style.display = 'none'
showBtn.style.display = 'unset'
}
//- callbacks
closeBtn.onclick = () => {
imageListContainer.style.display = 'none'
showBtn.style.display = 'unset'
}
clearButton.onclick = () => {
imageListContainer.replaceChildren(closeBtn, clearButton, loadButton)
}
clearButton.onclick = () => {
imageListContainer.replaceChildren(closeBtn, clearButton, loadButton)
}
lightboxNextBtn.onclick = () => {
currentImageIndex = (currentImageIndex + 1) % imageUrls.length
const imageUrl = imageUrls[currentImageIndex]
lightboxImage.src = imageUrl
}
lightboxNextBtn.onclick = () => {
currentImageIndex = (currentImageIndex + 1) % imageUrls.length
const imageUrl = imageUrls[currentImageIndex]
lightboxImage.src = imageUrl
}
// Modify the lightboxPrevBtn onclick callback
lightboxPrevBtn.onclick = () => {
currentImageIndex =
(currentImageIndex - 1 + imageUrls.length) % imageUrls.length
const imageUrl = imageUrls[currentImageIndex]
lightboxImage.src = imageUrl
}
// Modify the lightboxPrevBtn onclick callback
lightboxPrevBtn.onclick = () => {
currentImageIndex =
(currentImageIndex - 1 + imageUrls.length) % imageUrls.length
const imageUrl = imageUrls[currentImageIndex]
lightboxImage.src = imageUrl
}
lightboxCloseBtn.onclick = () => {
lightboxContainer.style.display = 'none'
}
lightboxImage.onclick = lightboxNextBtn.onclick
/**
* This is the function that creates the image buttons for the image list
* They are wrapped in a button so that they can be clicked and open
* the image in the lightbox.
* @param {*} src
*/
const createImageBtn = (src) => {
console.debug(`making image ${src.filename}`)
const img = document.createElement('img')
const but = document.createElement('button')
lightboxCloseBtn.onclick = () => {
lightboxContainer.style.display = 'none'
}
lightboxImage.onclick = lightboxNextBtn.onclick
/**
* This is the function that creates the image buttons for the image list
* They are wrapped in a button so that they can be clicked and open
* the image in the lightbox.
* @param {*} src
*/
const createImageBtn = (src) => {
console.debug(`making image ${src.filename}`)
const img = document.createElement('img')
const but = document.createElement('button')
Object.assign(but.style, {
height: '120px',
width: '120px',
border: 'none',
padding: 0,
margin: 0,
})
Object.assign(img.style, {
width: '100%',
height: '100%',
objectFit: 'cover',
})
Object.assign(but.style, {
height: '120px',
width: '120px',
border: 'none',
padding: 0,
margin: 0,
})
Object.assign(img.style, {
width: '100%',
height: '100%',
objectFit: 'cover',
})
img.src = `/view?filename=${encodeURIComponent(src.filename)}&type=${
src.type
}&subfolder=${encodeURIComponent(src.subfolder)}`
img.src = `/view?filename=${encodeURIComponent(src.filename)}&type=${
src.type
}&subfolder=${encodeURIComponent(src.subfolder)}`
imageUrls.push(img.src)
imageUrls.push(img.src)
console.debug(img.src)
console.debug(img.src)
img.onload = () => {
but.style.width = `${120 * (img.naturalWidth / img.naturalHeight)}px`
}
img.onload = () => {
but.style.width = `${120 * (img.naturalWidth / img.naturalHeight)}px`
}
but.onclick = () => {
lightboxContainer.style.display = 'flex'
// add the same image to the lightbox
lightboxImage.src = img.src
// lighboxContainer.replaceChildren(lightboxButtons, img);
}
but.onclick = () => {
lightboxContainer.style.display = 'flex'
// add the same image to the lightbox
lightboxImage.src = img.src
// lighboxContainer.replaceChildren(lightboxButtons, img);
}
// add right click menu
but.addEventListener('contextmenu', (e) => {
e.preventDefault()
// add right click menu
but.addEventListener('contextmenu', (e) => {
e.preventDefault()
if (image_menu) {
image_menu.remove()
}
if (image_menu) {
image_menu.remove()
}
image_menu = document.createElement('div')
Object.assign(image_menu.style, {
position: 'absolute',
top: `${e.clientY}px`,
left: `${e.clientX}px`,
background: '#333',
color: '#fff',
padding: '5px',
borderRadius: '5px',
zIndex: 999,
})
const load_img = document.createElement('button')
load_img.textContent = 'Load'
load_img.onclick = () => {
app.handleFile(img.src)
}
image_menu = document.createElement('div')
Object.assign(image_menu.style, {
position: 'absolute',
top: `${e.clientY}px`,
left: `${e.clientX}px`,
background: '#333',
color: '#fff',
padding: '5px',
borderRadius: '5px',
zIndex: 999,
})
const load_img = document.createElement('button')
load_img.textContent = 'Load'
load_img.onclick = () => {
app.handleFile(img.src)
}
image_menu.appendChild(load_img)
document.body.appendChild(image_menu)
})
image_menu.appendChild(load_img)
document.body.appendChild(image_menu)
})
but.append(img)
imageListContainer.prepend(but)
}
but.append(img)
imageListContainer.prepend(but)
}
loadButton.onclick = async () => {
const all_history = await api.getHistory()
for (const history of all_history.History) {
if (history.outputs) {
for (const key of Object.keys(history.outputs)) {
console.debug(key)
if (history.outputs[key].images) {
for (const im of history.outputs[key].images) {
console.debug(im)
createImageBtn(im)
}
}
}
// for (const src of outputs.outputs.images) {
// console.debug(src)
// makeImage(`${src.subfolder}/${src.filename}`)
// }
}
}
}
loadButton.onclick = async () => {
const all_history = await api.getHistory()
for (const history of all_history.History) {
if (history.outputs) {
for (const key of Object.keys(history.outputs)) {
console.debug(key)
if (history.outputs[key].images) {
for (const im of history.outputs[key].images) {
console.debug(im)
createImageBtn(im)
}
}
}
// for (const src of outputs.outputs.images) {
// console.debug(src)
// makeImage(`${src.subfolder}/${src.filename}`)
// }
}
}
}
///////-------
///////-------
// const all_history = await api.getHistory()
// for (const history of all_history.History) {
// if (history.outputs) {
// for (const key of Object.keys(history.outputs)) {
// for (const im of history.outputs[key].images) {
// makeImage(im)
// }
// }
// // for (const src of outputs.outputs.images) {
// // console.debug(src)
// // makeImage(`${src.subfolder}/${src.filename}`)
// // }
// }
// }
// const all_history = await api.getHistory()
// for (const history of all_history.History) {
// if (history.outputs) {
// for (const key of Object.keys(history.outputs)) {
// for (const im of history.outputs[key].images) {
// makeImage(im)
// }
// }
// // for (const src of outputs.outputs.images) {
// // console.debug(src)
// // makeImage(`${src.subfolder}/${src.filename}`)
// // }
// }
// }
//- Hook into the API
api.addEventListener('executed', ({ detail }) => {
if (detail?.output?.images) {
for (const src of detail.output.images) {
console.debug(`Adding ${src} to image feed`)
createImageBtn(src)
}
}
})
},
//- Hook into the API
api.addEventListener('executed', ({ detail }) => {
if (detail?.output?.images) {
for (const src of detail.output.images) {
console.debug(`Adding ${src} to image feed`)
createImageBtn(src)
}
}
})
},
})
+229
View File
@@ -0,0 +1,229 @@
import { app } from '../../scripts/app.js'
import { api } from '../../scripts/api.js'
// import * as shared from './comfy_shared.js'
import {
// defineCSSClass,
ensureMTBStyles,
makeElement,
makeSelect,
makeSlider,
renderSidebar,
} from './mtb_ui.js'
let offset = 0
let currentWidth = 200
let currentMode = 'input'
let currentSort = 'None'
const IMAGE_NODES = ['LoadImage']
const updateImage = (node, image) => {
if (IMAGE_NODES.includes(node.type)) {
const w = node.widgets?.find((w) => w.name === 'image')
if (w) {
w.value = image
w.callback()
}
}
}
const getImgsFromUrls = (urls, target) => {
const imgs = []
if (urls === undefined) {
return imgs
}
for (const [key, url] of Object.entries(urls)) {
const a = makeElement('img')
a.src = url
a.width = currentWidth
if (currentMode === 'input') {
a.onclick = (_e) => {
const selected = app.canvas.selected_nodes
if (selected && Object.keys(selected).length === 0) {
app.extensionManager.toast.add({
severity: 'warn',
summary: 'No LoadImage node selected!',
detail:
'For now the only action when clicking images in the sidebar is to set the image on all selected LoadImage nodes.',
life: 5000,
})
return
}
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
updateImage(node, `${key}.png`)
}
}
} else {
a.onclick = (_e) =>
// window.MTB?.notify?.("Output import isn't supported yet...", 5000)
app.extensionManager.toast.add({
severity: 'warn',
summary: 'Outputs not supported',
detail:
'For now only inputs can be clicked to load the image on the active LoadImage node.',
life: 5000,
})
}
imgs.push(a)
}
if (target !== undefined) {
target.append(...imgs)
}
return imgs
}
const getUrls = async () => {
const count = await api.getSetting('mtb.io-sidebar.count')
console.log('Sidebar count', count)
const inputs = await api.fetchApi('/mtb/actions', {
method: 'POST',
body: JSON.stringify({
name: 'getUserImages',
// mode, count, offset
args: [currentMode, count, offset, currentSort],
}),
})
const output = await inputs.json()
return output?.result || {}
}
if (window?.__COMFYUI_FRONTEND_VERSION__) {
let handle
const version = window?.__COMFYUI_FRONTEND_VERSION__
console.log(`%c ${version}`, 'background: orange; color: white;')
ensureMTBStyles()
app.ui.settings.addSetting({
id: 'mtb.io-sidebar.count',
category: ['mtb', 'Input & Output Sidebar', 'count'],
name: 'Number of images to fetch',
type: 'number',
defaultValue: 1000,
tooltip:
"This setting affects the input/output sidebar to determine how many images to fetch per pagination (pagination is not yet supported so for now it's the static total)",
attrs: {
style: {
// fontFamily: 'monospace',
},
},
})
app.ui.settings.addSetting({
id: 'mtb.io-sidebar.img-size',
category: ['mtb', 'Input & Output Sidebar', 'img-size'],
name: 'Resolution of the images',
type: 'number',
defaultValue: 512,
tooltip: "It's recommended to keep it at 512px",
attrs: {
style: {
// fontFamily: 'monospace',
},
},
})
app.ui.settings.addSetting({
id: 'mtb.io-sidebar.sort',
category: ['mtb', 'Input & Output Sidebar', 'sort'],
name: 'Default sort mode',
type: 'combo',
onChange: (v) => {
// alert(`Sort is now ${v}`)
currentSort = v
},
defaultValue: 'Modified',
// tooltip: "It's recommended to keep it at 512px",
options: ['None', 'Modified', 'Modified-Reverse', 'Name', 'Name-Reverse'],
})
app.extensionManager.registerSidebarTab({
id: 'mtb-inputs-outputs',
icon: 'pi pi-images',
title: 'Input & Outputs',
tooltip: 'MTB: Browse inputs and outputs directories.',
type: 'custom',
// this is run everytime the tab's diplay is toggled on.
render: async (el) => {
if (handle) {
handle.unregister()
handle = undefined
}
if (el.parentNode) {
el.parentNode.style.overflowY = 'clip'
}
const urls = await getUrls(currentMode)
let imgs = {}
const cont = makeElement('div.mtb_sidebar')
const imgGrid = makeElement('div.mtb_img_grid')
const selector = makeSelect(['input', 'output'], currentMode)
selector.addEventListener('change', async (e) => {
const newMode = e.target.value
const changed = newMode !== currentMode
currentMode = newMode
if (changed) {
imgGrid.innerHTML = ''
const urls = await getUrls()
if (urls) {
imgs = getImgsFromUrls(urls, imgGrid)
}
}
})
const imgTools = makeElement('div.mtb_tools')
const orderSelect = makeSelect(
['None', 'Modified', 'Modified-Reverse', 'Name', 'Name-Reverse'],
currentSort,
)
orderSelect.addEventListener('change', async (e) => {
const newSort = e.target.value
const changed = newSort !== currentSort
currentSort = newSort
if (changed) {
imgGrid.innerHTML = ''
const urls = await getUrls()
if (urls) {
imgs = getImgsFromUrls(urls, imgGrid)
}
}
})
const sizeSlider = makeSlider(64, 1024, currentWidth, 1)
imgTools.appendChild(orderSelect)
imgTools.appendChild(sizeSlider)
imgs = getImgsFromUrls(urls, imgGrid)
sizeSlider.addEventListener('input', (e) => {
currentWidth = e.target.value
for (const img of imgs) {
img.style.width = `${e.target.value}px`
}
})
handle = renderSidebar(el, cont, [selector, imgGrid, imgTools])
},
destroy: () => {
if (handle) {
handle.unregister()
handle = undefined
}
},
})
}
+25
View File
@@ -0,0 +1,25 @@
import { app } from '../../scripts/app.js'
// import { api } from '../../scripts/api.js'
import * as shared from './comfy_shared.js'
import { createOutliner } from './dist/mtb_inspector.js'
if (window?.__COMFYUI_FRONTEND_VERSION__) {
const version = window?.__COMFYUI_FRONTEND_VERSION__
console.log(`%c ${version}`, 'background: orange; color: white;')
const panel = app.extensionManager.registerSidebarTab({
id: 'mtb-nodes',
icon: 'pi pi-bolt',
title: 'MTB',
tooltip: 'MTB: API outliner',
type: 'custom',
// this is run everytime the tab's diplay is toggled on.
render: (el) => {
const outliner = createOutliner(el)
const inputs = shared.getAPIInputs()
console.log('INPUTS', inputs)
outliner.$$set({ inputs })
},
})
}
+504
View File
@@ -0,0 +1,504 @@
/**
* Adds a named stylesheet to the document with an optional ability to replace an existing one.
*
* @param {string} name - The unique name (ID) of the stylesheet.
* @param {string} css - The CSS rules as a string.
* @param {boolean} [force=false] - Whether to replace the existing stylesheet if it exists.
* @returns {void}
*/
export function addNamedStyleSheet(name, css, force = false) {
const existingStyleSheet = document.getElementById(name)
if (existingStyleSheet && !force) {
console.debug(
`Stylesheet with name "${name}" already exists. Skipping addition.`,
)
return
}
if (existingStyleSheet && force) {
console.debug(`Stylesheet with name "${name}" exists. Replacing...`)
existingStyleSheet.remove()
}
const styleElement = document.createElement('style')
styleElement.id = name
styleElement.type = 'text/css'
styleElement.appendChild(document.createTextNode(css))
document.head.appendChild(styleElement)
console.debug(`Stylesheet with name "${name}" added.`)
}
export const ensureMTBStyles = () => {
const S = {
fg: 'var(--fg-color)',
bgi: 'var(--comfy-input-bg)',
bgm: 'var(--comfy-menu-bg)',
border: 'var(--comfy-border)',
borderHover: 'var(--comfy-border-hover)',
box: 'var(--comfy-box)',
accent: 'var(--p-button-text-primary-color)',
}
const common = `
.mtb_sidebar {
display: flex;
flex-direction: column;
background: ${S.bgm};
}
.mtb_img_grid {
display: flex;
flex-wrap: wrap;
overflow: scroll;
gap: 1em;
align-items: center;
justify-content: center;
height: 100%;
width: 100%;
}
.mtb_tools {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 100%;
}
`
const inputs = `
/* SELECT */
.mtb_select {
appearance: none;
display: grid;
grid-template-areas: "select";
padding: 10px;
background-color: ${S.bgi};
border: none;
border-radius: 5px;
font-size: 14px;
color: ${S.fg};
cursor: pointer;
width: 100%;
}
@supports (-moz-appearance:none) {
.mtb_select{
grid-area: select;
background: ${S.bgi} url('data:image/gif;base64,R0lGODlhBgAGAKEDAFVVVX9/f9TU1CgmNyH5BAEKAAMALAAAAAAGAAYAAAIODA4hCDKWxlhNvmCnGwUAOw==') right center no-repeat !important;
background-position: calc(100% - 5px) center !important;
-moz-appearance:none !important;
}
/* styling the dropdown arrow for browsers that support it */
.mtb_select:after {
content: "";
width: 0.8em;
height: 0.5em;
background-color: ${S.fg};
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
}
.mtb_select:focus {
outline: none;
border-color: #0056b3;
}
.mtb_select > option {
padding: 10px;
background-color: ${S.bgi};
border:none;
color: ${S.fg};
}
.mtb_select > option:hover {
background-color: red;
color: ${S.fg};
}
/* SLIDER */
.mtb_slider[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 10px;
background: ${S.bgm};
border-radius: 5px;
outline: none;
opacity: 0.7;
transition: opacity .2s;
padding: 1em;
}
/* slider track */
.mtb_slider[type="range"]::-webkit-slider-runnable-track,
.mtb_slider[type="range"]::-moz-range-track {
width: 100%;
height: 10px;
background: ${S.bgi};
border-radius: 5px;
}
/* progress */
.mtb_slider[type="range"]::-moz-range-progress {
background-color: ${S.accent};
height:10px;
border-radius: 5px;
}
/* slider thumb (the handle) */
.mtb_slider[type="range"]::-webkit-slider-thumb,
.mtb_slider[type="range"]::-moz-range-thumb
{
-webkit-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: ${S.fg};
border: none;
cursor: pointer;
filter: drop-shadow(1px 1px 4px black);
}
.mtb_slider[type="range"]:focus {
opacity: 1;
}
.mtb_slider[type=range]:-moz-focusring{
outline: 1px solid red;
outline-offset: -1px;
}
.mtb_slider[type="range"]:hover::-webkit-slider-thumb,
.mtb_slider[type="range"]:active::-webkit-slider-thumb {
background-color: ${S.accent};
}
`
addNamedStyleSheet(
'mtb_ui',
`
${common}
${inputs}
`,
)
}
/**
* Creates a DOM element with optional styles, class, and id.
*
* @param {string} kind - The tag name of the element. Supports class and id syntax (e.g. 'div.class#id').
* @param {Object} [style] - CSS styles to apply to the element.
* @returns {HTMLElement} - The created DOM element.
*/
export const makeElement = (kind, style) => {
let [real_kind, className] = kind.split('.')
let id
if (className?.includes('#')) {
;[className, id] = className.split('#')
}
const el = document.createElement(real_kind)
if (style) {
Object.assign(el.style, style)
}
if (className) {
el.classList.add(...className.split(' ')) // Support multiple classes
}
if (id) {
el.id = id
}
return el
}
/**
* Clears all child elements of the given parent element.
*
* @param {HTMLElement} el - The parent element whose children should be removed.
*/
export const clearElement = (el) => {
while (el.firstChild) {
el.removeChild(el.firstChild)
}
}
/**
* Creates a labeled element (input, select, etc.).
*
* @param {HTMLElement} el - The element to label.
* @param {string} labelText - The label text.
* @returns {HTMLDivElement} - A div containing the label and the element.
*/
export const makeLabeledElement = (el, labelText) => {
const wrapper = makeElement('div.mtb_labeled_element', {
marginBottom: '1em',
})
const label = makeElement('label', {
display: 'block',
marginBottom: '0.5em',
})
label.textContent = labelText
wrapper.appendChild(label)
wrapper.appendChild(el)
return wrapper
}
/**
* Converts a camelCase CSS property to kebab-case.
*
* @param {string} prop - The camelCase CSS property.
* @returns {string} - The kebab-case CSS property.
*/
const camelToKebab = (prop) =>
prop.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
/**
* Parses the style string into an object of CSS property-value pairs.
*
* @param {string} styleString - The CSS rule text (e.g., "color: red; background-color: blue;").
* @returns {Object} - An object with camelCase CSS properties.
*/
const parseStyleString = (styleString) => {
const styleObj = {}
for (const rule of styleString.split(';')) {
const [property, value] = rule.split(':').map((item) => item.trim())
if (property && value) {
const camelProp = property.replace(/-([a-z])/g, (g) => g[1].toUpperCase())
styleObj[camelProp] = value
}
}
return styleObj
}
/**
* Defines a new CSS class with the provided styles, or skips if the class already exists.
*
* @param {string} className - The name of the CSS class to define.
* @param {Object} classStyles - An object containing camelCase CSS property-value pairs.
*/
export function defineCSSClass(className, classStyles) {
const styleSheets = document.styleSheets
let classExists = false
let existingStyleString = ''
const classExistsInStyleSheet = (styleSheet) => {
const rules = styleSheet.rules || styleSheet.cssRules
for (const rule of rules) {
if (rule.selectorText === `.${className}`) {
classExists = true
existingStyleString = rule.style.cssText // Capture existing styles
return true
}
}
return false
}
for (const styleSheet of styleSheets) {
if (classExistsInStyleSheet(styleSheet)) {
console.debug(`Class ${className} already exists, merging styles...`)
break
}
}
const existingStyles = classExists
? parseStyleString(existingStyleString)
: {}
const mergedStyles = { ...existingStyles, ...classStyles }
const stylesString = Object.entries(mergedStyles)
.map(([key, value]) => `${camelToKebab(key)}: ${value};`)
.join(' ')
if (!classExists) {
console.debug(`Defining new class ${className}...`)
if (styleSheets[0].insertRule) {
styleSheets[0].insertRule(`.${className} { ${stylesString} }`, 0)
} else if (styleSheets[0].addRule) {
styleSheets[0].addRule(`.${className}`, stylesString, 0)
}
} else {
console.debug(`Updating existing class ${className} with merged styles...`)
for (const styleSheet of styleSheets) {
const rules = styleSheet.rules || styleSheet.cssRules
for (const rule of rules) {
if (rule.selectorText === `.${className}`) {
rule.style.cssText = stylesString // Update the existing rule
}
}
}
}
console.debug(
`Class ${className} has been defined/updated with styles:`,
mergedStyles,
)
}
/**
* Renders a sidebar and ensures it resizes correctly when the window is resized.
*
* @param {HTMLElement} el - The element where the sidebar is rendered.
* @param {HTMLElement} cont - The content container of the sidebar.
* @param {HTMLElement[]} elems - Array of elements to append to the sidebar.
* @returns {Object} - A handle with a method to unregister the resize event.
*/
export const renderSidebar = (el, cont, elems) => {
el.appendChild(cont)
if (!el.parentNode) {
return
}
el.parentNode.style.overflowY = 'clip'
cont.style.height = `${el.parentNode.offsetHeight}px`
const resizeHandler = () => {
cont.style.height = `${el.parentNode.offsetHeight}px`
}
window.addEventListener('resize', resizeHandler)
for (const elem of elems) {
cont.appendChild(elem)
}
return {
unregister: () => {
window.removeEventListener('resize', resizeHandler)
},
}
}
/**
* Creates a <select> dropdown with given options.
*
* @param {string[]} options - The options for the select element.
* @param {string} [current] - The currently selected option (optional).
* @returns {HTMLSelectElement} - The created <select> element.
*/
export const makeSelect = (options, current = undefined) => {
const selector = makeElement('select.mtb_select', {
width: 'auto',
margin: '1em',
})
for (const option of options) {
const opt = makeElement('option')
opt.value = option
opt.innerHTML = option
selector.appendChild(opt)
}
if (current !== undefined) {
if (options.includes(current)) {
selector.value = current
} else {
console.error(
`You tried to select an option that doesn't exist (${current}). Options: ${options}`,
)
}
}
return selector
}
/**
* Creates an <input type="range"> slider element with given parameters.
*
* @param {number} min - Minimum value of the slider.
* @param {number} max - Maximum value of the slider.
* @param {number} [value] - Initial value of the slider.
* @param {number} [step] - Step value for the slider.
* @returns {HTMLInputElement} - The created slider element.
*/
export const makeSlider = (min, max, value = undefined, step = undefined) => {
const slider = makeElement('input.mtb_slider', {
width: '100%',
})
slider.type = 'range'
slider.min = min || 0
slider.max = max || 100
slider.value = value || slider.min
slider.step = step || 1
return slider
}
/**
* Creates a button element.
*
* @param {string} label - The label for the button.
* @param {Object} [style] - Optional styles to apply to the button.
* @param {Function} [onClick] - Optional click handler.
* @returns {HTMLButtonElement} - The created button element.
*/
export const makeButton = (label, style = {}, onClick = undefined) => {
const button = makeElement('button.mtb_button', style)
button.textContent = label
if (onClick) {
button.addEventListener('click', onClick)
}
return button
}
/**
* Creates a resizable splitter between two elements.
*
* @param {HTMLElement} el1 - The first element.
* @param {HTMLElement} el2 - The second element.
* @param {'vertical' | 'horizontal'} direction - Splitter direction (vertical or horizontal).
* @param {'absolute' | 'normal'} mode - Splitter mode: 'absolute' for free resizing, 'normal' for layout-based resizing.
* @returns {HTMLDivElement} - The container with resizable splitter.
*/
export const makeSplitter = (
el1,
el2,
direction = 'vertical',
mode = 'normal',
) => {
const container = makeElement('div.mtb_splitter_container', {
display: mode === 'absolute' ? 'block' : 'flex',
flexDirection: direction === 'vertical' ? 'row' : 'column',
position: mode === 'absolute' ? 'relative' : 'static',
height: '100%',
width: '100%',
})
const handle = makeElement('div.mtb_splitter_handle', {
backgroundColor: '#ccc',
cursor: direction === 'vertical' ? 'col-resize' : 'row-resize',
width: direction === 'vertical' ? '5px' : '100%',
height: direction === 'horizontal' ? '5px' : '100%',
})
let isResizing = false
handle.addEventListener('mousedown', () => {
isResizing = true
})
window.addEventListener('mouseup', () => {
isResizing = false
})
window.addEventListener('mousemove', (e) => {
if (!isResizing) return
if (direction === 'vertical') {
const newWidth = e.clientX - container.offsetLeft
el1.style.width = `${newWidth}px`
el2.style.width = `${container.offsetWidth - newWidth}px`
} else {
const newHeight = e.clientY - container.offsetTop
el1.style.height = `${newHeight}px`
el2.style.height = `${container.offsetHeight - newHeight}px`
}
})
container.appendChild(el1)
container.appendChild(handle)
container.appendChild(el2)
return container
}
+1049 -1038
View File
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
// web/note_plus.constants.js
export const DEFAULT_CSS = ''
export const DEFAULT_HTML = `<p style='color:red;font-family:monospace'>
Note+
</p>`
export const DEFAULT_MD = '## Note+'
export const DEFAULT_MODE = 'markdown'
export const DEFAULT_THEME = 'one_dark'
export const DEMO_CONTENT = `
# @mtb/svelte-markdown.
## This is a subheader
[![embedded test](https://github.com/melMass/comfy_mtb/actions/workflows/test_embedded.yml/badge.svg)](https://github.com/melMass/comfy_mtb/actions/workflows/test_embedded.yml)
![home](https://repository-images.githubusercontent.com/649047066/a3eef9a7-20dd-4ef9-b839-884502d4e873)
<details>
<summary>More details about the inception of the project</summary>
\`\`\`js
class YesMan{
constructor(){
this.started = false
}
}
\`\`\`
</details>
This is a paragraph. If it goes over the maximum width it will not automatically wrap unless it reaches the max-w of \`prose\` check [styles](/styles) for more info.
This component is useful for building some tools on top. Or even just a static system using svelte at its core. My personal blog is fully powered by **@mtb/svelte-markdown**
| And this is | A table |
|-------------|---------|
| With two | columns |
We also support github callout:
> [!NOTE]
> Highlights information that users should take into account, even when skimming.
> [!TIP]
> Optional information to help a user be more successful.
> [!IMPORTANT]
> Crucial information necessary for users to succeed.
> [!WARNING]
> Critical content demanding immediate user attention due to potential risks.
> [!CAUTION]
> Negative potential consequences of an action.
`
export const THEMES = [
'ambiance',
'chaos',
'chrome',
'cloud9_day',
'cloud9_night',
'cloud9_night_low_color',
'cloud_editor',
'cloud_editor_dark',
'clouds',
'clouds_midnight',
'cobalt',
'crimson_editor',
'dawn',
'dracula',
'dreamweaver',
'eclipse',
'github',
'github_dark',
'gob',
'gruvbox',
'gruvbox_dark_hard',
'gruvbox_light_hard',
'idle_fingers',
'iplastic',
'katzenmilch',
'kr_theme',
'kuroir',
'merbivore',
'merbivore_soft',
'mono_industrial',
'monokai',
'nord_dark',
'one_dark',
'pastel_on_dark',
'solarized_dark',
'solarized_light',
'sqlserver',
'terminal',
'textmate',
'tomorrow',
'tomorrow_night',
'tomorrow_night_blue',
'tomorrow_night_bright',
'tomorrow_night_eighties',
'twilight',
'vibrant_ink',
'vscode',
]
export const CSS_RESET = `
* {
font-family: monospace;
line-height: 1.25em;
}
.shiki{
padding: 1em;
width: 100%;
}
.markdown-callout-title {
.octicon{
fill:white;
}
/* background: var(--current-color); */
color: var(--current-color);
font-weight: bold;
/* border-start-end-radius: var(--radius); */
/* border-start-start-radius: var(--radius); */
padding: 0.5em;
padding-inline-start: 1em;
}
.markdown-callout-content {
padding: 1em;
}
.markdown-callout {
--radius: 8px;
--current-color: purple;
/* border-start-end-radius: var(--radius); */
/* border-start-start-radius: var(--radius); */
border-left: 3px solid var(--current-color);
margin-bottom: 1em;
margin-top: 1em;
}
.markdown-callout-tip {
--text-color: whitesmoke;
--current-color: #50e3c2;
}
.markdown-callout-note {
--text-color: whitesmoke;
--current-color: #0070f3;
}
.markdown-callout-important {
--text-color: whitesmoke;
--current-color: #7928ca;
}
.markdown-callout-warning {
--current-color: #f5a623;
}
.markdown-callout-caution {
--current-color: #e60000;
}
.note-plus-preview {
display:flex;
flex-direction:column;
align-items: flex-start;
width:95%;
margin-left: 20px;
margin-top:20px;
/*background-color: rgba(255,0,0,0.5)!important;*/
}
/* allowed to be selected*/
h1, h2, h3, h4, h5, h6,a, p, ul, ol, dl, blockquote,details,summary {
pointer-events:auto;
user-select:text;
}
h1, h2, h3, h4, h5, h6 {
display:inline-block;
margin: 0;
padding: 0;
font-weight: normal;
}
p, ul, ol, dl, blockquote {
margin: 0.3em;
padding: 0;
}
ul, ol {
padding-left: 1em;
}
a {
color: inherit;
text-decoration: none;
pointer-events: all;
color: cyan;
}
img {
padding: 1em 0;
max-width: 100%;
}
iframe {
max-width: 100%;
height: auto;
border:none;
pointer-events:all;
}
blockquote {
border-left: 4px solid #ccc;
padding-left: 1em;
margin-left: 0;
font-style: italic;
}
pre, code {
font-family: monospace;
}
table {
border-collapse: collapse;
width: 100%;
border-bottom: 1px solid #000;
margin: 1em 0;
}
th, td {
border-left: 1px solid #000;
border-right: 1px solid #000;
padding: 8px;
text-align: left;
}
th {
border: 1px solid #000;
background-color: rgba(0,0,0,0.5);
}
input[type="checkbox"] {
margin-right: 10px;
}
`
+390 -253
View File
@@ -1,155 +1,122 @@
/// <reference path="../types/typedefs.js" />
import { app } from '../../scripts/app.js'
import * as shared from './comfy_shared.js'
import { infoLogger, successLogger, errorLogger } from './comfy_shared.js'
import {
DEFAULT_CSS,
DEFAULT_HTML,
DEFAULT_MD,
DEFAULT_MODE,
DEFAULT_THEME,
THEMES,
CSS_RESET,
DEMO_CONTENT,
} from './note_plus.constants.js'
import { LocalStorageManager } from './comfy_shared.js'
const DEFAULT_CSS = ''
const DEFAULT_HTML = `<p style='color:red;font-family:monospace'>
Note+
</p>`
const DEFAULT_MD = '## Note+'
const DEFAULT_MODE = 'markdown'
const DEFAULT_THEME = 'one_dark'
const storage = new LocalStorageManager('mtb')
const CSS_RESET = `
* {
font-family: monospace;
line-height: 1.25em;
/**
* Uses `@mtb/markdown-parser` (a fork of marked)
* It is statically stored to avoid having
* more than 1 instance ever.
* The size difference between both libraries...
* ╭───┬────────────────────────────────┬──────────╮
* │ # │ name │ size │
* ├───┼────────────────────────────────┼──────────┤
* │ 0 │ web-dist/mtb_markdown_plus.mjs │ 1.2 MB │ <- with shiki
* │ 1 │ web-dist/mtb_markdown.mjs │ 44.7 KB │
* ╰───┴────────────────────────────────┴──────────╯
*/
let useShiki = storage.get('np-use-shiki', false)
const makeResizable = (dialog) => {
dialog.style.resize = 'both'
dialog.style.transformOrigin = 'top left'
dialog.style.overflow = 'auto'
}
h1, h2, h3, h4, h5, h6 {
margin: 0;
padding: 0;
font-weight: normal;
const makeDraggable = (dialog, handle) => {
let offsetX = 0
let offsetY = 0
let isDragging = false
const onMouseMove = (e) => {
if (isDragging) {
dialog.style.left = `${e.clientX - offsetX}px`
dialog.style.top = `${e.clientY - offsetY}px`
}
}
const onMouseUp = () => {
isDragging = false
document.removeEventListener('mousemove', onMouseMove)
document.removeEventListener('mouseup', onMouseUp)
}
handle.addEventListener('mousedown', (e) => {
isDragging = true
offsetX = e.clientX - dialog.offsetLeft
offsetY = e.clientY - dialog.offsetTop
document.addEventListener('mousemove', onMouseMove)
document.addEventListener('mouseup', onMouseUp)
})
}
p, ul, ol, dl, blockquote {
margin: 0.3em;
padding: 0;
}
ul, ol {
padding-left: 1em;
}
a {
color: inherit;
text-decoration: none;
pointer-events: all;
color: cyan;
}
img {
padding: 1em 0;
max-width: 100%;
}
iframe {
width: 100%;
height: auto;
border:none;
pointer-events:all;
}
blockquote {
border-left: 4px solid #ccc;
padding-left: 1em;
margin-left: 0;
font-style: italic;
}
pre, code {
font-family: monospace;
}
table {
border-collapse: collapse;
width: 100%;
border-bottom: 1px solid #000;
margin: 1em 0;
}
th, td {
border-left: 1px solid #000;
border-right: 1px solid #000;
padding: 8px;
text-align: left;
}
th {
border: 1px solid #000;
background-color: rgba(0,0,0,0.5);
}
input[type="checkbox"] {
margin-right: 10px;
}
`
const themes = [
'ambiance',
'chaos',
'chrome',
'cloud9_day',
'cloud9_night',
'cloud9_night_low_color',
'cloud_editor',
'cloud_editor_dark',
'clouds',
'clouds_midnight',
'cobalt',
'crimson_editor',
'dawn',
'dracula',
'dreamweaver',
'eclipse',
'github',
'github_dark',
'gob',
'gruvbox',
'gruvbox_dark_hard',
'gruvbox_light_hard',
'idle_fingers',
'iplastic',
'katzenmilch',
'kr_theme',
'kuroir',
'merbivore',
'merbivore_soft',
'mono_industrial',
'monokai',
'nord_dark',
'one_dark',
'pastel_on_dark',
'solarized_dark',
'solarized_light',
'sqlserver',
'terminal',
'textmate',
'tomorrow',
'tomorrow_night',
'tomorrow_night_blue',
'tomorrow_night_bright',
'tomorrow_night_eighties',
'twilight',
'vibrant_ink',
'vscode',
]
/** @extends {LGraphNode} */
class NotePlus extends LiteGraph.LGraphNode {
// same values as the comfy note
color = LGraphCanvas.node_colors.yellow.color
bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
/* NOTE: this is not serialized and only there to make multiple
* note+ nodes in the same graph unique.
*/
uuid
/** Stores the dialog observer*/
resizeObserver
/** Live update the preview*/
live = true
/** DOM height by adding child size together*/
calculated_height = 0
/** ????*/
_raw_html
/** might not be needed anymore */
inner
/** the dialog DOM widget*/
dialog
/** widgets*/
/** used to store the raw value and display the parsed html at the same time*/
html_widget
/** hidden widgets for serialization*/
css_widget
edit_mode_widget
theme_widget
editorsContainer
/** ACE editors instances*/
html_editor
css_editor
constructor() {
super()
this.uuid = shared.makeUUID()
infoLogger('Constructing Note+ instance')
shared.ensureMarkdownParser((_p) => {
this.updateHTML()
})
// - litegraph settings
this.collapsable = true
this.isVirtualNode = true
@@ -159,35 +126,30 @@ class NotePlus extends LiteGraph.LGraphNode {
// - default values, serialization is done through widgets
this._raw_html = DEFAULT_MODE === 'html' ? DEFAULT_HTML : DEFAULT_MD
// - mardown converter
this.markdownConverter = new showdown.Converter({
tables: true,
strikethrough: true,
emoji: true,
ghCodeBlocks: true,
tasklists: true,
ghMentions: true,
smoothLivePreview: true,
simplifiedAutoLink: true,
parseImgDimensions: true,
openLinksInNewWindow: true,
})
// - state
this.live = true
this.calculated_height = 0
// - add widgets
const inner = document.createElement('div')
inner.style.margin = '0'
inner.style.padding = '0'
inner.style.pointerEvents = 'none'
this.html_widget = this.addDOMWidget('HTML', 'html', inner, {
const cinner = document.createElement('div')
this.inner = document.createElement('div')
cinner.append(this.inner)
this.inner.classList.add('note-plus-preview')
cinner.style.margin = '0'
cinner.style.padding = '0'
this.html_widget = this.addDOMWidget('HTML', 'html', cinner, {
setValue: (val) => {
this._raw_html = val
},
getValue: () => this._raw_html,
getMinHeight: () => this.calculated_height, // (the edit button),
onDraw: () => {
// HACK: dirty hack for now until it's addressed upstream...
this.html_widget.element.style.pointerEvents = 'none'
// NOTE: not sure about this, it avoid the visual "bugs" but scrolling over the wrong area will affect zoom...
// this.html_widget.element.style.overflow = 'scroll'
},
hideOnZoom: false,
})
@@ -197,22 +159,48 @@ class NotePlus extends LiteGraph.LGraphNode {
}
/**
*
* @param {CanvasRenderingContext2D} ctx
* @param {LGraphCanvas} graphcanvas
* @returns
* @param {CanvasRenderingContext2D} ctx canvas context
* @param {any} _graphcanvas
*/
onDrawForeground(ctx, _graphcanvas) {
if (this.flags.collapsed) return
this.drawEditIcon(ctx)
this.drawSideHandle(ctx)
// Define the size and position of the icon
const iconSize = 14 // Size of the icon
const iconMargin = 8 // Margin from the edges
const x = this.size[0] - iconSize - iconMargin
const y = iconMargin * 1.5
// DEBUG BACKGROUND
// ctx.fillStyle = 'rgba(0, 255, 0, 0.3)'
// const rect = this.rect
// ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
}
drawSideHandle(ctx) {
const handleRect = this.sideHandleRect
const chamfer = 20
ctx.beginPath()
// top left
ctx.moveTo(handleRect.x, handleRect.y + chamfer)
// top right
ctx.lineTo(handleRect.x + handleRect.width, handleRect.y)
// bottom right
ctx.lineTo(
handleRect.x + handleRect.width,
handleRect.y + handleRect.height,
)
// bottom left
ctx.lineTo(handleRect.x, handleRect.y + handleRect.height - chamfer)
ctx.closePath()
ctx.fillStyle = 'rgba(255, 255, 255, 0.05)'
ctx.fill()
}
drawEditIcon(ctx) {
const rect = this.iconRect
// DEBUG ICON POSITION
// ctx.fillStyle = 'rgba(0, 255, 0, 0.3)'
// ctx.fillRect(rect.x, rect.y, rect.width, rect.height)
// Create a new Path2D object from SVG path data
const pencilPath = new Path2D(
'M21.28 6.4l-9.54 9.54c-.95.95-3.77 1.39-4.4.76-.63-.63-.2-3.45.75-4.4l9.55-9.55a2.58 2.58 0 1 1 3.64 3.65z',
)
@@ -220,41 +208,73 @@ class NotePlus extends LiteGraph.LGraphNode {
'M11 4H6a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h11c2.21 0 3-1.8 3-4v-5',
)
// Draw the paths
ctx.save()
ctx.translate(x, y) // Position the icon on the canvas
ctx.scale(iconSize / 32, iconSize / 32) // Scale the icon to the desired size
ctx.strokeStyle = 'rgba(255,255,255,0.3)'
ctx.translate(rect.x, rect.y)
ctx.scale(rect.width / 32, rect.height / 32)
ctx.strokeStyle = 'rgba(255,255,255,0.4)'
ctx.lineCap = 'round'
ctx.lineJoin = 'round'
ctx.lineWidth = 2.4
ctx.stroke(pencilPath)
ctx.stroke(folderPath)
ctx.restore()
}
onMouseDown(_e, localPos, _graphcanvas) {
// Check if the click is within the pencil icon bounds
const iconSize = 14
const iconMargin = 8
const iconX = this.size[0] - iconSize - iconMargin
const iconY = iconMargin * 1.5
if (
localPos[0] > iconX &&
localPos[0] < iconX + iconSize &&
localPos[1] > iconY &&
localPos[1] < iconY + iconSize
) {
// Pencil icon was clicked, open the editor
this.openEditorDialog()
return true // Return true to indicate the event was handled
/**
* @param {number} x
* @param {number} y
* @param {{x:number,y:number,width:number,height:number}} rect
* @returns {}
*/
inRect(x, y, rect) {
rect = rect || this.iconRect
return (
x >= rect.x &&
x <= rect.x + rect.width &&
y >= rect.y &&
y <= rect.y + rect.height
)
}
get rect() {
return {
x: 0,
y: 0,
width: this.size[0],
height: this.size[1],
}
}
get sideHandleRect() {
const w = this.size[0]
const h = this.size[1]
return false // Return false to let the event propagate
const bw = 32
const bho = 64
return {
x: w - bw,
y: bho,
width: bw,
height: h - bho * 1.5,
}
}
get iconRect() {
const iconSize = 32
const iconMargin = 16
return {
x: this.size[0] - iconSize - iconMargin,
y: iconMargin * 1.5,
width: iconSize,
height: iconSize,
}
}
onMouseDown(_e, localPos, _graphcanvas) {
if (this.inRect(localPos[0], localPos[1])) {
this.openEditorDialog()
return true
}
return false
}
/* Hidden widgets to store note+ settings in the workflow (stripped in API)*/
setupSerializationWidgets() {
infoLogger('Setup Serializing widgets')
@@ -283,15 +303,36 @@ class NotePlus extends LiteGraph.LGraphNode {
shared.hideWidgetForGood(this, this.css_widget)
shared.hideWidgetForGood(this, this.theme_widget)
}
setupDialog() {
infoLogger('Setup dialog')
// this.addWidget('button', 'Edit', 'Edit', this.openEditorDialog.bind(this))
this.dialog = new app.ui.dialog.constructor()
this.dialog.element.classList.add('comfy-settings')
Object.assign(this.dialog.element.style, {
position: 'absolute',
boxShadow: 'none',
})
const subcontainer = this.dialog.textElement.parentElement
if (subcontainer) {
Object.assign(subcontainer.style, {
width: '100%',
})
}
const closeButton = this.dialog.element.querySelector('button')
closeButton.textContent = 'CANCEL'
closeButton.id = 'cancel-editor-dialog'
closeButton.title =
"Cancel the changes since last opened (doesn't support live mode)"
closeButton.disabled = this.live
closeButton.style.background = this.live
? 'repeating-linear-gradient(45deg,#606dbc,#606dbc 10px,#465298 10px,#465298 20px)'
: ''
const saveButton = document.createElement('button')
saveButton.textContent = 'SAVE'
saveButton.onclick = () => {
@@ -313,32 +354,54 @@ class NotePlus extends LiteGraph.LGraphNode {
closeEditorDialog(accept) {
infoLogger('Closing editor dialog', accept)
if (accept) {
if (accept && !this.live) {
this.updateHTML(this.html_editor.getValue())
this.updateCSS(this.css_editor.getValue())
}
if (this.resizeObserver) {
this.resizeObserver.disconnect()
this.resizeObserver = null
}
this.teardownEditors()
this.dialog.close()
}
/**
* @param {HTMLElement} elem
*/
hookResize(elem) {
if (!this.resizeObserver) {
const observer = () => {
this.html_editor.resize()
this.css_editor.resize()
Object.assign(this.editorsContainer.style, {
minHeight: `${(this.dialog.element.clientHeight / 100) * 50}px`, //'200px',
})
}
this.resizeObserver = new ResizeObserver(observer).observe(elem)
}
}
openEditorDialog() {
infoLogger(`Current edit mode ${this.edit_mode_widget.value}`)
this.hookResize(this.dialog.element)
const container = document.createElement('div')
Object.assign(container.style, {
display: 'flex',
gap: '10px',
flexDirection: 'column',
})
const editorsContainer = document.createElement('div')
Object.assign(editorsContainer.style, {
this.editorsContainer = document.createElement('div')
Object.assign(this.editorsContainer.style, {
display: 'flex',
gap: '10px',
flexDirection: 'row',
minHeight: this.dialog.element.offsetHeight, //'200px',
width: '100%',
})
container.append(editorsContainer)
container.append(this.editorsContainer)
this.dialog.show('')
this.dialog.textElement.append(container)
@@ -346,30 +409,39 @@ class NotePlus extends LiteGraph.LGraphNode {
const aceHTML = document.createElement('div')
aceHTML.id = 'noteplus-html-editor'
Object.assign(aceHTML.style, {
width: '300px',
height: '300px',
// backgroundColor: 'rgb(30,30,30)',
// color: 'whitesmoke',
width: '100%',
height: '100%',
minWidth: '300px',
minHeight: 'inherit',
})
editorsContainer.append(aceHTML)
this.editorsContainer.append(aceHTML)
const aceCSS = document.createElement('div')
aceCSS.id = 'noteplus-css-editor'
Object.assign(aceCSS.style, {
width: '300px',
height: '300px',
// backgroundColor: 'rgb(30,30,30)',
// color: 'whitesmoke',
width: '100%',
height: '100%',
minHeight: 'inherit',
})
editorsContainer.append(aceCSS)
this.editorsContainer.append(aceCSS)
const live_edit = document.createElement('input')
live_edit.type = 'checkbox'
live_edit.checked = this.live
live_edit.onchange = () => {
this.live = live_edit.checked
const cancel_button = this.dialog.element.querySelector(
'#cancel-editor-dialog',
)
if (cancel_button) {
cancel_button.disabled = this.live
cancel_button.style.background = this.live
? 'repeating-linear-gradient(45deg,#606dbc,#606dbc 10px,#465298 10px,#465298 20px)'
: ''
}
}
//- "Dynamic" elements
@@ -388,15 +460,14 @@ class NotePlus extends LiteGraph.LGraphNode {
const md = this.html_editor.getValue()
this.edit_mode_widget.value = 'html'
select_mode.value = 'html'
const html = this.markdownConverter.makeHtml(md)
this.html_widget.value = html
this.html_editor.setValue(html)
this.html_editor.session.setMode('ace/mode/html')
this.updateHTML(this.html_widget.value)
convert_to_html.remove()
MTB.mdParser.parse(md).then((content) => {
this.html_widget.value = content
this.html_editor.setValue(content)
this.html_editor.session.setMode('ace/mode/html')
this.updateHTML(this.html_widget.value)
convert_to_html.remove()
})
}
firstButton.before(convert_to_html)
}
} else {
@@ -406,6 +477,19 @@ class NotePlus extends LiteGraph.LGraphNode {
}
}
select_mode.value = this.edit_mode_widget.value
// the header for dragging the dialog
const header = document.createElement('div')
header.style.padding = '8px'
header.style.cursor = 'move'
header.style.backgroundColor = 'rgba(0,0,0,0.5)'
header.style.userSelect = 'none'
header.style.borderBottom = '1px solid #ddd'
header.textContent = 'MTB Note+ Editor'
container.prepend(header)
makeDraggable(this.dialog.element, header)
makeResizable(this.dialog.element)
}
//- combobox
let theme_select = this.dialog.element.querySelector('#theme_select')
@@ -421,7 +505,7 @@ class NotePlus extends LiteGraph.LGraphNode {
option.textContent = label
theme_select.append(option)
}
for (const t of themes) {
for (const t of THEMES) {
addOption(t)
}
@@ -491,54 +575,59 @@ class NotePlus extends LiteGraph.LGraphNode {
onCreate() {
errorLogger('NotePlus onCreate')
}
configure(info) {
super.configure(info)
infoLogger('Restoring serialized values', info)
// - update view from serialzed data
restoreNodeState(info) {
this.html_widget.element.id = `note-plus-${this.uuid}`
this.setMode(this.edit_mode_widget.value)
this.setTheme(this.theme_widget.value)
this.updateHTML(this.html_widget.value)
this.updateCSS(this.css_widget.value)
this.setSize(info.size)
if (info?.size) {
this.setSize(info.size)
}
}
configure(info) {
super.configure(info)
infoLogger('Restoring serialized values', info)
this.restoreNodeState(info)
// - update view from serialzed data
}
onNodeCreated() {
infoLogger('Node created', this.uuid)
this.html_widget.element.id = `note-plus-${this.uuid}`
this.setMode(this.edit_mode_widget.value)
this.setTheme(this.theme_widget.value)
this.updateHTML(this.html_widget.value) // widget is populated here since we called super
this.updateCSS(this.css_widget.value)
this.restoreNodeState({})
// this.html_widget.element.id = `note-plus-${this.uuid}`
// this.setMode(this.edit_mode_widget.value)
// this.setTheme(this.theme_widget.value)
// this.updateHTML(this.html_widget.value) // widget is populated here since we called super
// this.updateCSS(this.css_widget.value)
}
onRemoved() {
infoLogger('Node removed', this.uuid)
}
getExtraMenuOptions() {
const options = []
// {
// content: string;
// callback?: ContextMenuEventListener;
// /** Used as innerHTML for extra child element */
// title?: string;
// disabled?: boolean;
// has_submenu?: boolean;
// submenu?: {
// options: ContextMenuItem[];
// } & IContextMenuOptions;
// className?: string;
// }
options.push({
content: `Set to ${
this.edit_mode_widget.value === 'html' ? 'markdown' : 'html'
}`,
callback: () => {
this.edit_mode_widget.value =
this.edit_mode_widget.value === 'html' ? 'markdown' : 'html'
this.updateHTML(this.html_widget.value)
},
})
const currentMode = this.edit_mode_widget.value
const newMode = currentMode === 'html' ? 'markdown' : 'html'
return options
const debugItems = window.MTB?.DEBUG
? [
{
content: 'Replace with demo content (debug)',
callback: () => {
this.html_widget.value = DEMO_CONTENT
},
},
]
: []
return [
...debugItems,
{
content: `Set to ${newMode}`,
callback: () => {
this.edit_mode_widget.value = newMode
this.updateHTML(this.html_widget.value)
},
},
]
}
_setupEditor(editor) {
@@ -663,17 +752,44 @@ class NotePlus extends LiteGraph.LGraphNode {
// this.setSize(this.computeSize())
}
updateHTML(val) {
const cleanHTML = DOMPurify.sanitize(val, { ADD_TAGS: ['iframe'] })
this.html_widget.value = cleanHTML
parserInitiated() {
if (window.MTB?.mdParser) return true
return false
}
// update our widget preview
if (this.edit_mode_widget.value === 'html') {
this.html_widget.element.innerHTML = cleanHTML
} else if (this.edit_mode_widget.value === 'markdown') {
this.html_widget.element.innerHTML =
this.markdownConverter.makeHtml(cleanHTML)
/** to easilty swap purification methods*/
purify(content) {
return DOMPurify.sanitize(content, {
ADD_TAGS: ['iframe', 'detail', 'summary'],
})
}
updateHTML(val) {
if (!this.parserInitiated()) {
return
}
val = val || this.html_widget.value
const isHTML = this.edit_mode_widget.value === 'html'
const cleanHTML = this.purify(val)
const value = isHTML
? cleanHTML
: cleanHTML.replaceAll('&gt;', '>').replaceAll('&lt;', '<')
// .replaceAll('&amp;', '&')
// .replaceAll('&quot;', '"')
// .replaceAll('&#039;', "'")
this.html_widget.value = value
if (isHTML) {
this.inner.innerHTML = value
} else {
MTB.mdParser.parse(value).then((e) => {
this.inner.innerHTML = e
})
}
// this.html_widget.element.innerHTML = `<div id="note-plus-spacer"></div>${value}`
this.calculateHeight()
// this.setSize(this.computeSize())
}
@@ -681,6 +797,27 @@ class NotePlus extends LiteGraph.LGraphNode {
app.registerExtension({
name: 'mtb.noteplus',
setup: () => {
app.ui.settings.addSetting({
id: 'mtb.noteplus.use-shiki',
category: ['mtb', 'Note+', 'use-shiki'],
name: 'Use shiki to highlight code',
tooltip:
'This will load a larger version of @mtb/markdown-parser that bundles shiki, it supports all shiki transformers (supported langs: html,css,python,markdown)',
type: 'boolean',
defaultValue: false,
attrs: {
style: {
// fontFamily: 'monospace',
},
},
async onChange(value) {
storage.set('np-use-shiki', value)
useShiki = value
},
})
},
registerCustomNodes() {
LiteGraph.registerNodeType('Note Plus (mtb)', NotePlus)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long