Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90d96366c8 | ||
|
|
605c8db320 | ||
|
|
cf965727e8 | ||
|
|
12b134ab4c | ||
|
|
dd27f990c7 | ||
|
|
16c1a59312 | ||
|
|
59a361af58 | ||
|
|
e4da832b99 | ||
|
|
14ee9e23c0 | ||
|
|
53cb503866 | ||
|
|
d5c4c5f264 | ||
|
|
87e301d120 | ||
|
|
537a0d8108 | ||
|
|
9afad1a168 | ||
|
|
142624eea6 | ||
|
|
c8658dfbdd | ||
|
|
403903798a | ||
|
|
4e07450bca | ||
|
|
bcac66508d | ||
|
|
6b993b8407 | ||
|
|
049983dbe2 | ||
|
|
255ac036ba | ||
|
|
8d12b59844 | ||
|
|
7812cfa3c2 | ||
|
|
278f22c209 | ||
|
|
e6f6502673 | ||
|
|
5af284067c | ||
|
|
d7b8ac8e0c | ||
|
|
af94203d1b | ||
|
|
bb90e0415f | ||
|
|
3e8c2fe789 | ||
|
|
3e93ea6f2c | ||
|
|
cea0b08eb0 | ||
|
|
5b75436610 | ||
|
|
a798eb07d0 | ||
|
|
25b933c698 | ||
|
|
5dfea51dd8 | ||
|
|
f1ff9fc7c4 | ||
|
|
c1d42de0fc | ||
|
|
4605f74f37 | ||
|
|
8f909864bf | ||
|
|
4917e31c42 | ||
|
|
cef5023efc | ||
|
|
bb3277d85f | ||
|
|
dc500b788e | ||
|
|
21acc87ff0 | ||
|
|
d49b2578c2 | ||
|
|
87b245c6a6 | ||
|
|
38df58a78c | ||
|
|
90aee83797 | ||
|
|
a50b11bdaa | ||
|
|
88a2779687 | ||
|
|
da290dbcf2 | ||
|
|
b949bb406b | ||
|
|
cdd098e102 | ||
|
|
cbdb816164 | ||
|
|
11162b3ea7 | ||
|
|
638498c6b4 | ||
|
|
2faa2f2a14 | ||
|
|
6a00d1da5a | ||
|
|
cc43654af2 | ||
|
|
e11df9d45c | ||
|
|
616b2bfc6c | ||
|
|
22cac9b2d9 | ||
|
|
bb35098c65 | ||
|
|
e2773ff22e | ||
|
|
3b07984716 | ||
|
|
fe8f519f88 | ||
|
|
a71c273baf | ||
|
|
49c64c74eb | ||
|
|
2ecd4700d7 | ||
|
|
ea5d73d48c | ||
|
|
30d6cfe812 | ||
|
|
610afe031f | ||
|
|
a4d99d966b | ||
|
|
4fc84d615d | ||
|
|
8523392df7 | ||
|
|
dbdb872b74 | ||
|
|
40560f8154 | ||
|
|
e7f72f9825 | ||
|
|
11444662b9 | ||
|
|
2eccba4e33 | ||
|
|
5ec5511433 | ||
|
|
630b492347 | ||
|
|
4f30829e06 | ||
|
|
414beb99a1 | ||
|
|
3f14b1676d | ||
|
|
9c2e8ac57c | ||
|
|
4dd5321852 | ||
|
|
91f60d4c46 | ||
|
|
fb644847ca | ||
|
|
84ac8ac852 | ||
|
|
63b3aece2b | ||
|
|
a54d7d5346 | ||
|
|
13d255a730 | ||
|
|
2bc7ae88bf | ||
|
|
0fb2d4da90 | ||
|
|
cfb3b237cf | ||
|
|
3d5075fea2 | ||
|
|
098d74a3cd | ||
|
|
e74314b04e | ||
|
|
d4f791d7a1 | ||
|
|
2ff04672da | ||
|
|
b854a302ce | ||
|
|
512de6023e | ||
|
|
c5bbe83008 | ||
|
|
7b3afca817 | ||
|
|
bbfcb62c39 | ||
|
|
a22fd01d66 | ||
|
|
8e5b7765cc | ||
|
|
36d8e6bdb0 | ||
|
|
3dadc119f4 | ||
|
|
ffa1a87b91 | ||
|
|
346ff649d5 |
@@ -2,7 +2,9 @@ name: 🐞 Bug Report
|
||||
title: "[bug] "
|
||||
description: Report a bug
|
||||
labels: ["type: 🐛 bug", "status: 🧹 needs triage"]
|
||||
|
||||
assignees:
|
||||
- melMass
|
||||
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
@@ -40,16 +42,30 @@ body:
|
||||
label: Expected behavior
|
||||
description: A clear description of what you expected to happen.
|
||||
|
||||
- type: textarea
|
||||
id: info
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Platform and versions
|
||||
description: "informations about the environment you run Comfy in"
|
||||
render: sh
|
||||
placeholder: |
|
||||
- OS: [e.g. Linux]
|
||||
- Comfy Mode [e.g. custom env, standalone, google colab]
|
||||
|
||||
label: Operating System
|
||||
description: What OS are you using?
|
||||
options:
|
||||
- Windows (Default)
|
||||
- Linux
|
||||
- Mac
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: comfy_mode
|
||||
attributes:
|
||||
label: Comfy Mode
|
||||
description: What flavor of Comfy do you use?
|
||||
options:
|
||||
- Comfy Portable (embed) (Default)
|
||||
- In a custom virtual env (venv, virtualenv, conda...)
|
||||
- Google Colab
|
||||
- Other (online services, containers etc..)
|
||||
default: 0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
|
||||
@@ -76,4 +76,4 @@ jobs:
|
||||
uses: actions/cache/save@v3
|
||||
with:
|
||||
path: ${{ env.archive_name }}.zip
|
||||
key: ${{ env.archive_name }}
|
||||
key: ${{ env.archive_name }}-${{ hashFiles('reqs.txt') }}
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
name:
|
||||
description: Release tag / name ?
|
||||
required: true
|
||||
default: "latest"
|
||||
default: 'latest'
|
||||
type: string
|
||||
environment:
|
||||
description: Environment to run tests against
|
||||
@@ -27,9 +27,9 @@ jobs:
|
||||
- name: ♻️ Checking out the repository
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: "recursive"
|
||||
submodules: 'recursive'
|
||||
path: ${{ env.repo_name }}
|
||||
|
||||
|
||||
# - name: 📝 Prepare file with paths to remove
|
||||
# run: |
|
||||
# find ${{ env.repo_name }} -type f -size +10M > .release_ignore
|
||||
@@ -56,7 +56,7 @@ jobs:
|
||||
else
|
||||
echo "No .release_ignore file found. Skipping removal of files and directories."
|
||||
fi
|
||||
|
||||
|
||||
- name: 📦 Building custom comfy nodes
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -98,10 +98,18 @@ jobs:
|
||||
id: cache
|
||||
with:
|
||||
path: ${{ env.archive_name }}.zip
|
||||
key: ${{ env.archive_name }}
|
||||
key: ${{ env.archive_name }}-${{ hashFiles('reqs.txt') }}
|
||||
- name: 📦 Unzip wheels
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p wheels
|
||||
unzip -j ${{ env.archive_name }}.zip "**/*.whl" -d wheels
|
||||
unzip -j ${{ env.archive_name }}.zip "**/*.txt" -d wheels
|
||||
if: success()
|
||||
- name: ✅ Add wheels to release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: ${{ inputs.name }}
|
||||
files: |
|
||||
${{ env.archive_name }}.zip
|
||||
wheels/*.whl
|
||||
wheels/wheel_order.txt
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
name: 🧪 Test Comfy Portable
|
||||
|
||||
on: workflow_dispatch
|
||||
jobs:
|
||||
install-comfy:
|
||||
runs-on: windows-latest
|
||||
env:
|
||||
repo_name: ${{ github.event.repository.name }}
|
||||
steps:
|
||||
- name: ⚡️ Restore Cache if Available
|
||||
id: cache-comfy
|
||||
uses: actions/cache/restore@v3
|
||||
with:
|
||||
path: ComfyUI_windows_portable
|
||||
key: ${{ runner.os }}-comfy-env
|
||||
|
||||
- name: 🚡 Download and Extract Comfy
|
||||
id: download-extract-comfy
|
||||
if: steps.cache-comfy.outputs.cache-hit != 'true'
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir comfy_temp
|
||||
curl -L -o comfy_temp/comfyui.7z https://github.com/comfyanonymous/ComfyUI/releases/download/latest/ComfyUI_windows_portable_nvidia_cu118_or_cpu.7z
|
||||
|
||||
7z x comfy_temp/comfyui.7z -o./comfy_temp
|
||||
|
||||
|
||||
# mv comfy_temp/ComfyUI_windows_portable/python_embeded .
|
||||
# mv comfy_temp/ComfyUI_windows_portable/ComfyUI .
|
||||
# mv comfy_temp/ComfyUI_windows_portable/update .
|
||||
ls
|
||||
mv comfy_temp/ComfyUI_windows_portable .
|
||||
|
||||
- name: 💾 Store cache
|
||||
uses: actions/cache/save@v3
|
||||
if: steps.cache-comfy.outputs.cache-hit != 'true'
|
||||
with:
|
||||
path: ComfyUI_windows_portable
|
||||
key: ${{ runner.os }}-comfy-env
|
||||
- name: ⏬ Install other extensions
|
||||
shell: bash
|
||||
run: |
|
||||
export COMFY_PYTHON="${GITHUB_WORKSPACE}/ComfyUI_windows_portable/python_embeded/python.exe"
|
||||
cd "${GITHUB_WORKSPACE}/ComfyUI_windows_portable/ComfyUI/custom_nodes"
|
||||
|
||||
git clone https://github.com/Fannovel16/comfy_controlnet_preprocessors
|
||||
cd comfy_controlnet_preprocessors
|
||||
$COMFY_PYTHON -m pip install -r requirements.txt
|
||||
|
||||
- name: ♻️ Checking out comfy_mtb to custom_nodes
|
||||
uses: actions/checkout@v3
|
||||
with:
|
||||
submodules: 'recursive'
|
||||
path: ComfyUI_windows_portable/ComfyUI/custom_nodes/${{ env.repo_name }}
|
||||
|
||||
- name: 📦 Install mtb nodes
|
||||
shell: bash
|
||||
run: |
|
||||
# run install
|
||||
export COMFY_PYTHON="${GITHUB_WORKSPACE}/ComfyUI_windows_portable/python_embeded/python.exe"
|
||||
cd "${GITHUB_WORKSPACE}/ComfyUI_windows_portable/ComfyUI/custom_nodes"
|
||||
$COMFY_PYTHON ${{ env.repo_name }}/install.py -w
|
||||
|
||||
- name: ⏬ Import mtb_nodes
|
||||
shell: bash
|
||||
run: |
|
||||
export COMFY_PYTHON="${GITHUB_WORKSPACE}/ComfyUI_windows_portable/python_embeded/python.exe"
|
||||
cd "${GITHUB_WORKSPACE}/ComfyUI_windows_portable/ComfyUI"
|
||||
$COMFY_PYTHON -s main.py --quick-test-for-ci --cpu
|
||||
|
||||
$COMFY_PYTHON -m pip freeze
|
||||
@@ -4,7 +4,6 @@
|
||||
- [ComfyUI Manager](#comfyui-manager)
|
||||
- [Virtual Env](#virtual-env)
|
||||
- [Models Download](#models-download)
|
||||
- [Web Extensions](#web-extensions)
|
||||
- [Old installation method (MANUAL)](#old-installation-method-manual)
|
||||
- [Dependencies](#dependencies)
|
||||
|
||||
@@ -35,11 +34,6 @@ then follow the prompt or just press enter to download every models.
|
||||
python scripts/download_models.py -y
|
||||
```
|
||||
|
||||
### Web Extensions
|
||||
|
||||
On first run the script [tries to symlink](https://github.com/melMass/comfy_mtb/blob/d982b69a58c05ccead9c49370764beaa4549992a/__init__.py#L45-L61) the [web extensions](https://github.com/melMass/comfy_mtb/tree/main/web) to your comfy `web/extensions` folder. In case it fails you can manually copy the mtb folder to `ComfyUI/web/extensions` it only provides a color widget for now shared by a few nodes:
|
||||
|
||||
<img alt="color widget preview" src="https://github.com/melMass/comfy_mtb/assets/7041726/cff7e66a-4cc4-4866-b35b-10af0bb2d110" width=450>
|
||||
|
||||
## Old installation method (MANUAL)
|
||||
### Dependencies
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
# MTB Nodes
|
||||
[](https://github.com/melMass/comfy_mtb/actions/workflows/test_embedded.yml)
|
||||
|
||||

|
||||
|
||||
<!-- omit in toc -->
|
||||
|
||||
**Translated Readme (using DeepTranslate, PRs are welcome)**:
|
||||
@@ -15,20 +19,50 @@ Welcome to the MTB Nodes project! This codebase is open for you to explore and u
|
||||
|
||||
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)
|
||||
- [face detection / swapping](#face-detection--swapping)
|
||||
- [image interpolation (animation)](#image-interpolation-animation)
|
||||
- [image ops](#image-ops)
|
||||
- [latent utils](#latent-utils)
|
||||
- [misc utils](#misc-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" />
|
||||
|
||||
|
||||
# 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
|
||||
@@ -40,21 +74,7 @@ Before proceeding, please be aware of the licenses associated with certain libra
|
||||
- `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=345/>
|
||||
|
||||
## face detection / swapping
|
||||
- `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)
|
||||
> **Note**
|
||||
> The face index allow you to choose which face to replace as you can see here:
|
||||
<img src="https://github.com/melMass/comfy_mtb/assets/7041726/2e9d6066-c466-4a01-bd6c-315f7f1e8b42" width=320/>
|
||||
- `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)
|
||||
- `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 src="https://github.com/melMass/comfy_mtb/assets/7041726/3afd1647-6634-4b92-a34b-51432e6a9834" width=400/>
|
||||
- `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.
|
||||
<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.
|
||||
@@ -71,8 +91,16 @@ Before proceeding, please be aware of the licenses associated with certain libra
|
||||
## 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
|
||||
@@ -83,13 +111,57 @@ Before proceeding, please be aware of the licenses associated with certain libra
|
||||
- `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
|
||||
|
||||
## textures
|
||||
|
||||
- `DeepBump`: Normal & height maps generation from single pictures
|
||||
## 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
|
||||
|
||||
+96
-74
@@ -13,26 +13,37 @@ import os
|
||||
os.environ["TF_FORCE_GPU_ALLOW_GROWTH"] = "true"
|
||||
os.environ["TF_GPU_ALLOCATOR"] = "cuda_malloc_async"
|
||||
|
||||
import traceback
|
||||
from .log import log, blue_text, cyan_text, get_summary, get_label
|
||||
from .utils import here
|
||||
from .utils import comfy_dir
|
||||
import importlib
|
||||
import os
|
||||
import ast
|
||||
import contextlib
|
||||
import importlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import traceback
|
||||
from importlib import reload
|
||||
|
||||
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 = {}
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
__version__ = "0.1.2"
|
||||
__version__ = "0.2.0"
|
||||
|
||||
|
||||
def extract_nodes_from_source(filename):
|
||||
source_code = ""
|
||||
|
||||
with open(filename, "r") as file:
|
||||
with open(filename, "r", encoding="utf8") as file:
|
||||
source_code = file.read()
|
||||
|
||||
nodes = []
|
||||
@@ -45,19 +56,15 @@ def extract_nodes_from_source(filename):
|
||||
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) or isinstance(
|
||||
node_value, ast.Tuple
|
||||
):
|
||||
for element in node_value.elts:
|
||||
if isinstance(element, ast.Name):
|
||||
print(element.id)
|
||||
nodes.append(element.id)
|
||||
|
||||
if isinstance(node_value, (ast.List, ast.Tuple)):
|
||||
nodes.extend(
|
||||
element.id
|
||||
for element in node_value.elts
|
||||
if isinstance(element, ast.Name)
|
||||
)
|
||||
break
|
||||
except SyntaxError:
|
||||
log.error("Failed to parse")
|
||||
pass # File couldn't be parsed
|
||||
|
||||
return nodes
|
||||
|
||||
|
||||
@@ -89,7 +96,7 @@ def load_nodes():
|
||||
nodes_failed.extend(extract_nodes_from_source(filename))
|
||||
|
||||
if errors:
|
||||
log.info(
|
||||
log.debug(
|
||||
f"Some nodes failed to load:\n\t"
|
||||
+ "\n\t".join(errors)
|
||||
+ "\n\n"
|
||||
@@ -104,46 +111,17 @@ def load_nodes():
|
||||
web_extensions_root = comfy_dir / "web" / "extensions"
|
||||
web_mtb = web_extensions_root / "mtb"
|
||||
|
||||
if web_mtb.exists():
|
||||
log.debug(f"Web extensions folder found at {web_mtb}")
|
||||
if not os.path.islink(web_mtb.as_posix()):
|
||||
log.warn(
|
||||
f"Web extensions folder at {web_mtb} is not a symlink, if updating please delete it before"
|
||||
)
|
||||
|
||||
|
||||
elif web_extensions_root.exists():
|
||||
web_tgt = here / "web"
|
||||
src = web_tgt.as_posix()
|
||||
dst = web_mtb.as_posix()
|
||||
if web_mtb.exists() and hasattr(nodes, "EXTENSION_WEB_DIRS"):
|
||||
try:
|
||||
if os.name == "nt":
|
||||
import _winapi
|
||||
|
||||
_winapi.CreateJunction(src, dst)
|
||||
if web_mtb.is_symlink():
|
||||
web_mtb.unlink()
|
||||
else:
|
||||
os.symlink(web_tgt.as_posix(), web_mtb.as_posix())
|
||||
|
||||
except OSError:
|
||||
log.warn(f"Failed to create symlink to {web_mtb}, trying to copy it")
|
||||
try:
|
||||
import shutil
|
||||
|
||||
shutil.copytree(web_tgt, web_mtb)
|
||||
log.info(f"Successfully copied {web_tgt} to {web_mtb}")
|
||||
except Exception:
|
||||
log.warn(
|
||||
f"Failed to symlink and copy {web_tgt} to {web_mtb}. Please copy the folder manually."
|
||||
)
|
||||
|
||||
except Exception: # OSError
|
||||
log.warn(
|
||||
f"Failed to create symlink to {web_mtb}. Please copy the folder manually."
|
||||
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."
|
||||
)
|
||||
else:
|
||||
log.warn(
|
||||
f"Comfy root probably not found automatically, please copy the folder {web_mtb} manually in the web/extensions folder of ComfyUI"
|
||||
)
|
||||
|
||||
|
||||
# - REGISTER NODES
|
||||
nodes, failed = load_nodes()
|
||||
@@ -168,7 +146,7 @@ for node_class in nodes:
|
||||
)
|
||||
)
|
||||
|
||||
log.info(
|
||||
log.debug(
|
||||
f"Loaded the following nodes:\n\t"
|
||||
+ "\n\t".join(
|
||||
f"{cyan_text(k)}: {blue_text(get_summary(doc)) if doc else '-'}"
|
||||
@@ -176,15 +154,55 @@ log.info(
|
||||
)
|
||||
)
|
||||
|
||||
log.info(f"loaded {cyan_text(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."
|
||||
)
|
||||
|
||||
|
||||
# - ENDPOINT
|
||||
from server import PromptServer
|
||||
from .log import log
|
||||
from aiohttp import web
|
||||
from importlib import reload
|
||||
import logging
|
||||
from .endpoint import endlog
|
||||
|
||||
|
||||
if hasattr(PromptServer, "instance"):
|
||||
restore_deps = ["basicsr"]
|
||||
onnx_deps = ["onnxruntime"]
|
||||
swap_deps = ["insightface"] + onnx_deps
|
||||
node_dependency_mapping = {
|
||||
"QrCode": ["qrcode"],
|
||||
"DeepBump": onnx_deps,
|
||||
"FaceSwap": swap_deps,
|
||||
"LoadFaceSwapModel": swap_deps,
|
||||
"LoadFaceAnalysisModel": restore_deps,
|
||||
}
|
||||
|
||||
PromptServer.instance.app.router.add_static(
|
||||
"/mtb-assets/", path=(here / "html").as_posix()
|
||||
)
|
||||
|
||||
@PromptServer.instance.routes.get("/mtb/manage")
|
||||
async def manage(request):
|
||||
from . import endpoint
|
||||
|
||||
reload(endpoint)
|
||||
|
||||
endlog.debug("Initializing Manager")
|
||||
if "text/html" in request.headers.get("Accept", ""):
|
||||
csv_editor = endpoint.csv_editor()
|
||||
|
||||
tabview = endpoint.render_tab_view(Styles=csv_editor)
|
||||
return web.Response(
|
||||
text=endpoint.render_base_template("MTB", tabview),
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"message": "manage only has a POST api for now",
|
||||
}
|
||||
)
|
||||
|
||||
@PromptServer.instance.routes.get("/mtb/status")
|
||||
async def get_full_library(request):
|
||||
@@ -200,7 +218,13 @@ if hasattr(PromptServer, "instance"):
|
||||
NODE_CLASS_MAPPINGS_DEBUG, title="Registered"
|
||||
)
|
||||
html_response += endpoint.render_table(
|
||||
{k: "-" for k in failed}, title="Failed to load"
|
||||
{
|
||||
k: {"dependencies": node_dependency_mapping.get(k)}
|
||||
if node_dependency_mapping.get(k)
|
||||
else "-"
|
||||
for k in failed
|
||||
},
|
||||
title="Failed to load",
|
||||
)
|
||||
|
||||
return web.Response(
|
||||
@@ -224,11 +248,10 @@ if hasattr(PromptServer, "instance"):
|
||||
log.setLevel(logging.DEBUG)
|
||||
log.debug("Debug mode set from API (/mtb/debug POST route)")
|
||||
|
||||
else:
|
||||
if "MTB_DEBUG" in os.environ:
|
||||
# del os.environ["MTB_DEBUG"]
|
||||
os.environ.pop("MTB_DEBUG")
|
||||
log.setLevel(logging.INFO)
|
||||
elif "MTB_DEBUG" in os.environ:
|
||||
# del os.environ["MTB_DEBUG"]
|
||||
os.environ.pop("MTB_DEBUG")
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
return web.json_response(
|
||||
{"message": f"Debug mode {'set' if enabled else 'unset'}"}
|
||||
@@ -242,8 +265,9 @@ if hasattr(PromptServer, "instance"):
|
||||
# Check if the request prefers HTML content
|
||||
if "text/html" in request.headers.get("Accept", ""):
|
||||
# # Return an HTML page
|
||||
html_response = f"""
|
||||
html_response = """
|
||||
<div class="flex-container menu">
|
||||
<a href="/mtb/manage">manage</a>
|
||||
<a href="/mtb/debug">debug</a>
|
||||
<a href="/mtb/status">status</a>
|
||||
</div>
|
||||
@@ -261,9 +285,7 @@ if hasattr(PromptServer, "instance"):
|
||||
from . import endpoint
|
||||
|
||||
reload(endpoint)
|
||||
enabled = False
|
||||
if "MTB_DEBUG" in os.environ:
|
||||
enabled = True
|
||||
enabled = "MTB_DEBUG" in os.environ
|
||||
# Check if the request prefers HTML content
|
||||
if "text/html" in request.headers.get("Accept", ""):
|
||||
# # Return an HTML page
|
||||
@@ -283,7 +305,7 @@ if hasattr(PromptServer, "instance"):
|
||||
from . import endpoint
|
||||
|
||||
if "text/html" in request.headers.get("Accept", ""):
|
||||
html_response = f"""
|
||||
html_response = """
|
||||
<h1>Actions has no get for now...</h1>
|
||||
"""
|
||||
return web.Response(
|
||||
|
||||
+239
-22
@@ -1,11 +1,47 @@
|
||||
from .utils import here
|
||||
import csv
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from .log import mklog
|
||||
import os
|
||||
from .utils import backup_file, here, import_install, reqs_map, run_command, styles_dir
|
||||
|
||||
endlog = mklog("mtb endpoint")
|
||||
|
||||
#- ACTIONS
|
||||
# - ACTIONS
|
||||
import platform
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import_install("requirements")
|
||||
|
||||
|
||||
def ACTIONS_installDependency(dependency_names=None):
|
||||
if dependency_names is None:
|
||||
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]
|
||||
try:
|
||||
run_command([Path(sys.executable), "-m", "pip", "install"] + resolved_names)
|
||||
return {"success": True}
|
||||
|
||||
except Exception as e:
|
||||
return {"error": f"Failed to install dependencies: {e}"}
|
||||
|
||||
# if platform.system() == "Windows":
|
||||
# reqs = list(requirements.parse((here / "reqs_windows.txt").read_text()))
|
||||
# else:
|
||||
# reqs = list(requirements.parse((here / "reqs.txt").read_text()))
|
||||
# print([x.specs for x in reqs])
|
||||
# print(
|
||||
# "\n".join([f"{x.line} {''.join(x.specs[0] if x.specs else '')}" for x in reqs])
|
||||
# )
|
||||
# for dependency_name in dependency_names:
|
||||
# for req in reqs:
|
||||
# if req.name == dependency_name:
|
||||
# endlog.debug(f"Dependency {dependency_name} installed")
|
||||
# break
|
||||
|
||||
|
||||
def ACTIONS_getStyles(style_name=None):
|
||||
from .nodes.conditions import StylesLoader
|
||||
@@ -19,14 +55,37 @@ def ACTIONS_getStyles(style_name=None):
|
||||
if not key.startswith("__") and key not in match_list
|
||||
}
|
||||
if style_name:
|
||||
if style_name in filtered_styles:
|
||||
return filtered_styles[style_name]
|
||||
else:
|
||||
return {"error": "Style not found"}
|
||||
return filtered_styles.get(style_name, {"error": "Style not found"})
|
||||
return filtered_styles
|
||||
return {"error": "No styles found"}
|
||||
|
||||
|
||||
def ACTIONS_saveStyle(data):
|
||||
# endlog.debug(f"Received Save Styles for {data.keys()}")
|
||||
# endlog.debug(data)
|
||||
|
||||
styles = [f.name for f in styles_dir.iterdir() if f.suffix == ".csv"]
|
||||
target = None
|
||||
rows = []
|
||||
for fp, content in data.items():
|
||||
if fp in styles:
|
||||
endlog.debug(f"Overwriting {fp}")
|
||||
target = styles_dir / fp
|
||||
rows = content
|
||||
break
|
||||
|
||||
if not target:
|
||||
endlog.warning(f"Could not determine the target file for {data.keys()}")
|
||||
return {"error": "Could not determine the target file for the style"}
|
||||
|
||||
backup_file(target)
|
||||
|
||||
with target.open("w", newline="", encoding="utf-8") as file:
|
||||
csv_writer = csv.writer(file, quoting=csv.QUOTE_ALL)
|
||||
for row in rows:
|
||||
csv_writer.writerow(row)
|
||||
|
||||
|
||||
async def do_action(request) -> web.Response:
|
||||
endlog.debug("Init action request")
|
||||
request_data = await request.json()
|
||||
@@ -35,7 +94,7 @@ async def do_action(request) -> web.Response:
|
||||
|
||||
endlog.debug(f"Received action request: {name} {args}")
|
||||
|
||||
method_name = "ACTIONS_" + name
|
||||
method_name = f"ACTIONS_{name}"
|
||||
method = globals().get(method_name)
|
||||
|
||||
if callable(method):
|
||||
@@ -53,16 +112,159 @@ async def do_action(request) -> web.Response:
|
||||
|
||||
|
||||
# - HTML UTILS
|
||||
|
||||
|
||||
def dependencies_button(name, dependencies):
|
||||
deps = ",".join([f"'{x}'" for x in dependencies])
|
||||
return f"""
|
||||
<button class="dependency-button" onclick="window.mtb_action('installDependency',[{deps}])">Install {name} deps</button>
|
||||
"""
|
||||
|
||||
|
||||
def csv_editor():
|
||||
inputs = [f for f in styles_dir.iterdir() if f.suffix == ".csv"]
|
||||
# rows = {f.stem: list(csv.reader(f.read_text("utf8"))) for f in styles}
|
||||
|
||||
style_files = {}
|
||||
for file in inputs:
|
||||
with open(file, "r", encoding="utf8") as f:
|
||||
parsed = csv.reader(f)
|
||||
style_files[file.name] = []
|
||||
for row in parsed:
|
||||
endlog.debug(f"Adding style {row[0]}")
|
||||
style_files[file.name].append((row[0], row[1], row[2]))
|
||||
|
||||
html_out = """
|
||||
<div id="style-editor">
|
||||
<h1>Style Editor</h1>
|
||||
|
||||
"""
|
||||
for current, styles in style_files.items():
|
||||
current_out = f"<h3>{current}</h3>"
|
||||
table_rows = []
|
||||
for index, style in enumerate(styles):
|
||||
table_rows += (
|
||||
(["<tr>"] + [f"<th>{cell}</th>" for cell in style] + ["</tr>"])
|
||||
if index == 0
|
||||
else (
|
||||
["<tr>"]
|
||||
+ [
|
||||
f"<td><input type='text' value='{cell}'></td>"
|
||||
if i == 0
|
||||
else f"<td><textarea name='Text1' cols='40' rows='5'>{cell}</textarea></td>"
|
||||
for i, cell in enumerate(style)
|
||||
]
|
||||
+ ["</tr>"]
|
||||
)
|
||||
)
|
||||
current_out += (
|
||||
f"<table data-id='{current}' data-filename='{current}'>"
|
||||
+ "".join(table_rows)
|
||||
+ "</table>"
|
||||
)
|
||||
current_out += f"<button data-id='{current}' onclick='saveTableData(this.getAttribute(\"data-id\"))'>Save {current}</button>"
|
||||
|
||||
html_out += add_foldable_region(current, current_out)
|
||||
|
||||
html_out += "</div>"
|
||||
html_out += """<script src='/mtb-assets/js/saveTableData.js'></script>"""
|
||||
|
||||
return html_out
|
||||
|
||||
|
||||
def render_tab_view(**kwargs):
|
||||
tab_headers = []
|
||||
tab_contents = []
|
||||
|
||||
for idx, (tab_name, content) in enumerate(kwargs.items()):
|
||||
active_class = "active" if idx == 0 else ""
|
||||
tab_headers.append(
|
||||
f"<button class='tablinks {active_class}' onclick=\"openTab(event, '{tab_name}')\">{tab_name}</button>"
|
||||
)
|
||||
tab_contents.append(
|
||||
f"<div id='{tab_name}' class='tabcontent {active_class}'>{content}</div>"
|
||||
)
|
||||
|
||||
headers_str = "\n".join(tab_headers)
|
||||
contents_str = "\n".join(tab_contents)
|
||||
|
||||
return f"""
|
||||
<div class='tab-container'>
|
||||
<div class='tab'>
|
||||
{headers_str}
|
||||
</div>
|
||||
{contents_str}
|
||||
</div>
|
||||
<script src='/mtb-assets/js/tabSwitch.js'></script>
|
||||
"""
|
||||
|
||||
|
||||
def add_foldable_region(title, content):
|
||||
symbol_id = f"{title}-symbol"
|
||||
return f"""
|
||||
<div class='foldable'>
|
||||
<div class='foldable-title' onclick="toggleFoldable('{title}', '{symbol_id}')">
|
||||
<span id='{symbol_id}' class='foldable-symbol'>▷</span>
|
||||
{title}
|
||||
</div>
|
||||
<div id='{title}' class='foldable-content'>
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
<script src='/mtb-assets/js/foldable.js'></script>
|
||||
"""
|
||||
|
||||
|
||||
def add_split_pane(left_content, right_content, vertical=True):
|
||||
orientation = "vertical" if vertical else "horizontal"
|
||||
return f"""
|
||||
<div class="split-pane {orientation}">
|
||||
<div id="leftPane">
|
||||
{left_content}
|
||||
</div>
|
||||
<div id="resizer"></div>
|
||||
<div id="rightPane">
|
||||
{right_content}
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
initSplitPane({str(vertical).lower()});
|
||||
</script>
|
||||
<script src='/mtb-assets/js/splitPane.js'></script>
|
||||
"""
|
||||
|
||||
|
||||
def add_dropdown(title, options):
|
||||
option_str = "\n".join([f"<option value='{opt}'>{opt}</option>" for opt in options])
|
||||
return f"""
|
||||
<select>
|
||||
<option disabled selected>{title}</option>
|
||||
{option_str}
|
||||
</select>
|
||||
"""
|
||||
|
||||
|
||||
def render_table(table_dict, sort=True, title=None):
|
||||
table_rows = ""
|
||||
table_dict = sorted(
|
||||
table_dict.items(), key=lambda item: item[0]
|
||||
) # Sort the dictionary by keys
|
||||
|
||||
for name, description in table_dict:
|
||||
table_rows += f"<tr><td>{name}</td><td>{description}</td></tr>"
|
||||
table_rows = ""
|
||||
for name, item in table_dict:
|
||||
if isinstance(item, dict):
|
||||
if "dependencies" in item:
|
||||
table_rows += f"<tr><td>{name}</td><td>"
|
||||
table_rows += f"{dependencies_button(name,item['dependencies'])}"
|
||||
|
||||
html_response = f"""
|
||||
table_rows += "</td></tr>"
|
||||
else:
|
||||
table_rows += f"<tr><td>{name}</td><td>{render_table(item)}</td></tr>"
|
||||
# elif isinstance(item, str):
|
||||
# table_rows += f"<tr><td>{name}</td><td>{item}</td></tr>"
|
||||
else:
|
||||
table_rows += f"<tr><td>{name}</td><td>{item}</td></tr>"
|
||||
|
||||
return f"""
|
||||
<div class="table-container">
|
||||
{"" if title is None else f"<h1>{title}</h1>"}
|
||||
<table>
|
||||
@@ -78,26 +280,40 @@ def render_table(table_dict, sort=True, title=None):
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
return html_response
|
||||
|
||||
|
||||
def render_base_template(title, content):
|
||||
css_content = ""
|
||||
css_path = here / "html" / "style.css"
|
||||
if css_path:
|
||||
with open(css_path, "r") as css_file:
|
||||
css_content = css_file.read()
|
||||
|
||||
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>
|
||||
<html>
|
||||
<head>
|
||||
<title>{title}</title>
|
||||
<style>
|
||||
{css_content}
|
||||
</style>
|
||||
<link rel="stylesheet" href="/mtb-assets/style.css"/>
|
||||
</head>
|
||||
<script type="module">
|
||||
import {{ api }} from '/scripts/api.js'
|
||||
const mtb_action = async (action, args) =>{{
|
||||
console.log(`Sending ${{action}} with args: ${{args}}`)
|
||||
}}
|
||||
window.mtb_action = async (action, args) =>{{
|
||||
console.log(`Sending ${{action}} with args: ${{args}} to the API`)
|
||||
const res = await api.fetchApi('/actions', {{
|
||||
method: 'POST',
|
||||
body: JSON.stringify({{
|
||||
name: action,
|
||||
args,
|
||||
}}),
|
||||
}})
|
||||
|
||||
const output = await res.json()
|
||||
console.debug(`Received ${{action}} response:`, output)
|
||||
if (output?.result?.error){{
|
||||
alert(`An error occured: {{output?.result?.error}}`)
|
||||
}}
|
||||
return output?.result
|
||||
}}
|
||||
</script>
|
||||
<body>
|
||||
<header>
|
||||
<a href="/">Back to Comfy</a>
|
||||
@@ -117,5 +333,6 @@ def render_base_template(title, content):
|
||||
<!-- Shared footer content here -->
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
class ModelNotFound(Exception):
|
||||
def __init__(self, model_name, *args, **kwargs):
|
||||
super().__init__(
|
||||
f"The model {model_name} could not be found, make sure to download it using ComfyManager first.\nrepository: https://github.com/ltdrdata/ComfyUI-Manager",
|
||||
*args,
|
||||
**kwargs,
|
||||
)
|
||||
+64
-65
@@ -265,7 +265,7 @@
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Closeup portrait of an old bearded Caucasian man smiling, (NYC 1995), trench coat, golden ring, brown eyes"
|
||||
"Medium cinematic shot of an old Caucasian man smiling, (NYC 1995), trench coat, golden ring, brown eyes, (with a blue light saber)"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -424,65 +424,6 @@
|
||||
"embedding:EasyNegative, embedding:EasyNegativeV2, watermark, text, deformed, disfigured, blurry"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
-483,
|
||||
-21
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
474
|
||||
],
|
||||
"flags": {},
|
||||
"order": 14,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 120
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
138
|
||||
],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
542821171533322,
|
||||
"fixed",
|
||||
45,
|
||||
8,
|
||||
"dpmpp_sde",
|
||||
"simple",
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"type": "VAEDecodeTiled",
|
||||
@@ -584,7 +525,7 @@
|
||||
52
|
||||
],
|
||||
"size": [
|
||||
260.3902351585391,
|
||||
260.3902282714844,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
@@ -615,8 +556,8 @@
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
265.97600515853924,
|
||||
87.31192548828142
|
||||
265.97601318359375,
|
||||
87.31192779541016
|
||||
],
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
@@ -795,8 +736,66 @@
|
||||
"Node name for S&R": "Face Swap (mtb)"
|
||||
},
|
||||
"widgets_values": [
|
||||
"0",
|
||||
false
|
||||
"0"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
-483,
|
||||
-21
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
474
|
||||
],
|
||||
"flags": {},
|
||||
"order": 14,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 120
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 2
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
138
|
||||
],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
542071534529,
|
||||
"fixed",
|
||||
32,
|
||||
9,
|
||||
"dpmpp_2m",
|
||||
"normal",
|
||||
1
|
||||
]
|
||||
}
|
||||
],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"last_node_id": 86,
|
||||
"last_link_id": 171,
|
||||
"last_link_id": 172,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 59,
|
||||
@@ -111,40 +111,6 @@
|
||||
"horizontal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
-1410,
|
||||
660
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
2,
|
||||
153
|
||||
],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
768,
|
||||
512,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
@@ -211,7 +177,7 @@
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
45,
|
||||
1682,
|
||||
"fixed",
|
||||
45,
|
||||
8,
|
||||
@@ -232,7 +198,7 @@
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
@@ -263,7 +229,7 @@
|
||||
246
|
||||
],
|
||||
"flags": {},
|
||||
"order": 23,
|
||||
"order": 22,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -289,7 +255,7 @@
|
||||
75.28300476074219
|
||||
],
|
||||
"flags": {},
|
||||
"order": 18,
|
||||
"order": 16,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -301,7 +267,7 @@
|
||||
{
|
||||
"name": "text",
|
||||
"type": "STRING",
|
||||
"link": 171,
|
||||
"link": 172,
|
||||
"widget": {
|
||||
"name": "text",
|
||||
"config": [
|
||||
@@ -437,7 +403,7 @@
|
||||
"flags": {
|
||||
"collapsed": false
|
||||
},
|
||||
"order": 20,
|
||||
"order": 19,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -480,7 +446,7 @@
|
||||
474
|
||||
],
|
||||
"flags": {},
|
||||
"order": 19,
|
||||
"order": 18,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -536,7 +502,7 @@
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
45,
|
||||
1682,
|
||||
"fixed",
|
||||
45,
|
||||
8,
|
||||
@@ -557,7 +523,7 @@
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
@@ -648,7 +614,7 @@
|
||||
46
|
||||
],
|
||||
"flags": {},
|
||||
"order": 21,
|
||||
"order": 20,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -757,10 +723,10 @@
|
||||
],
|
||||
"size": [
|
||||
294,
|
||||
104.4554216999652
|
||||
104.4554214477539
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
@@ -847,7 +813,7 @@
|
||||
78
|
||||
],
|
||||
"flags": {},
|
||||
"order": 22,
|
||||
"order": 21,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
@@ -882,34 +848,36 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"type": "Save Gif (mtb)",
|
||||
"id": 83,
|
||||
"type": "Text box",
|
||||
"pos": [
|
||||
1546,
|
||||
401
|
||||
-2456,
|
||||
236
|
||||
],
|
||||
"size": [
|
||||
210,
|
||||
336
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 24,
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
"outputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 169
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
166,
|
||||
167
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Save Gif (mtb)"
|
||||
"Node name for S&R": "Text box"
|
||||
},
|
||||
"widgets_values": [
|
||||
12,
|
||||
0.7,
|
||||
true,
|
||||
"/view?filename=eda71478da.gif&subfolder=&type=output"
|
||||
"Close up photo of the face of a Caucasian young man (looking down, and frowning), rim lighting, Tokyo 1987, Bernard, over a blue sky, blue eyes shaved close"
|
||||
]
|
||||
},
|
||||
{
|
||||
@@ -951,49 +919,16 @@
|
||||
"title": "seed",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
45,
|
||||
1682,
|
||||
"fixed"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 83,
|
||||
"type": "Text box",
|
||||
"pos": [
|
||||
-2456,
|
||||
236
|
||||
],
|
||||
"size": [
|
||||
400,
|
||||
200
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
166,
|
||||
167
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Text box"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Close up photo of the face of a man (looking down, and frowning), rim lighting, Tokyo 1987, Bernard, over a blue sky, blue eyes and a long beard"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 69,
|
||||
"type": "String Replace (mtb)",
|
||||
"pos": [
|
||||
-1442,
|
||||
-1
|
||||
-1529,
|
||||
-3
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
@@ -1014,7 +949,7 @@
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"links": [
|
||||
170
|
||||
172
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
@@ -1024,48 +959,75 @@
|
||||
"Node name for S&R": "String Replace (mtb)"
|
||||
},
|
||||
"widgets_values": [
|
||||
"looking down",
|
||||
"looking up"
|
||||
"a Caucasian young",
|
||||
"an African old"
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 86,
|
||||
"type": "String Replace (mtb)",
|
||||
"id": 5,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
-1082,
|
||||
1
|
||||
-1410,
|
||||
660
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
82
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 16,
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "string",
|
||||
"type": "STRING",
|
||||
"link": 170
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "STRING",
|
||||
"type": "STRING",
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
171
|
||||
2,
|
||||
153
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "String Replace (mtb)"
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
"frowning",
|
||||
"smiling"
|
||||
768,
|
||||
320,
|
||||
1
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": 85,
|
||||
"type": "Save Gif (mtb)",
|
||||
"pos": [
|
||||
1519,
|
||||
364
|
||||
],
|
||||
"size": [
|
||||
862.6054045703117,
|
||||
496.63413712402314
|
||||
],
|
||||
"flags": {},
|
||||
"order": 23,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 169
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Save Gif (mtb)"
|
||||
},
|
||||
"widgets_values": [
|
||||
12,
|
||||
0.7,
|
||||
true,
|
||||
"/view?filename=eda71478da.gif&subfolder=&type=output",
|
||||
"nearest",
|
||||
"/view?filename=421883da47.gif&subfolder=&type=output"
|
||||
]
|
||||
}
|
||||
],
|
||||
@@ -1327,17 +1289,9 @@
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
170,
|
||||
172,
|
||||
69,
|
||||
0,
|
||||
86,
|
||||
0,
|
||||
"STRING"
|
||||
],
|
||||
[
|
||||
171,
|
||||
86,
|
||||
0,
|
||||
71,
|
||||
1,
|
||||
"STRING"
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,905 @@
|
||||
{
|
||||
"last_node_id": 97,
|
||||
"last_link_id": 179,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 6,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
-1165.8749246009997,
|
||||
30
|
||||
],
|
||||
"size": [
|
||||
422.84503173828125,
|
||||
164.31304931640625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 4,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 3
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
4,
|
||||
158
|
||||
],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Closeup texture of rocks"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653",
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 7,
|
||||
"type": "CLIPTextEncode",
|
||||
"pos": [
|
||||
-1175.8749246009997,
|
||||
250
|
||||
],
|
||||
"size": [
|
||||
425.27801513671875,
|
||||
180.6060791015625
|
||||
],
|
||||
"flags": {},
|
||||
"order": 5,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "clip",
|
||||
"type": "CLIP",
|
||||
"link": 5
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "CONDITIONING",
|
||||
"type": "CONDITIONING",
|
||||
"links": [
|
||||
6
|
||||
],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CLIPTextEncode"
|
||||
},
|
||||
"widgets_values": [
|
||||
"((drawing, cartoon, painting, sketch, blur, depth of field, dof))"
|
||||
],
|
||||
"color": "#432",
|
||||
"bgcolor": "#653",
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 89,
|
||||
"type": "Reroute",
|
||||
"pos": [
|
||||
350,
|
||||
803
|
||||
],
|
||||
"size": [
|
||||
75,
|
||||
26
|
||||
],
|
||||
"flags": {},
|
||||
"order": 9,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "*",
|
||||
"link": 176
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
167
|
||||
]
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"showOutputText": false,
|
||||
"horizontal": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4,
|
||||
"type": "CheckpointLoaderSimple",
|
||||
"pos": [
|
||||
-1740,
|
||||
236
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
98
|
||||
],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "MODEL",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
170
|
||||
],
|
||||
"slot_index": 0
|
||||
},
|
||||
{
|
||||
"name": "CLIP",
|
||||
"type": "CLIP",
|
||||
"links": [
|
||||
3,
|
||||
5
|
||||
],
|
||||
"slot_index": 1
|
||||
},
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [],
|
||||
"slot_index": 2
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "CheckpointLoaderSimple"
|
||||
},
|
||||
"widgets_values": [
|
||||
"revAnimated_v122.safetensors"
|
||||
],
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 63,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1315,
|
||||
18
|
||||
],
|
||||
"size": [
|
||||
539.2050170898438,
|
||||
617.2159423828125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 13,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 115
|
||||
}
|
||||
],
|
||||
"title": "Normal",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Normal"
|
||||
],
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 67,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
2095,
|
||||
22
|
||||
],
|
||||
"size": [
|
||||
539.2050170898438,
|
||||
617.2159423828125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 16,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 119
|
||||
}
|
||||
],
|
||||
"title": "Curvature",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Curvature"
|
||||
],
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 69,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
1560,
|
||||
1290
|
||||
],
|
||||
"size": [
|
||||
539.2050170898438,
|
||||
617.2159423828125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 17,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 121
|
||||
}
|
||||
],
|
||||
"title": "Depth",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Height"
|
||||
],
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 91,
|
||||
"type": "Model Patch Seamless (mtb)",
|
||||
"pos": [
|
||||
-1150,
|
||||
-146
|
||||
],
|
||||
"size": [
|
||||
430.8000183105469,
|
||||
78
|
||||
],
|
||||
"flags": {},
|
||||
"order": 3,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 170
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "Original Model (passthrough)",
|
||||
"type": "MODEL",
|
||||
"links": null,
|
||||
"shape": 3
|
||||
},
|
||||
{
|
||||
"name": "Patched Model",
|
||||
"type": "MODEL",
|
||||
"links": [
|
||||
169
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 1
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Model Patch Seamless (mtb)"
|
||||
},
|
||||
"widgets_values": [
|
||||
true
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 93,
|
||||
"type": "PreviewImage",
|
||||
"pos": [
|
||||
1115,
|
||||
-597
|
||||
],
|
||||
"size": [
|
||||
451.3526306152344,
|
||||
478.3444519042969
|
||||
],
|
||||
"flags": {},
|
||||
"order": 12,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 179
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "PreviewImage"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 43,
|
||||
"type": "VAELoader",
|
||||
"pos": [
|
||||
-598.2757622278747,
|
||||
577.3595309932109
|
||||
],
|
||||
"size": [
|
||||
387.48089599609375,
|
||||
70.60645294189453
|
||||
],
|
||||
"flags": {},
|
||||
"order": 1,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "VAE",
|
||||
"type": "VAE",
|
||||
"links": [
|
||||
174
|
||||
],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "VAELoader"
|
||||
},
|
||||
"widgets_values": [
|
||||
"vae-ft-mse-840000-ema-pruned.safetensors"
|
||||
],
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 97,
|
||||
"type": "Image Tile Offset (mtb)",
|
||||
"pos": [
|
||||
617,
|
||||
-598
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
58
|
||||
],
|
||||
"flags": {},
|
||||
"order": 10,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 178
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
179
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Image Tile Offset (mtb)"
|
||||
},
|
||||
"widgets_values": [
|
||||
2
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 96,
|
||||
"type": "Vae Decode (mtb)",
|
||||
"pos": [
|
||||
-52,
|
||||
40
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
126
|
||||
],
|
||||
"flags": {},
|
||||
"order": 7,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "samples",
|
||||
"type": "LATENT",
|
||||
"link": 173
|
||||
},
|
||||
{
|
||||
"name": "vae",
|
||||
"type": "VAE",
|
||||
"link": 174
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
175,
|
||||
176,
|
||||
178
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Vae Decode (mtb)"
|
||||
},
|
||||
"widgets_values": [
|
||||
true,
|
||||
false,
|
||||
512
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353"
|
||||
},
|
||||
{
|
||||
"id": 46,
|
||||
"type": "SaveImage",
|
||||
"pos": [
|
||||
533,
|
||||
25
|
||||
],
|
||||
"size": [
|
||||
539.2050170898438,
|
||||
617.2159423828125
|
||||
],
|
||||
"flags": {},
|
||||
"order": 8,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "images",
|
||||
"type": "IMAGE",
|
||||
"link": 175
|
||||
}
|
||||
],
|
||||
"title": "Albedo",
|
||||
"properties": {},
|
||||
"widgets_values": [
|
||||
"Albedo"
|
||||
],
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 74,
|
||||
"type": "EmptyLatentImage",
|
||||
"pos": [
|
||||
-1075.8749246009997,
|
||||
480
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
106
|
||||
],
|
||||
"flags": {},
|
||||
"order": 2,
|
||||
"mode": 0,
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
132
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "EmptyLatentImage"
|
||||
},
|
||||
"widgets_values": [
|
||||
768,
|
||||
768,
|
||||
1
|
||||
],
|
||||
"color": "#323",
|
||||
"bgcolor": "#535",
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 62,
|
||||
"type": "Deep Bump (mtb)",
|
||||
"pos": [
|
||||
727,
|
||||
801
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
130
|
||||
],
|
||||
"flags": {},
|
||||
"order": 11,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 167
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
115,
|
||||
118,
|
||||
122
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Deep Bump (mtb)"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Color to Normals",
|
||||
"SMALL",
|
||||
"SMALLEST",
|
||||
true
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353",
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 66,
|
||||
"type": "Deep Bump (mtb)",
|
||||
"pos": [
|
||||
1626,
|
||||
808
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
130
|
||||
],
|
||||
"flags": {},
|
||||
"order": 14,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 118
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
119
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Deep Bump (mtb)"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Normals to Curvature",
|
||||
"SMALL",
|
||||
"SMALLEST",
|
||||
true
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353",
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 68,
|
||||
"type": "Deep Bump (mtb)",
|
||||
"pos": [
|
||||
1185,
|
||||
1288
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
130
|
||||
],
|
||||
"flags": {},
|
||||
"order": 15,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "image",
|
||||
"type": "IMAGE",
|
||||
"link": 122
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "IMAGE",
|
||||
"type": "IMAGE",
|
||||
"links": [
|
||||
121
|
||||
],
|
||||
"shape": 3,
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "Deep Bump (mtb)"
|
||||
},
|
||||
"widgets_values": [
|
||||
"Normals to Height",
|
||||
"SMALL",
|
||||
"SMALLEST",
|
||||
true
|
||||
],
|
||||
"color": "#232",
|
||||
"bgcolor": "#353",
|
||||
"shape": 1
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"type": "KSampler",
|
||||
"pos": [
|
||||
-518.2757622278748,
|
||||
47.359530993211024
|
||||
],
|
||||
"size": [
|
||||
315,
|
||||
474
|
||||
],
|
||||
"flags": {},
|
||||
"order": 6,
|
||||
"mode": 0,
|
||||
"inputs": [
|
||||
{
|
||||
"name": "model",
|
||||
"type": "MODEL",
|
||||
"link": 169
|
||||
},
|
||||
{
|
||||
"name": "positive",
|
||||
"type": "CONDITIONING",
|
||||
"link": 4
|
||||
},
|
||||
{
|
||||
"name": "negative",
|
||||
"type": "CONDITIONING",
|
||||
"link": 6
|
||||
},
|
||||
{
|
||||
"name": "latent_image",
|
||||
"type": "LATENT",
|
||||
"link": 132
|
||||
}
|
||||
],
|
||||
"outputs": [
|
||||
{
|
||||
"name": "LATENT",
|
||||
"type": "LATENT",
|
||||
"links": [
|
||||
173
|
||||
],
|
||||
"slot_index": 0
|
||||
}
|
||||
],
|
||||
"properties": {
|
||||
"Node name for S&R": "KSampler"
|
||||
},
|
||||
"widgets_values": [
|
||||
1001,
|
||||
"fixed",
|
||||
28,
|
||||
8,
|
||||
"dpmpp_2m",
|
||||
"normal",
|
||||
1
|
||||
],
|
||||
"color": "#222",
|
||||
"bgcolor": "#000",
|
||||
"shape": 1
|
||||
}
|
||||
],
|
||||
"links": [
|
||||
[
|
||||
3,
|
||||
4,
|
||||
1,
|
||||
6,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
4,
|
||||
6,
|
||||
0,
|
||||
3,
|
||||
1,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
5,
|
||||
4,
|
||||
1,
|
||||
7,
|
||||
0,
|
||||
"CLIP"
|
||||
],
|
||||
[
|
||||
6,
|
||||
7,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
"CONDITIONING"
|
||||
],
|
||||
[
|
||||
115,
|
||||
62,
|
||||
0,
|
||||
63,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
118,
|
||||
62,
|
||||
0,
|
||||
66,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
119,
|
||||
66,
|
||||
0,
|
||||
67,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
121,
|
||||
68,
|
||||
0,
|
||||
69,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
122,
|
||||
62,
|
||||
0,
|
||||
68,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
132,
|
||||
74,
|
||||
0,
|
||||
3,
|
||||
3,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
158,
|
||||
6,
|
||||
0,
|
||||
86,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
167,
|
||||
89,
|
||||
0,
|
||||
62,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
169,
|
||||
91,
|
||||
1,
|
||||
3,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
170,
|
||||
4,
|
||||
0,
|
||||
91,
|
||||
0,
|
||||
"MODEL"
|
||||
],
|
||||
[
|
||||
173,
|
||||
3,
|
||||
0,
|
||||
96,
|
||||
0,
|
||||
"LATENT"
|
||||
],
|
||||
[
|
||||
174,
|
||||
43,
|
||||
0,
|
||||
96,
|
||||
1,
|
||||
"VAE"
|
||||
],
|
||||
[
|
||||
175,
|
||||
96,
|
||||
0,
|
||||
46,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
176,
|
||||
96,
|
||||
0,
|
||||
89,
|
||||
0,
|
||||
"*"
|
||||
],
|
||||
[
|
||||
178,
|
||||
96,
|
||||
0,
|
||||
97,
|
||||
0,
|
||||
"IMAGE"
|
||||
],
|
||||
[
|
||||
179,
|
||||
97,
|
||||
0,
|
||||
93,
|
||||
0,
|
||||
"IMAGE"
|
||||
]
|
||||
],
|
||||
"groups": [
|
||||
{
|
||||
"title": "Seamless Diffusion",
|
||||
"bounding": [
|
||||
-1752,
|
||||
-392,
|
||||
1658,
|
||||
1102
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 76,
|
||||
"locked": false
|
||||
},
|
||||
{
|
||||
"title": "Seamless Check",
|
||||
"bounding": [
|
||||
421,
|
||||
-795,
|
||||
1374,
|
||||
763
|
||||
],
|
||||
"color": "#3f789e",
|
||||
"font_size": 76,
|
||||
"locked": false
|
||||
}
|
||||
],
|
||||
"config": {},
|
||||
"extra": {},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* File: foldable.js
|
||||
* Project: comfy_mtb
|
||||
* Author: Mel Massadian
|
||||
*
|
||||
* Copyright (c) 2023 Mel Massadian
|
||||
*
|
||||
*/
|
||||
|
||||
function toggleFoldable(elementId, symbolId) {
|
||||
const content = document.getElementById(elementId)
|
||||
const symbol = document.getElementById(symbolId)
|
||||
if (content.style.display === 'none' || content.style.display === '') {
|
||||
content.style.display = 'flex'
|
||||
symbol.innerHTML = '▽' // Down arrow
|
||||
} else {
|
||||
content.style.display = 'none'
|
||||
symbol.innerHTML = '▷' // Right arrow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* File: saveTableData.js
|
||||
* Project: comfy_mtb
|
||||
* Author: Mel Massadian
|
||||
*
|
||||
* Copyright (c) 2023 Mel Massadian
|
||||
*
|
||||
*/
|
||||
|
||||
function saveTableData(identifier) {
|
||||
const table = document.querySelector(
|
||||
`#style-editor table[data-id='${identifier}']`
|
||||
)
|
||||
|
||||
let currentData = []
|
||||
const rows = table.querySelectorAll('tr')
|
||||
const filename = table.getAttribute('data-id')
|
||||
|
||||
rows.forEach((row, rowIndex) => {
|
||||
const rowData = []
|
||||
const cells =
|
||||
rowIndex === 0
|
||||
? row.querySelectorAll('th')
|
||||
: row.querySelectorAll('td input, td textarea')
|
||||
|
||||
cells.forEach((cell) => {
|
||||
rowData.push(rowIndex === 0 ? cell.textContent : cell.value)
|
||||
})
|
||||
|
||||
currentData.push(rowData)
|
||||
})
|
||||
|
||||
let tablesData = {}
|
||||
tablesData[filename] = currentData
|
||||
|
||||
console.debug('Sending styles to manage endpoint:', tablesData)
|
||||
fetch('/mtb/actions', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'saveStyle',
|
||||
args: tablesData,
|
||||
}),
|
||||
})
|
||||
.then((response) => response.json())
|
||||
.then((data) => {
|
||||
console.debug('Success:', data)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Error:', error)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/**
|
||||
* File: splitPane.js
|
||||
* Project: comfy_mtb
|
||||
* Author: Mel Massadian
|
||||
*
|
||||
* Copyright (c) 2023 Mel Massadian
|
||||
*
|
||||
*/
|
||||
|
||||
function initSplitPane(vertical) {
|
||||
let resizer = document.getElementById('resizer')
|
||||
let left = document.getElementById('leftPane')
|
||||
let right = document.getElementById('rightPane')
|
||||
resizer.addEventListener('mousedown', function (e) {
|
||||
document.addEventListener('mousemove', onMouseMove)
|
||||
document.addEventListener('mouseup', function () {
|
||||
document.removeEventListener('mousemove', onMouseMove)
|
||||
})
|
||||
})
|
||||
|
||||
const onMouseMove = (e) => {
|
||||
if (vertical) {
|
||||
let leftWidth = e.clientX
|
||||
let rightWidth = window.innerWidth - e.clientX
|
||||
left.style.width = leftWidth + 'px'
|
||||
right.style.width = rightWidth + 'px'
|
||||
} else {
|
||||
let topHeight = e.clientY
|
||||
let bottomHeight = window.innerHeight - e.clientY
|
||||
left.style.height = topHeight + 'px'
|
||||
right.style.height = bottomHeight + 'px'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* File: tabSwitch.js
|
||||
* Project: comfy_mtb
|
||||
* Author: Mel Massadian
|
||||
*
|
||||
* Copyright (c) 2023 Mel Massadian
|
||||
*
|
||||
*/
|
||||
|
||||
function openTab(evt, tabName) {
|
||||
var i, tabcontent, tablinks
|
||||
tabcontent = document.getElementsByClassName('tabcontent')
|
||||
for (i = 0; i < tabcontent.length; i++) {
|
||||
tabcontent[i].style.display = 'none'
|
||||
}
|
||||
tablinks = document.getElementsByClassName('tablinks')
|
||||
for (i = 0; i < tablinks.length; i++) {
|
||||
tablinks[i].className = tablinks[i].className.replace(' active', '')
|
||||
}
|
||||
document.getElementById(tabName).style.display = 'block'
|
||||
evt.currentTarget.className += ' active'
|
||||
}
|
||||
+98
-3
@@ -18,7 +18,7 @@ a {
|
||||
}
|
||||
|
||||
table {
|
||||
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
@@ -119,7 +119,7 @@ main {
|
||||
justify-content: center;
|
||||
padding: 1em;
|
||||
margin: 0;
|
||||
height: 80%;
|
||||
/* height: 80%; */
|
||||
}
|
||||
|
||||
.flex-container {
|
||||
@@ -130,4 +130,99 @@ main {
|
||||
.menu {
|
||||
font-size: 3em;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
input, button, textarea {
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background-color: rgba(0,0,0,0.3);
|
||||
|
||||
}
|
||||
button {
|
||||
padding: 14px 16px;
|
||||
|
||||
}
|
||||
/* -STYLES EDITOR */
|
||||
|
||||
#style-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width:100%;
|
||||
|
||||
}
|
||||
|
||||
#style-editor > table {
|
||||
/* background-color: red; */
|
||||
width:100%;
|
||||
}
|
||||
#style-editor input, #style-editor textarea {
|
||||
/* background-color: blue; */
|
||||
width:100%;
|
||||
}
|
||||
|
||||
#style-editor td{
|
||||
width: 33.33%;
|
||||
}
|
||||
|
||||
/* -TABS */
|
||||
|
||||
.tab {
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
|
||||
}
|
||||
|
||||
.tab-container{
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.tab button {
|
||||
background-color: transparent;
|
||||
color:white;
|
||||
float: left;
|
||||
border: none;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
padding: 14px 16px;
|
||||
transition: 0.3s;
|
||||
width:100%;
|
||||
font-size: 1.5em;
|
||||
}
|
||||
|
||||
.tab button.active {
|
||||
background-color: #2e2e2e;
|
||||
}
|
||||
|
||||
.tabcontent {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tabcontent.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
|
||||
.foldable-title {
|
||||
cursor: pointer;
|
||||
font-weight: bold;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.foldable-symbol {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.foldable-content {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
margin-left: 20px;
|
||||
}
|
||||
|
||||
+217
-282
@@ -1,35 +1,54 @@
|
||||
import requests
|
||||
import os
|
||||
import ast
|
||||
import re
|
||||
import argparse
|
||||
import sys
|
||||
import subprocess
|
||||
from importlib import import_module
|
||||
import ast
|
||||
import os
|
||||
import platform
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import zipfile
|
||||
import shutil
|
||||
import shlex
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from contextlib import contextmanager
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
||||
# region constants
|
||||
here = Path(__file__).parent
|
||||
executable = sys.executable
|
||||
executable = Path(sys.executable)
|
||||
|
||||
# - detect mode
|
||||
mode = None
|
||||
if os.environ.get("COLAB_GPU"):
|
||||
mode = "colab"
|
||||
elif "python_embeded" in executable:
|
||||
elif "python_embeded" in str(executable):
|
||||
mode = "embeded"
|
||||
elif ".venv" in executable:
|
||||
elif ".venv" in str(executable):
|
||||
mode = "venv"
|
||||
|
||||
|
||||
if mode == None:
|
||||
if mode is None:
|
||||
mode = "unknown"
|
||||
|
||||
repo_url = "https://github.com/melmass/comfy_mtb.git"
|
||||
repo_owner = "melmass"
|
||||
repo_name = "comfy_mtb"
|
||||
short_platform = {
|
||||
"windows": "win_amd64",
|
||||
"linux": "linux_x86_64",
|
||||
}
|
||||
current_platform = platform.system().lower()
|
||||
pip_map = {
|
||||
"onnxruntime-gpu": "onnxruntime",
|
||||
"opencv-contrib": "cv2",
|
||||
"tb-nightly": "tensorboard",
|
||||
"protobuf": "google.protobuf",
|
||||
"qrcode[pil]": "qrcode",
|
||||
"requirements-parser": "requirements"
|
||||
# Add more mappings as needed
|
||||
}
|
||||
|
||||
# endregion
|
||||
|
||||
# region ansi
|
||||
# ANSI escape sequences for text styling
|
||||
ANSI_FORMATS = {
|
||||
@@ -102,55 +121,117 @@ def print_formatted(text, *formats, color=None, background=None, **kwargs):
|
||||
formatted_text = apply_format(text, *formats)
|
||||
formatted_text = apply_color(formatted_text, color, background)
|
||||
file = kwargs.get("file", sys.stdout)
|
||||
header = "[mtb install] "
|
||||
|
||||
# Handle console encoding for Unicode characters (utf-8)
|
||||
encoded_header = header.encode(sys.stdout.encoding, errors="replace").decode(
|
||||
sys.stdout.encoding
|
||||
)
|
||||
encoded_text = formatted_text.encode(sys.stdout.encoding, errors="replace").decode(
|
||||
sys.stdout.encoding
|
||||
)
|
||||
|
||||
print(
|
||||
apply_color(apply_format("[mtb install] ", "bold"), color="yellow"),
|
||||
formatted_text,
|
||||
" " * len(encoded_header)
|
||||
if kwargs.get("no_header")
|
||||
else apply_color(apply_format(encoded_header, "bold"), color="yellow"),
|
||||
encoded_text,
|
||||
file=file,
|
||||
)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
try:
|
||||
import requirements
|
||||
except ImportError:
|
||||
print_formatted("Installing requirements-parser...", "italic", color="yellow")
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", "requirements-parser"]
|
||||
|
||||
# region utils
|
||||
def run_command(cmd, ignored_lines_start=None):
|
||||
if ignored_lines_start is None:
|
||||
ignored_lines_start = []
|
||||
|
||||
if isinstance(cmd, str):
|
||||
shell_cmd = cmd
|
||||
elif isinstance(cmd, list):
|
||||
shell_cmd = " ".join(
|
||||
arg.as_posix() if isinstance(arg, Path) else shlex.quote(str(arg))
|
||||
for arg in cmd
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Invalid 'cmd' argument. It must be a string or a list of arguments."
|
||||
)
|
||||
|
||||
try:
|
||||
_run_command(shell_cmd, ignored_lines_start)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Command failed with return code: {e.returncode}", file=sys.stderr)
|
||||
print(e.stderr.strip(), file=sys.stderr)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Command execution interrupted.")
|
||||
|
||||
|
||||
def _run_command(shell_cmd, ignored_lines_start):
|
||||
print_formatted(f"Running {shell_cmd}", "bold")
|
||||
result = subprocess.run(
|
||||
shell_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
import requirements
|
||||
|
||||
print_formatted("Done.", "italic", color="green")
|
||||
stdout_lines = result.stdout.strip().split("\n")
|
||||
stderr_lines = result.stderr.strip().split("\n")
|
||||
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
except ImportError:
|
||||
print_formatted("Installing tqdm...", "italic", color="yellow")
|
||||
subprocess.check_call([sys.executable, "-m", "pip", "install", "--upgrade", "tqdm"])
|
||||
from tqdm import tqdm
|
||||
import importlib
|
||||
# Print stdout, skipping ignored lines
|
||||
for line in stdout_lines:
|
||||
if not any(line.startswith(ign) for ign in ignored_lines_start):
|
||||
print(line)
|
||||
|
||||
# Print stderr
|
||||
for line in stderr_lines:
|
||||
print(line, file=sys.stderr)
|
||||
|
||||
pip_map = {
|
||||
"onnxruntime-gpu": "onnxruntime",
|
||||
"opencv-contrib": "cv2",
|
||||
"tb-nightly": "tensorboard",
|
||||
"protobuf": "google.protobuf",
|
||||
# Add more mappings as needed
|
||||
}
|
||||
print("Command executed successfully!")
|
||||
|
||||
|
||||
def is_pipe():
|
||||
try:
|
||||
mode = os.fstat(0).st_mode
|
||||
return (
|
||||
stat.S_ISFIFO(mode)
|
||||
or stat.S_ISREG(mode)
|
||||
or stat.S_ISBLK(mode)
|
||||
or stat.S_ISSOCK(mode)
|
||||
)
|
||||
except OSError:
|
||||
if not sys.stdin.isatty():
|
||||
return False
|
||||
if sys.platform == "win32":
|
||||
try:
|
||||
import msvcrt
|
||||
|
||||
return msvcrt.get_osfhandle(0) != -1
|
||||
except ImportError:
|
||||
return False
|
||||
else:
|
||||
try:
|
||||
mode = os.fstat(0).st_mode
|
||||
return (
|
||||
stat.S_ISFIFO(mode)
|
||||
or stat.S_ISREG(mode)
|
||||
or stat.S_ISBLK(mode)
|
||||
or stat.S_ISSOCK(mode)
|
||||
)
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
@contextmanager
|
||||
def suppress_std():
|
||||
with open(os.devnull, "w") as devnull:
|
||||
old_stdout = sys.stdout
|
||||
old_stderr = sys.stderr
|
||||
sys.stdout = devnull
|
||||
sys.stderr = devnull
|
||||
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
sys.stdout = old_stdout
|
||||
sys.stderr = old_stderr
|
||||
|
||||
|
||||
# Get the version from __init__.py
|
||||
@@ -187,184 +268,71 @@ def download_file(url, file_name):
|
||||
progress_bar.update(len(chunk))
|
||||
|
||||
|
||||
def get_requirements(path: Path):
|
||||
with open(path.resolve(), "r") as requirements_file:
|
||||
requirements_txt = requirements_file.read()
|
||||
|
||||
try:
|
||||
parsed_requirements = requirements.parse(requirements_txt)
|
||||
except AttributeError:
|
||||
print_formatted(
|
||||
f"Failed to parse {path}. Please make sure the file is correctly formatted.",
|
||||
"bold",
|
||||
color="red",
|
||||
)
|
||||
|
||||
return
|
||||
|
||||
return parsed_requirements
|
||||
|
||||
|
||||
def try_import(requirement):
|
||||
dependency = requirement.name.strip()
|
||||
import_name = pip_map.get(dependency, dependency)
|
||||
installed = False
|
||||
|
||||
pip_name = dependency
|
||||
if specs := requirement.specs:
|
||||
pip_name += "".join(specs[0])
|
||||
|
||||
pip_spec = "".join(specs[0]) if (specs := requirement.specs) else ""
|
||||
try:
|
||||
import_module(import_name)
|
||||
with suppress_std():
|
||||
import_module(import_name)
|
||||
print_formatted(
|
||||
f"Package {pip_name} already installed (import name: '{import_name}').",
|
||||
f"\t✅ Package {pip_name} already installed (import name: '{import_name}').",
|
||||
"bold",
|
||||
color="green",
|
||||
no_header=True,
|
||||
)
|
||||
installed = True
|
||||
except ImportError:
|
||||
pass
|
||||
print_formatted(
|
||||
f"\t⛔ Package {pip_name} is missing (import name: '{import_name}').",
|
||||
"bold",
|
||||
color="red",
|
||||
no_header=True,
|
||||
)
|
||||
|
||||
return (installed, pip_name, import_name)
|
||||
return (installed, pip_name, pip_spec, import_name)
|
||||
|
||||
|
||||
def import_or_install(requirement, dry=False):
|
||||
installed, pip_name, import_name = try_import(requirement)
|
||||
installed, pip_name, pip_spec, import_name = try_import(requirement)
|
||||
|
||||
pip_install_name = pip_name + pip_spec
|
||||
|
||||
if not installed:
|
||||
print_formatted(f"Installing package {pip_name}...", "italic", color="yellow")
|
||||
if dry:
|
||||
print_formatted(
|
||||
f"Dry-run: Package {pip_name} would be installed (import name: '{import_name}').",
|
||||
f"Dry-run: Package {pip_install_name} would be installed (import name: '{import_name}').",
|
||||
color="yellow",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
subprocess.check_call(
|
||||
[sys.executable, "-m", "pip", "install", pip_name]
|
||||
)
|
||||
run_command([executable, "-m", "pip", "install", pip_install_name])
|
||||
print_formatted(
|
||||
f"Package {pip_name} installed successfully using pip package name (import name: '{import_name}')",
|
||||
f"Package {pip_install_name} installed successfully using pip package name (import name: '{import_name}')",
|
||||
"bold",
|
||||
color="green",
|
||||
)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print_formatted(
|
||||
f"Failed to install package {pip_name} using pip package name (import name: '{import_name}'). Error: {str(e)}",
|
||||
f"Failed to install package {pip_install_name} using pip package name (import name: '{import_name}'). Error: {str(e)}",
|
||||
"bold",
|
||||
color="red",
|
||||
)
|
||||
|
||||
|
||||
# Install dependencies from requirements.txt
|
||||
def install_dependencies(dry=False):
|
||||
parsed_requirements = get_requirements(here / "reqs.txt")
|
||||
if not parsed_requirements:
|
||||
return
|
||||
print_formatted(
|
||||
"Installing dependencies from reqs.txt...", "italic", color="yellow"
|
||||
)
|
||||
|
||||
for requirement in parsed_requirements:
|
||||
import_or_install(requirement, dry=dry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
full = False
|
||||
if is_pipe():
|
||||
print_formatted("Pipe detected, full install...", color="green")
|
||||
# we clone our repo
|
||||
url = "https://github.com/melmass/comfy_mtb.git"
|
||||
clone_dir = here / "custom_nodes" / "comfy_mtb"
|
||||
if not clone_dir.exists():
|
||||
clone_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
print_formatted(f"Cloning {url} to {clone_dir}", "italic", color="yellow")
|
||||
subprocess.check_call(["git", "clone", "--recursive", url, clone_dir])
|
||||
|
||||
# os.chdir(clone_dir)
|
||||
here = clone_dir
|
||||
full = True
|
||||
|
||||
if len(sys.argv) == 1:
|
||||
print_formatted(
|
||||
"No arguments provided, doing a full install/update...",
|
||||
"italic",
|
||||
color="yellow",
|
||||
def get_github_assets(tag=None):
|
||||
if tag:
|
||||
tag_url = (
|
||||
f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/tags/{tag}"
|
||||
)
|
||||
|
||||
full = True
|
||||
|
||||
# Parse command-line arguments
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument(
|
||||
"--wheels", "-w", action="store_true", help="Install wheel dependencies"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--requirements", "-r", action="store_true", help="Install requirements.txt"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--dry",
|
||||
action="store_true",
|
||||
help="Print what will happen without doing it (still making requests to the GH Api)",
|
||||
)
|
||||
|
||||
# parser.add_argument(
|
||||
# "--version",
|
||||
# default=get_local_version(),
|
||||
# help="Version to check against the GitHub API",
|
||||
# )
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
wheels_directory = here / "wheels"
|
||||
print_formatted(f"Detected environment: {apply_color(mode,'cyan')}")
|
||||
|
||||
# Install dependencies from requirements.txt
|
||||
# if args.requirements or mode == "venv":
|
||||
|
||||
if (not args.wheels and mode not in ["colab", "embeded"]) and not full:
|
||||
print_formatted(
|
||||
"Skipping wheel installation. Use --wheels to install wheel dependencies. (only needed for Comfy embed)",
|
||||
"italic",
|
||||
color="yellow",
|
||||
else:
|
||||
tag_url = (
|
||||
f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
||||
)
|
||||
|
||||
install_dependencies(dry=args.dry)
|
||||
sys.exit()
|
||||
|
||||
if mode in ["colab", "embeded"]:
|
||||
print_formatted(
|
||||
f"Downloading and installing release wheels since we are in a Comfy {apply_color(mode,'cyan')} environment",
|
||||
)
|
||||
if full:
|
||||
print_formatted(
|
||||
f"Downloading and installing release wheels since no arguments where provided"
|
||||
)
|
||||
|
||||
# - Check the env before proceeding.
|
||||
missing_wheels = False
|
||||
parsed_requirements = get_requirements(here / "reqs.txt")
|
||||
if parsed_requirements:
|
||||
for requirement in parsed_requirements:
|
||||
installed, pip_name, import_name = try_import(requirement)
|
||||
if not installed:
|
||||
missing_wheels = True
|
||||
break
|
||||
|
||||
if not missing_wheels:
|
||||
print_formatted(
|
||||
f"All requirements are already installed.", "italic", color="green"
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
# Fetch the JSON data from the GitHub API URL
|
||||
owner = "melmass"
|
||||
repo = "comfy_mtb"
|
||||
# version = args.version
|
||||
current_platform = platform.system().lower()
|
||||
|
||||
# Get the tag version from the GitHub API
|
||||
tag_url = f"https://api.github.com/repos/{owner}/{repo}/releases/latest"
|
||||
response = requests.get(tag_url)
|
||||
if response.status_code == 404:
|
||||
# print_formatted(
|
||||
@@ -376,111 +344,78 @@ if __name__ == "__main__":
|
||||
tag_data = response.json()
|
||||
tag_name = tag_data["name"]
|
||||
|
||||
# # Compare the local and tag versions
|
||||
# if version and tag_name:
|
||||
# if re.match(r"v?(\d+(\.\d+)+)", version) and re.match(
|
||||
# r"v?(\d+(\.\d+)+)", tag_name
|
||||
# ):
|
||||
# version_parts = [int(part) for part in version.lstrip("v").split(".")]
|
||||
# tag_version_parts = [int(part) for part in tag_name.lstrip("v").split(".")]
|
||||
return tag_data, tag_name
|
||||
|
||||
# if version_parts > tag_version_parts:
|
||||
# print_formatted(
|
||||
# f"Local version ({version}) is greater than the release version ({tag_name}).",
|
||||
# "bold",
|
||||
# "yellow",
|
||||
# )
|
||||
# sys.exit()
|
||||
|
||||
# Download the assets for the given version
|
||||
matching_assets = [
|
||||
asset for asset in tag_data["assets"] if current_platform in asset["name"]
|
||||
]
|
||||
if not matching_assets:
|
||||
# endregion
|
||||
|
||||
|
||||
try:
|
||||
from tqdm import tqdm
|
||||
except ImportError:
|
||||
print_formatted("Installing tqdm...", "italic", color="yellow")
|
||||
run_command([executable, "-m", "pip", "install", "--upgrade", "tqdm"])
|
||||
from tqdm import tqdm
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) == 1:
|
||||
print_formatted(
|
||||
f"Unsupported operating system: {current_platform}", color="yellow"
|
||||
"mtb doesn't need an install script anymore.", "italic", color="yellow"
|
||||
)
|
||||
|
||||
wheels_directory.mkdir(exist_ok=True)
|
||||
# - Install the wheels
|
||||
for asset in matching_assets:
|
||||
asset_name = asset["name"]
|
||||
asset_download_url = asset["browser_download_url"]
|
||||
print_formatted(f"Downloading asset: {asset_name}", color="yellow")
|
||||
asset_dest = wheels_directory / asset_name
|
||||
download_file(asset_download_url, asset_dest)
|
||||
|
||||
# - Unzip to wheels dir
|
||||
whl_files = []
|
||||
whl_order = None
|
||||
with zipfile.ZipFile(asset_dest, "r") as zip_ref:
|
||||
for item in tqdm(zip_ref.namelist(), desc="Extracting", unit="file"):
|
||||
if item.endswith(".whl"):
|
||||
item_basename = os.path.basename(item)
|
||||
target_path = wheels_directory / item_basename
|
||||
with zip_ref.open(item) as source, open(
|
||||
target_path, "wb"
|
||||
) as target:
|
||||
whl_files.append(target_path)
|
||||
shutil.copyfileobj(source, target)
|
||||
elif item.endswith("order.txt"):
|
||||
item_basename = os.path.basename(item)
|
||||
target_path = wheels_directory / item_basename
|
||||
with zip_ref.open(item) as source, open(
|
||||
target_path, "wb"
|
||||
) as target:
|
||||
whl_order = target_path
|
||||
shutil.copyfileobj(source, target)
|
||||
|
||||
print_formatted(
|
||||
f"Wheels extracted for {current_platform} to the '{wheels_directory}' directory.",
|
||||
"bold",
|
||||
color="green",
|
||||
return
|
||||
if all(arg not in ("-p", "--path") for arg in sys.argv):
|
||||
print(
|
||||
"This script is only used for and edge case of remote installs on some cloud providers, unrecognized arguments:",
|
||||
sys.argv[1:],
|
||||
)
|
||||
return
|
||||
|
||||
if whl_files:
|
||||
if whl_order:
|
||||
with open(whl_order, "r") as order:
|
||||
wheel_order_lines = [line.strip() for line in order]
|
||||
whl_files = sorted(
|
||||
whl_files,
|
||||
key=lambda x: wheel_order_lines.index(x.split("-")[0]),
|
||||
)
|
||||
# Parse command-line arguments
|
||||
parser = argparse.ArgumentParser(description="Comfy_mtb install script")
|
||||
parser.add_argument(
|
||||
"--path",
|
||||
"-p",
|
||||
type=str,
|
||||
help="Path to clone the repository to (i.e the absolute path to ComfyUI/custom_nodes)",
|
||||
)
|
||||
|
||||
for whl_file in tqdm(whl_files, desc="Installing", unit="package"):
|
||||
whl_path = wheels_directory / whl_file
|
||||
print_formatted("mtb install", "bold", color="yellow")
|
||||
|
||||
# check if installed
|
||||
try:
|
||||
whl_dep = whl_path.name.split("-")[0]
|
||||
import_name = pip_map.get(whl_dep, whl_dep)
|
||||
import_module(import_name)
|
||||
tqdm.write(
|
||||
f"Package {import_name} already installed, skipping wheel installation.",
|
||||
)
|
||||
continue
|
||||
except ImportError:
|
||||
if args.dry:
|
||||
tqdm.write(
|
||||
f"Dry-run: Package {whl_path.name} would be installed.",
|
||||
)
|
||||
continue
|
||||
args = parser.parse_args()
|
||||
|
||||
tqdm.write("Installing wheel: " + whl_path.name)
|
||||
print_formatted(f"Detected environment: {apply_color(mode,'cyan')}")
|
||||
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
whl_path.resolve().as_posix(),
|
||||
]
|
||||
)
|
||||
if args.path:
|
||||
clone_dir = Path(args.path)
|
||||
if not clone_dir.exists():
|
||||
print_formatted(
|
||||
"The path provided does not exist on disk... It must be pointing to ComfyUI's custom_nodes directory"
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
print_formatted("Wheels installation completed.", color="green")
|
||||
else:
|
||||
print_formatted("No .whl files found. Nothing to install.", color="yellow")
|
||||
repo_dir = clone_dir / repo_name
|
||||
if not repo_dir.exists():
|
||||
print_formatted(f"Cloning to {repo_dir}...", "italic", color="yellow")
|
||||
run_command(["git", "clone", "--recursive", repo_url, repo_dir])
|
||||
else:
|
||||
print_formatted(
|
||||
f"Directory {repo_dir} already exists, we will update it..."
|
||||
)
|
||||
run_command(["git", "pull", "-C", repo_dir])
|
||||
here = clone_dir
|
||||
full = True
|
||||
|
||||
# - Install all remainings
|
||||
install_dependencies(dry=args.dry)
|
||||
print_formatted("Checking environment...", "italic", color="yellow")
|
||||
missing_deps = []
|
||||
install_cmd = [executable, "-m", "pip", "install", "-r", "requirements.txt"]
|
||||
run_command(install_cmd)
|
||||
|
||||
print_formatted(
|
||||
"✅ Successfully installed all dependencies.", "italic", color="green"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import logging
|
||||
import re
|
||||
import os
|
||||
import re
|
||||
|
||||
base_log_level = logging.DEBUG if os.environ.get("MTB_DEBUG") else logging.INFO
|
||||
print(f"Log level: {base_log_level}")
|
||||
|
||||
|
||||
# Custom object that discards the output
|
||||
@@ -76,5 +75,7 @@ def cyan_text(text):
|
||||
|
||||
|
||||
def get_label(label):
|
||||
if label.startswith("MTB_"):
|
||||
label = label[4:]
|
||||
words = re.findall(r"(?:^|[A-Z])[a-z]*", label)
|
||||
return " ".join(words).strip()
|
||||
|
||||
+61
-42
@@ -1,43 +1,62 @@
|
||||
{
|
||||
"Animation Builder (mtb)": "Convenient way to manage basic animation maths at the core of many of my workflows",
|
||||
"Bbox (mtb)": "The bounding box (BBOX) custom type used by other nodes",
|
||||
"Bbox From Mask (mtb)": "From a mask extract the bounding box",
|
||||
"Blur (mtb)": "Blur an image using a Gaussian filter.",
|
||||
"Color Correct (mtb)": "Various color correction methods",
|
||||
"Colored Image (mtb)": "Constant color image of given size",
|
||||
"Concat Images (mtb)": "Add images to batch",
|
||||
"Crop (mtb)": "Crops an image and an optional mask to a given bounding box\n\n The bounding box can be given as a tuple of (x, y, width, height) or as a BBOX type\n The BBOX input takes precedence over the tuple input\n ",
|
||||
"Debug (mtb)": "Experimental node to debug any Comfy values, support for more types and widgets is planned",
|
||||
"Deep Bump (mtb)": "Normal & height maps generation from single pictures",
|
||||
"Export To Prores (mtb)": "Export to ProRes 4444 (Experimental)",
|
||||
"Face Swap (mtb)": "Face swap using deepinsight/insightface models",
|
||||
"Film Interpolation (mtb)": "Google Research FILM frame interpolation for large motion",
|
||||
"Fit Number (mtb)": "Fit the input float using a source and target range",
|
||||
"Float To Number (mtb)": "Node addon for the WAS Suite. Converts a \"comfy\" FLOAT to a NUMBER.",
|
||||
"Get Batch From History (mtb)": "Very experimental node to load images from the history of the server.\n\n Queue items without output are ignore in the count.",
|
||||
"Image Compare (mtb)": "Compare two images and return a difference image",
|
||||
"Image Premultiply (mtb)": "Premultiply image with mask",
|
||||
"Image Remove Background Rembg (mtb)": "Removes the background from the input using Rembg.",
|
||||
"Image Resize Factor (mtb)": "Extracted mostly from WAS Node Suite, with a few edits (most notably multiple image support) and less features.",
|
||||
"Int To Bool (mtb)": "Basic int to bool conversion",
|
||||
"Int To Number (mtb)": "Node addon for the WAS Suite. Converts a \"comfy\" INT to a NUMBER.",
|
||||
"Latent Lerp (mtb)": "Linear interpolation (blend) between two latent vectors",
|
||||
"Latent Noise (mtb)": "Inject noise into latent space",
|
||||
"Latent Transform (mtb)": "Dumb attempt at reproducing some deforum like motion",
|
||||
"Load Face Enhance Model (mtb)": "Loads a GFPGan or RestoreFormer model for face enhancement.",
|
||||
"Load Face Swap Model (mtb)": "Loads a faceswap model",
|
||||
"Load Film Model (mtb)": "Loads a FILM model",
|
||||
"Load Image From Url (mtb)": "Load an image from the given URL",
|
||||
"Load Image Sequence (mtb)": "Load an image sequence from a folder. The current frame is used to determine which image to load.\n\n Usually used in conjunction with the `Primitive` node set to increment to load a sequence of images from a folder.\n Use -1 to load all matching frames as a batch.\n ",
|
||||
"Mask To Image (mtb)": "Converts a mask (alpha) to an RGB image with a color and background",
|
||||
"Qr Code (mtb)": "Basic QR Code generator",
|
||||
"Restore Face (mtb)": "Uses GFPGan to restore faces",
|
||||
"Save Gif (mtb)": "Save the images from the batch as a GIF",
|
||||
"Save Image Grid (mtb)": "Save all the images in the input batch as a grid of images.",
|
||||
"Save Image Sequence (mtb)": "Save an image sequence to a folder. The current frame is used to determine which image to save.\n\n This is merely a wrapper around the `save_images` function with formatting for the output folder and filename.\n ",
|
||||
"Smart Step (mtb)": "Utils to control the steps start/stop of the KAdvancedSampler in percentage",
|
||||
"String Replace (mtb)": "Basic string replacement",
|
||||
"Styles Loader (mtb)": "Load csv files and populate a dropdown from the rows (\u00e0 la A111)",
|
||||
"Text To Image (mtb)": "Utils to convert text to image using a font\n\n\n The tool looks for any .ttf file in the Comfy folder hierarchy.\n ",
|
||||
"Uncrop (mtb)": "Uncrops an image to a given bounding box\n\n The bounding box can be given as a tuple of (x, y, width, height) or as a BBOX type\n The BBOX input takes precedence over the tuple input"
|
||||
{
|
||||
"Animation Builder (mtb)": "Convenient way to manage basic animation maths at the core of many of my workflows",
|
||||
"Any To String (mtb)": "Tries to take any input and convert it to a string",
|
||||
"Batch Float (mtb)": "Generates a batch of float values with interpolation",
|
||||
"Batch Float Assemble (mtb)": "Assembles mutiple batches of floats into a single stream (batch)",
|
||||
"Batch Float Fill (mtb)": "Fills a batch float with a single value until it reaches the target length",
|
||||
"Batch Make (mtb)": "Simply duplicates the input frame as a batch",
|
||||
"Batch Merge (mtb)": "Merges multiple image batches with different frame counts",
|
||||
"Batch Shake (mtb)": "Applies a shaking effect to batches of images.",
|
||||
"Batch Shape (mtb)": "Generates a batch of 2D shapes with optional shading (experimental)",
|
||||
"Batch Transform (mtb)": "Transform a batch of images using a batch of keyframes",
|
||||
"Bbox (mtb)": "The bounding box (BBOX) custom type used by other nodes",
|
||||
"Bbox From Mask (mtb)": "From a mask extract the bounding box",
|
||||
"Blur (mtb)": "Blur an image using a Gaussian filter.",
|
||||
"Color Correct (mtb)": "Various color correction methods",
|
||||
"Colored Image (mtb)": "Constant color image of given size",
|
||||
"Concat Images (mtb)": "Add images to batch",
|
||||
"Crop (mtb)": "Crops an image and an optional mask to a given bounding box\n\n The bounding box can be given as a tuple of (x, y, width, height) or as a BBOX type\n The BBOX input takes precedence over the tuple input\n ",
|
||||
"Debug (mtb)": "Experimental node to debug any Comfy values, support for more types and widgets is planned",
|
||||
"Deep Bump (mtb)": "Normal & height maps generation from single pictures",
|
||||
"Export With Ffmpeg (mtb)": "Export with FFmpeg (Experimental)",
|
||||
"Face Swap (mtb)": "Face swap using deepinsight/insightface models",
|
||||
"Film Interpolation (mtb)": "Google Research FILM frame interpolation for large motion",
|
||||
"Fit Number (mtb)": "Fit the input float using a source and target range",
|
||||
"Float To Number (mtb)": "Node addon for the WAS Suite. Converts a \"comfy\" FLOAT to a NUMBER.",
|
||||
"Get Batch From History (mtb)": "Very experimental node to load images from the history of the server.\n\n Queue items without output are ignored in the count.",
|
||||
"Image Compare (mtb)": "Compare two images and return a difference image",
|
||||
"Image Premultiply (mtb)": "Premultiply image with mask",
|
||||
"Image Remove Background Rembg (mtb)": "Removes the background from the input using Rembg.",
|
||||
"Image Resize Factor (mtb)": "Extracted mostly from WAS Node Suite, with a few edits (most notably multiple image support) and less features.",
|
||||
"Image Tile Offset (mtb)": "Mimics an old photoshop technique to check for seamless textures",
|
||||
"Int To Bool (mtb)": "Basic int to bool conversion",
|
||||
"Int To Number (mtb)": "Node addon for the WAS Suite. Converts a \"comfy\" INT to a NUMBER.",
|
||||
"Interpolate Clip Sequential (mtb)": null,
|
||||
"Latent Lerp (mtb)": "Linear interpolation (blend) between two latent vectors",
|
||||
"Load Face Analysis Model (mtb)": "Loads a face analysis model",
|
||||
"Load Face Enhance Model (mtb)": "Loads a GFPGan or RestoreFormer model for face enhancement.",
|
||||
"Load Face Swap Model (mtb)": "Loads a faceswap model",
|
||||
"Load Film Model (mtb)": "Loads a FILM model",
|
||||
"Load Image From Url (mtb)": "Load an image from the given URL",
|
||||
"Load Image Sequence (mtb)": "Load an image sequence from a folder. The current frame is used to determine which image to load.\n\n Usually used in conjunction with the `Primitive` node set to increment to load a sequence of images from a folder.\n Use -1 to load all matching frames as a batch.\n ",
|
||||
"Mask To Image (mtb)": "Converts a mask (alpha) to an RGB image with a color and background",
|
||||
"Math Expression (mtb)": "Node to evaluate a simple math expression string",
|
||||
"Model Patch Seamless (mtb)": "Uses the stable diffusion 'hack' to infer seamless images by setting the model layers padding mode to circular (experimental)",
|
||||
"Pick From Batch (mtb)": "Pick a specific number of images from a batch, either from the start or end.",
|
||||
"Qr Code (mtb)": "Basic QR Code generator",
|
||||
"Restore Face (mtb)": "Uses GFPGan to restore faces",
|
||||
"Save Gif (mtb)": "Save the images from the batch as a GIF",
|
||||
"Save Image Grid (mtb)": "Save all the images in the input batch as a grid of images.",
|
||||
"Save Image Sequence (mtb)": "Save an image sequence to a folder. The current frame is used to determine which image to save.\n\n This is merely a wrapper around the `save_images` function with formatting for the output folder and filename.\n ",
|
||||
"Save Tensors (mtb)": "Save torch tensors (image, mask or latent) to disk, useful to debug things outside comfy",
|
||||
"Sharpen (mtb)": "Sharpens an image using a Gaussian kernel.",
|
||||
"Smart Step (mtb)": "Utils to control the steps start/stop of the KAdvancedSampler in percentage",
|
||||
"Stack Images (mtb)": "Stack the input images horizontally or vertically",
|
||||
"String Replace (mtb)": "Basic string replacement",
|
||||
"Styles Loader (mtb)": "Load csv files and populate a dropdown from the rows (\u00e0 la A111)",
|
||||
"Text To Image (mtb)": "Utils to convert text to image using a font\n\n\n The tool looks for any .ttf file in the Comfy folder hierarchy.\n ",
|
||||
"Transform Image (mtb)": "Save torch tensors (image, mask or latent) to disk, useful to debug things outside comfy\n\n\n it return a tensor representing the transformed images with the same shape as the input tensor\n ",
|
||||
"Uncrop (mtb)": "Uncrops an image to a given bounding box\n\n The bounding box can be given as a tuple of (x, y, width, height) or as a BBOX type\n The BBOX input takes precedence over the tuple input",
|
||||
"Unsplash Image (mtb)": "Unsplash Image given a keyword and a size",
|
||||
"Vae Decode (mtb)": "Wrapper for the 2 core decoders but also adding the sd seamless hack, taken from: FlyingFireCo/tiled_ksampler"
|
||||
}
|
||||
+1
-1
@@ -17,7 +17,7 @@ class AnimationBuilder:
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT", "FLOAT", "INT", "BOOL")
|
||||
RETURN_TYPES = ("INT", "FLOAT", "INT", "BOOLEAN")
|
||||
RETURN_NAMES = ("frame", "0-1 (scaled)", "count", "loop_ended")
|
||||
CATEGORY = "mtb/animation"
|
||||
FUNCTION = "build_animation"
|
||||
|
||||
+625
@@ -0,0 +1,625 @@
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import cv2
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import torch
|
||||
|
||||
from ..log import log
|
||||
from ..utils import apply_easing, pil2tensor
|
||||
from .transform import TransformImage
|
||||
|
||||
|
||||
def hex_to_rgb(hex_color, bgr=False):
|
||||
hex_color = hex_color.lstrip("#")
|
||||
if bgr:
|
||||
return tuple(int(hex_color[i : i + 2], 16) for i in (4, 2, 0))
|
||||
|
||||
return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||
|
||||
|
||||
class BatchMake:
|
||||
"""Simply duplicates the input frame as a batch"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"count": ("INT", {"default": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate_batch"
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def generate_batch(self, image: torch.Tensor, count):
|
||||
if len(image.shape) == 3:
|
||||
image = image.unsqueeze(0)
|
||||
|
||||
return (image.repeat(count, 1, 1, 1),)
|
||||
|
||||
|
||||
class BatchShape:
|
||||
"""Generates a batch of 2D shapes with optional shading (experimental)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"count": ("INT", {"default": 1}),
|
||||
"shape": (
|
||||
["Box", "Circle", "Diamond"],
|
||||
{"default": "Box"},
|
||||
),
|
||||
"image_width": ("INT", {"default": 512}),
|
||||
"image_height": ("INT", {"default": 512}),
|
||||
"shape_size": ("INT", {"default": 100}),
|
||||
"color": ("COLOR", {"default": "#ffffff"}),
|
||||
"bg_color": ("COLOR", {"default": "#000000"}),
|
||||
"shade_color": ("COLOR", {"default": "#000000"}),
|
||||
"shadex": ("FLOAT", {"default": 0.0}),
|
||||
"shadey": ("FLOAT", {"default": 0.0}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate_shapes"
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def generate_shapes(
|
||||
self,
|
||||
count,
|
||||
shape,
|
||||
image_width,
|
||||
image_height,
|
||||
shape_size,
|
||||
color,
|
||||
bg_color,
|
||||
shade_color,
|
||||
shadex,
|
||||
shadey,
|
||||
):
|
||||
print(f"COLOR: {color}")
|
||||
print(f"BG_COLOR: {bg_color}")
|
||||
print(f"SHADE_COLOR: {shade_color}")
|
||||
|
||||
# Parse color input to BGR tuple for OpenCV
|
||||
color = hex_to_rgb(color)
|
||||
bg_color = hex_to_rgb(bg_color)
|
||||
shade_color = hex_to_rgb(shade_color)
|
||||
res = []
|
||||
for x in range(count):
|
||||
# Initialize an image canvas
|
||||
canvas = np.full((image_height, image_width, 3), bg_color, dtype=np.uint8)
|
||||
mask = np.zeros((image_height, image_width), dtype=np.uint8)
|
||||
|
||||
# Compute the center point of the shape
|
||||
center = (image_width // 2, image_height // 2)
|
||||
|
||||
if shape == "Box":
|
||||
half_size = shape_size // 2
|
||||
top_left = (center[0] - half_size, center[1] - half_size)
|
||||
bottom_right = (center[0] + half_size, center[1] + half_size)
|
||||
cv2.rectangle(mask, top_left, bottom_right, 255, -1)
|
||||
elif shape == "Circle":
|
||||
cv2.circle(mask, center, shape_size // 2, 255, -1)
|
||||
elif shape == "Diamond":
|
||||
pts = np.array(
|
||||
[
|
||||
[center[0], center[1] - shape_size // 2],
|
||||
[center[0] + shape_size // 2, center[1]],
|
||||
[center[0], center[1] + shape_size // 2],
|
||||
[center[0] - shape_size // 2, center[1]],
|
||||
]
|
||||
)
|
||||
cv2.fillPoly(mask, [pts], 255)
|
||||
|
||||
# Color the shape
|
||||
canvas[mask == 255] = color
|
||||
|
||||
# Apply shading effects to a separate shading canvas
|
||||
shading = np.zeros_like(canvas, dtype=np.float32)
|
||||
shading[:, :, 0] = shadex * np.linspace(0, 1, image_width)
|
||||
shading[:, :, 1] = shadey * np.linspace(0, 1, image_height).reshape(-1, 1)
|
||||
shading_canvas = cv2.addWeighted(
|
||||
canvas.astype(np.float32), 1, shading, 1, 0
|
||||
).astype(np.uint8)
|
||||
|
||||
# Apply shading only to the shape area using the mask
|
||||
canvas[mask == 255] = shading_canvas[mask == 255]
|
||||
res.append(canvas)
|
||||
|
||||
return (pil2tensor(res),)
|
||||
|
||||
|
||||
class BatchFloatFill:
|
||||
"""Fills a batch float with a single value until it reaches the target length"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"floats": ("FLOATS",),
|
||||
"direction": (["head", "tail"], {"default": "tail"}),
|
||||
"value": ("FLOAT", {"default": 0.0}),
|
||||
"count": ("INT", {"default": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
FUNCTION = "fill_floats"
|
||||
RETURN_TYPES = ("FLOATS",)
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def fill_floats(self, floats, direction, value, count):
|
||||
size = len(floats)
|
||||
if size > count:
|
||||
raise ValueError(f"Size ({size}) is less then target count ({count})")
|
||||
|
||||
rem = count - size
|
||||
if direction == "tail":
|
||||
floats = floats + [value] * rem
|
||||
else:
|
||||
floats = [value] * rem + floats
|
||||
return (floats,)
|
||||
|
||||
|
||||
class BatchFloatAssemble:
|
||||
"""Assembles mutiple batches of floats into a single stream (batch)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"reverse": ("BOOLEAN", {"default": False})}}
|
||||
|
||||
FUNCTION = "assemble_floats"
|
||||
RETURN_TYPES = ("FLOATS",)
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def assemble_floats(self, reverse, **kwargs):
|
||||
res = []
|
||||
if reverse:
|
||||
for x in reversed(kwargs.values()):
|
||||
res += x
|
||||
else:
|
||||
for x in kwargs.values():
|
||||
res += x
|
||||
|
||||
return (res,)
|
||||
|
||||
|
||||
class BatchFloat:
|
||||
"""Generates a batch of float values with interpolation"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"mode": (
|
||||
["Single", "Steps"],
|
||||
{"default": "Steps"},
|
||||
),
|
||||
"count": ("INT", {"default": 1}),
|
||||
"min": ("FLOAT", {"default": 0.0}),
|
||||
"max": ("FLOAT", {"default": 1.0}),
|
||||
"easing": (
|
||||
[
|
||||
"Linear",
|
||||
"Sine In",
|
||||
"Sine Out",
|
||||
"Sine In/Out",
|
||||
"Quart In",
|
||||
"Quart Out",
|
||||
"Quart In/Out",
|
||||
"Cubic In",
|
||||
"Cubic Out",
|
||||
"Cubic In/Out",
|
||||
"Circ In",
|
||||
"Circ Out",
|
||||
"Circ In/Out",
|
||||
"Back In",
|
||||
"Back Out",
|
||||
"Back In/Out",
|
||||
"Elastic In",
|
||||
"Elastic Out",
|
||||
"Elastic In/Out",
|
||||
"Bounce In",
|
||||
"Bounce Out",
|
||||
"Bounce In/Out",
|
||||
],
|
||||
{"default": "Linear"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
FUNCTION = "set_floats"
|
||||
RETURN_TYPES = ("FLOATS",)
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def set_floats(self, mode, count, min, max, easing):
|
||||
keyframes = []
|
||||
if mode == "Single":
|
||||
keyframes = [min] * count
|
||||
return (keyframes,)
|
||||
|
||||
for i in range(count):
|
||||
normalized_step = i / (count - 1)
|
||||
eased_step = apply_easing(normalized_step, easing)
|
||||
eased_value = min + (max - min) * eased_step
|
||||
keyframes.append(eased_value)
|
||||
|
||||
return (keyframes,)
|
||||
|
||||
|
||||
class BatchMerge:
|
||||
"""Merges multiple image batches with different frame counts"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"fusion_mode": (["add", "multiply", "average"], {"default": "average"}),
|
||||
"fill": (["head", "tail"], {"default": "tail"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "merge_batches"
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def merge_batches(self, fusion_mode, fill, **kwargs):
|
||||
images = kwargs.values()
|
||||
max_frames = max(img.shape[0] for img in images)
|
||||
|
||||
adjusted_images = []
|
||||
for img in images:
|
||||
frame_count = img.shape[0]
|
||||
if frame_count < max_frames:
|
||||
fill_frame = img[0] if fill == "head" else img[-1]
|
||||
fill_frames = fill_frame.repeat(max_frames - frame_count, 1, 1, 1)
|
||||
adjusted_batch = (
|
||||
torch.cat((fill_frames, img), dim=0)
|
||||
if fill == "head"
|
||||
else torch.cat((img, fill_frames), dim=0)
|
||||
)
|
||||
else:
|
||||
adjusted_batch = img
|
||||
adjusted_images.append(adjusted_batch)
|
||||
|
||||
# Merge the adjusted batches
|
||||
merged_image = None
|
||||
for img in adjusted_images:
|
||||
if merged_image is None:
|
||||
merged_image = img
|
||||
else:
|
||||
if fusion_mode == "add":
|
||||
merged_image += img
|
||||
elif fusion_mode == "multiply":
|
||||
merged_image *= img
|
||||
elif fusion_mode == "average":
|
||||
merged_image = (merged_image + img) / 2
|
||||
|
||||
return (merged_image,)
|
||||
|
||||
|
||||
class Batch2dTransform:
|
||||
"""Transform a batch of images using a batch of keyframes"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"border_handling": (
|
||||
["edge", "constant", "reflect", "symmetric"],
|
||||
{"default": "edge"},
|
||||
),
|
||||
"constant_color": ("COLOR", {"default": "#000000"}),
|
||||
},
|
||||
"optional": {
|
||||
"x": ("FLOATS",),
|
||||
"y": ("FLOATS",),
|
||||
"zoom": ("FLOATS",),
|
||||
"angle": ("FLOATS",),
|
||||
"shear": ("FLOATS",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "transform_batch"
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def get_num_elements(self, param) -> int:
|
||||
if isinstance(param, torch.Tensor):
|
||||
return torch.numel(param)
|
||||
elif isinstance(param, list):
|
||||
return len(param)
|
||||
|
||||
return 0
|
||||
|
||||
def transform_batch(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
border_handling,
|
||||
constant_color,
|
||||
x=None,
|
||||
y=None,
|
||||
zoom=None,
|
||||
angle=None,
|
||||
shear=None,
|
||||
):
|
||||
if all(
|
||||
self.get_num_elements(param) <= 0 for param in [x, y, zoom, angle, shear]
|
||||
):
|
||||
raise ValueError("At least one transform parameter must be provided")
|
||||
|
||||
keyframes = {"x": [], "y": [], "zoom": [], "angle": [], "shear": []}
|
||||
|
||||
default_vals = {"x": 0, "y": 0, "zoom": 1.0, "angle": 0, "shear": 0}
|
||||
|
||||
if self.get_num_elements(x) > 0:
|
||||
keyframes["x"] = x
|
||||
if self.get_num_elements(y) > 0:
|
||||
keyframes["y"] = y
|
||||
if self.get_num_elements(zoom) > 0:
|
||||
keyframes["zoom"] = zoom
|
||||
if self.get_num_elements(angle) > 0:
|
||||
keyframes["angle"] = angle
|
||||
if self.get_num_elements(shear) > 0:
|
||||
keyframes["shear"] = shear
|
||||
|
||||
for name, values in keyframes.items():
|
||||
count = len(values)
|
||||
if count > 0 and count != image.shape[0]:
|
||||
raise ValueError(
|
||||
f"Length of {name} values ({count}) must match number of images ({image.shape[0]})"
|
||||
)
|
||||
if count == 0:
|
||||
keyframes[name] = [default_vals[name]] * image.shape[0]
|
||||
|
||||
transformer = TransformImage()
|
||||
res = [
|
||||
transformer.transform(
|
||||
image[i].unsqueeze(0),
|
||||
keyframes["x"][i],
|
||||
keyframes["y"][i],
|
||||
keyframes["zoom"][i],
|
||||
keyframes["angle"][i],
|
||||
keyframes["shear"][i],
|
||||
border_handling,
|
||||
constant_color,
|
||||
)[0]
|
||||
for i in range(image.shape[0])
|
||||
]
|
||||
return (torch.cat(res, dim=0),)
|
||||
|
||||
|
||||
DEFAULT_INTERPOLANT = lambda t: t * t * t * (t * (t * 6 - 15) + 10)
|
||||
|
||||
|
||||
class BatchShake:
|
||||
"""Applies a shaking effect to batches of images."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"position_amount_x": ("FLOAT", {"default": 1.0}),
|
||||
"position_amount_y": ("FLOAT", {"default": 1.0}),
|
||||
"rotation_amount": ("FLOAT", {"default": 10.0}),
|
||||
"frequency": ("FLOAT", {"default": 1.0, "min": 0.005}),
|
||||
"frequency_divider": ("FLOAT", {"default": 1.0, "min": 0.005}),
|
||||
"octaves": ("INT", {"default": 1, "min": 1}),
|
||||
"seed": ("INT", {"default": 0}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "FLOATS", "FLOATS", "FLOATS")
|
||||
RETURN_NAMES = ("image", "pos_x", "pos_y", "rot")
|
||||
FUNCTION = "apply_shake"
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
# def interpolant(self, t):
|
||||
# return t * t * t * (t * (t * 6 - 15) + 10)
|
||||
|
||||
def generate_perlin_noise_2d(
|
||||
self, shape, res, tileable=(False, False), interpolant=None
|
||||
):
|
||||
"""Generate a 2D numpy array of perlin noise.
|
||||
|
||||
Args:
|
||||
shape: The shape of the generated array (tuple of two ints).
|
||||
This must be a multple of res.
|
||||
res: The number of periods of noise to generate along each
|
||||
axis (tuple of two ints). Note shape must be a multiple of
|
||||
res.
|
||||
tileable: If the noise should be tileable along each axis
|
||||
(tuple of two bools). Defaults to (False, False).
|
||||
interpolant: The interpolation function, defaults to
|
||||
t*t*t*(t*(t*6 - 15) + 10).
|
||||
|
||||
Returns:
|
||||
A numpy array of shape shape with the generated noise.
|
||||
|
||||
Raises:
|
||||
ValueError: If shape is not a multiple of res.
|
||||
"""
|
||||
interpolant = interpolant or DEFAULT_INTERPOLANT
|
||||
delta = (res[0] / shape[0], res[1] / shape[1])
|
||||
d = (shape[0] // res[0], shape[1] // res[1])
|
||||
grid = (
|
||||
np.mgrid[0 : res[0] : delta[0], 0 : res[1] : delta[1]].transpose(1, 2, 0)
|
||||
% 1
|
||||
)
|
||||
# Gradients
|
||||
angles = 2 * np.pi * np.random.rand(res[0] + 1, res[1] + 1)
|
||||
gradients = np.dstack((np.cos(angles), np.sin(angles)))
|
||||
if tileable[0]:
|
||||
gradients[-1, :] = gradients[0, :]
|
||||
if tileable[1]:
|
||||
gradients[:, -1] = gradients[:, 0]
|
||||
gradients = gradients.repeat(d[0], 0).repeat(d[1], 1)
|
||||
g00 = gradients[: -d[0], : -d[1]]
|
||||
g10 = gradients[d[0] :, : -d[1]]
|
||||
g01 = gradients[: -d[0], d[1] :]
|
||||
g11 = gradients[d[0] :, d[1] :]
|
||||
# Ramps
|
||||
n00 = np.sum(np.dstack((grid[:, :, 0], grid[:, :, 1])) * g00, 2)
|
||||
n10 = np.sum(np.dstack((grid[:, :, 0] - 1, grid[:, :, 1])) * g10, 2)
|
||||
n01 = np.sum(np.dstack((grid[:, :, 0], grid[:, :, 1] - 1)) * g01, 2)
|
||||
n11 = np.sum(np.dstack((grid[:, :, 0] - 1, grid[:, :, 1] - 1)) * g11, 2)
|
||||
# Interpolation
|
||||
t = interpolant(grid)
|
||||
n0 = n00 * (1 - t[:, :, 0]) + t[:, :, 0] * n10
|
||||
n1 = n01 * (1 - t[:, :, 0]) + t[:, :, 0] * n11
|
||||
return np.sqrt(2) * ((1 - t[:, :, 1]) * n0 + t[:, :, 1] * n1)
|
||||
|
||||
def generate_fractal_noise_2d(
|
||||
self,
|
||||
shape,
|
||||
res,
|
||||
octaves=1,
|
||||
persistence=0.5,
|
||||
lacunarity=2,
|
||||
tileable=(True, True),
|
||||
interpolant=None,
|
||||
):
|
||||
"""Generate a 2D numpy array of fractal noise.
|
||||
|
||||
Args:
|
||||
shape: The shape of the generated array (tuple of two ints).
|
||||
This must be a multiple of lacunarity**(octaves-1)*res.
|
||||
res: The number of periods of noise to generate along each
|
||||
axis (tuple of two ints). Note shape must be a multiple of
|
||||
(lacunarity**(octaves-1)*res).
|
||||
octaves: The number of octaves in the noise. Defaults to 1.
|
||||
persistence: The scaling factor between two octaves.
|
||||
lacunarity: The frequency factor between two octaves.
|
||||
tileable: If the noise should be tileable along each axis
|
||||
(tuple of two bools). Defaults to (True,True).
|
||||
interpolant: The, interpolation function, defaults to
|
||||
t*t*t*(t*(t*6 - 15) + 10).
|
||||
|
||||
Returns:
|
||||
A numpy array of fractal noise and of shape shape generated by
|
||||
combining several octaves of perlin noise.
|
||||
|
||||
Raises:
|
||||
ValueError: If shape is not a multiple of
|
||||
(lacunarity**(octaves-1)*res).
|
||||
"""
|
||||
interpolant = interpolant or DEFAULT_INTERPOLANT
|
||||
|
||||
noise = np.zeros(shape)
|
||||
frequency = 1
|
||||
amplitude = 1
|
||||
for _ in range(octaves):
|
||||
noise += amplitude * self.generate_perlin_noise_2d(
|
||||
shape, (frequency * res[0], frequency * res[1]), tileable, interpolant
|
||||
)
|
||||
frequency *= lacunarity
|
||||
amplitude *= persistence
|
||||
return noise
|
||||
|
||||
def fbm(self, x, y, octaves):
|
||||
# noise_2d = self.generate_fractal_noise_2d((256, 256), (8, 8), octaves)
|
||||
# Now, extract a single noise value based on x and y, wrapping indices if necessary
|
||||
x_idx = int(x) % 256
|
||||
y_idx = int(y) % 256
|
||||
return self.noise_pattern[x_idx, y_idx]
|
||||
|
||||
def apply_shake(
|
||||
self,
|
||||
images,
|
||||
position_amount_x,
|
||||
position_amount_y,
|
||||
rotation_amount,
|
||||
frequency,
|
||||
frequency_divider,
|
||||
octaves,
|
||||
seed,
|
||||
):
|
||||
# Rehash
|
||||
np.random.seed(seed)
|
||||
self.position_offset = np.random.uniform(-1e3, 1e3, 3)
|
||||
self.rotation_offset = np.random.uniform(-1e3, 1e3, 3)
|
||||
self.noise_pattern = self.generate_perlin_noise_2d(
|
||||
(512, 512), (32, 32), (True, True)
|
||||
)
|
||||
|
||||
# Assuming frame count is derived from the first dimension of images tensor
|
||||
frame_count = images.shape[0]
|
||||
|
||||
frequency = frequency / frequency_divider
|
||||
|
||||
# Generate shaking parameters for each frame
|
||||
x_translations = []
|
||||
y_translations = []
|
||||
rotations = []
|
||||
|
||||
for frame_num in range(frame_count):
|
||||
time = frame_num * frequency
|
||||
x_idx = (self.position_offset[0] + frame_num) % 256
|
||||
y_idx = (self.position_offset[1] + frame_num) % 256
|
||||
|
||||
np_position = np.array(
|
||||
[
|
||||
self.fbm(x_idx, time, octaves),
|
||||
self.fbm(y_idx, time, octaves),
|
||||
]
|
||||
)
|
||||
|
||||
# np_position = np.array(
|
||||
# [
|
||||
# self.fbm(self.position_offset[0] + frame_num, time, octaves),
|
||||
# self.fbm(self.position_offset[1] + frame_num, time, octaves),
|
||||
# ]
|
||||
# )
|
||||
# np_rotation = self.fbm(self.rotation_offset[2] + frame_num, time, octaves)
|
||||
|
||||
rot_idx = (self.rotation_offset[2] + frame_num) % 256
|
||||
np_rotation = self.fbm(rot_idx, time, octaves)
|
||||
|
||||
x_translations.append(np_position[0] * position_amount_x)
|
||||
y_translations.append(np_position[1] * position_amount_y)
|
||||
rotations.append(np_rotation * rotation_amount)
|
||||
|
||||
# Convert lists to tensors
|
||||
# x_translations = torch.tensor(x_translations, dtype=torch.float32)
|
||||
# y_translations = torch.tensor(y_translations, dtype=torch.float32)
|
||||
# rotations = torch.tensor(rotations, dtype=torch.float32)
|
||||
|
||||
# Create an instance of Batch2dTransform
|
||||
transform = Batch2dTransform()
|
||||
|
||||
log.debug(
|
||||
f"Applying shaking with parameters: \nposition {position_amount_x}, {position_amount_y}\nrotation {rotation_amount}\nfrequency {frequency}\noctaves {octaves}"
|
||||
)
|
||||
|
||||
# Apply shaking transformations to images
|
||||
shaken_images = transform.transform_batch(
|
||||
images,
|
||||
border_handling="edge", # Assuming edge handling as default
|
||||
constant_color="#000000", # Assuming black as default constant color
|
||||
x=x_translations,
|
||||
y=y_translations,
|
||||
angle=rotations,
|
||||
)[0]
|
||||
|
||||
return (shaken_images, x_translations, y_translations, rotations)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
BatchFloat,
|
||||
Batch2dTransform,
|
||||
BatchShape,
|
||||
BatchMake,
|
||||
BatchFloatAssemble,
|
||||
BatchFloatFill,
|
||||
BatchMerge,
|
||||
BatchShake,
|
||||
]
|
||||
+105
-115
@@ -1,10 +1,92 @@
|
||||
from ..utils import pil2tensor
|
||||
from ..utils import here, comfy_dir
|
||||
from ..log import log
|
||||
import folder_paths
|
||||
import csv, shutil
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import csv
|
||||
|
||||
import folder_paths
|
||||
|
||||
from ..log import log
|
||||
from ..utils import here
|
||||
|
||||
|
||||
class InterpolateClipSequential:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"base_text": ("STRING", {"multiline": True}),
|
||||
"text_to_replace": ("STRING", {"default": ""}),
|
||||
"clip": ("CLIP",),
|
||||
"interpolation_strength": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING",)
|
||||
FUNCTION = "interpolate_encodings_sequential"
|
||||
|
||||
CATEGORY = "mtb/conditioning"
|
||||
|
||||
def interpolate_encodings_sequential(
|
||||
self, base_text, text_to_replace, clip, interpolation_strength, **replacements
|
||||
):
|
||||
log.debug(f"Received interpolation_strength: {interpolation_strength}")
|
||||
|
||||
# - Ensure interpolation strength is within [0, 1]
|
||||
interpolation_strength = max(0.0, min(1.0, interpolation_strength))
|
||||
|
||||
# - Check if replacements were provided
|
||||
if not replacements:
|
||||
raise ValueError("At least one replacement should be provided.")
|
||||
|
||||
num_replacements = len(replacements)
|
||||
log.debug(f"Number of replacements: {num_replacements}")
|
||||
|
||||
segment_length = 1.0 / num_replacements
|
||||
log.debug(f"Calculated segment_length: {segment_length}")
|
||||
|
||||
# - Find the segment that the interpolation_strength falls into
|
||||
segment_index = min(
|
||||
int(interpolation_strength // segment_length), num_replacements - 1
|
||||
)
|
||||
log.debug(f"Segment index: {segment_index}")
|
||||
|
||||
# - Calculate the local strength within the segment
|
||||
local_strength = (
|
||||
interpolation_strength - (segment_index * segment_length)
|
||||
) / segment_length
|
||||
log.debug(f"Local strength: {local_strength}")
|
||||
|
||||
# - If it's the first segment, interpolate between base_text and the first replacement
|
||||
if segment_index == 0:
|
||||
replacement_text = list(replacements.values())[0]
|
||||
log.debug("Using the base text a the base blend")
|
||||
# - Start with the base_text condition
|
||||
tokens = clip.tokenize(base_text)
|
||||
cond_from, pooled_from = clip.encode_from_tokens(tokens, return_pooled=True)
|
||||
else:
|
||||
base_replace = list(replacements.values())[segment_index - 1]
|
||||
log.debug(f"Using {base_replace} a the base blend")
|
||||
|
||||
# - Start with the base_text condition replaced by the closest replacement
|
||||
tokens = clip.tokenize(base_text.replace(text_to_replace, base_replace))
|
||||
cond_from, pooled_from = clip.encode_from_tokens(tokens, return_pooled=True)
|
||||
|
||||
replacement_text = list(replacements.values())[segment_index]
|
||||
|
||||
interpolated_text = base_text.replace(text_to_replace, replacement_text)
|
||||
tokens = clip.tokenize(interpolated_text)
|
||||
cond_to, pooled_to = clip.encode_from_tokens(tokens, return_pooled=True)
|
||||
|
||||
# - Linearly interpolate between the two conditions
|
||||
interpolated_condition = (
|
||||
1.0 - local_strength
|
||||
) * cond_from + local_strength * cond_to
|
||||
interpolated_pooled = (
|
||||
1.0 - local_strength
|
||||
) * pooled_from + local_strength * pooled_to
|
||||
|
||||
return ([[interpolated_condition, {"pooled_output": interpolated_pooled}]],)
|
||||
|
||||
|
||||
class SmartStep:
|
||||
@@ -74,9 +156,23 @@ class StylesLoader:
|
||||
for file in files:
|
||||
with open(file, "r", encoding="utf8") as f:
|
||||
parsed = csv.reader(f)
|
||||
for row in parsed:
|
||||
for i, row in enumerate(parsed):
|
||||
log.debug(f"Adding style {row[0]}")
|
||||
cls.options[row[0]] = (row[1], row[2])
|
||||
try:
|
||||
name, positive, negative = (row + [None] * 3)[:3]
|
||||
positive = positive or ""
|
||||
negative = negative or ""
|
||||
if name is not None:
|
||||
cls.options[name] = (positive, negative)
|
||||
else:
|
||||
# Handle the case where 'name' is None
|
||||
log.warning(f"Missing 'name' in row {i}.")
|
||||
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
f"There was an error while parsing {file}, make sure it respects A1111 format, i.e 3 columns name, positive, negative:\n{e}"
|
||||
)
|
||||
continue
|
||||
|
||||
else:
|
||||
log.debug(f"Using cached styles (count: {len(cls.options)})")
|
||||
@@ -97,110 +193,4 @@ class StylesLoader:
|
||||
return (self.options[style_name][0], self.options[style_name][1])
|
||||
|
||||
|
||||
class TextToImage:
|
||||
"""Utils to convert text to image using a font
|
||||
|
||||
|
||||
The tool looks for any .ttf file in the Comfy folder hierarchy.
|
||||
"""
|
||||
|
||||
fonts = {}
|
||||
|
||||
def __init__(self):
|
||||
# - This is executed when the graph is executed, we could conditionaly reload fonts there
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def CACHE_FONTS(cls):
|
||||
font_extensions = ["*.ttf", "*.otf", "*.woff", "*.woff2", "*.eot"]
|
||||
fonts = []
|
||||
|
||||
for extension in font_extensions:
|
||||
fonts.extend(comfy_dir.glob(f"**/{extension}"))
|
||||
|
||||
if not fonts:
|
||||
log.warn(
|
||||
"> No fonts found in the comfy folder, place at least one font file somewhere in ComfyUI's hierarchy"
|
||||
)
|
||||
else:
|
||||
log.debug(f"> Found {len(fonts)} fonts")
|
||||
|
||||
for font in fonts:
|
||||
log.debug(f"Adding font {font}")
|
||||
cls.fonts[font.stem] = font.as_posix()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
if not cls.fonts:
|
||||
cls.CACHE_FONTS()
|
||||
else:
|
||||
log.debug(f"Using cached fonts (count: {len(cls.fonts)})")
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
"STRING",
|
||||
{"default": "Hello world!"},
|
||||
),
|
||||
"font": ((sorted(cls.fonts.keys())),),
|
||||
"wrap": (
|
||||
"INT",
|
||||
{"default": 120, "min": 0, "max": 8096, "step": 1},
|
||||
),
|
||||
"font_size": (
|
||||
"INT",
|
||||
{"default": 12, "min": 1, "max": 2500, "step": 1},
|
||||
),
|
||||
"width": (
|
||||
"INT",
|
||||
{"default": 512, "min": 1, "max": 8096, "step": 1},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{"default": 512, "min": 1, "max": 8096, "step": 1},
|
||||
),
|
||||
# "position": (["INT"], {"default": 0, "min": 0, "max": 100, "step": 1}),
|
||||
"color": (
|
||||
"COLOR",
|
||||
{"default": "black"},
|
||||
),
|
||||
"background": (
|
||||
"COLOR",
|
||||
{"default": "white"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "text_to_image"
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
def text_to_image(
|
||||
self, text, font, wrap, font_size, width, height, color, background
|
||||
):
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import textwrap
|
||||
|
||||
font = self.fonts[font]
|
||||
font = ImageFont.truetype(font, font_size)
|
||||
if wrap == 0:
|
||||
wrap = width / font_size
|
||||
lines = textwrap.wrap(text, width=wrap)
|
||||
log.debug(f"Lines: {lines}")
|
||||
line_height = font.getsize("hg")[1]
|
||||
img_height = height # line_height * len(lines)
|
||||
img_width = width # max(font.getsize(line)[0] for line in lines)
|
||||
|
||||
img = Image.new("RGBA", (img_width, img_height), background)
|
||||
draw = ImageDraw.Draw(img)
|
||||
y_text = 0
|
||||
for line in lines:
|
||||
width, height = font.getsize(line)
|
||||
draw.text((0, y_text), line, color, font=font)
|
||||
y_text += height
|
||||
|
||||
# img.save(os.path.join(folder_paths.base_path, f'{str(uuid.uuid4())}.png'))
|
||||
return (pil2tensor(img),)
|
||||
|
||||
|
||||
__nodes__ = [SmartStep, TextToImage, StylesLoader]
|
||||
__nodes__ = [SmartStep, StylesLoader, InterpolateClipSequential]
|
||||
|
||||
+15
-11
@@ -1,9 +1,9 @@
|
||||
import torch
|
||||
from ..utils import tensor2pil, pil2tensor, tensor2np, np2tensor
|
||||
from PIL import Image, ImageFilter, ImageDraw, ImageChops
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image, ImageChops, ImageDraw, ImageFilter
|
||||
|
||||
from ..log import log
|
||||
from ..utils import np2tensor, pil2tensor, tensor2np, tensor2pil
|
||||
|
||||
|
||||
class Bbox:
|
||||
@@ -32,7 +32,7 @@ class Bbox:
|
||||
CATEGORY = "mtb/crop"
|
||||
|
||||
def do_crop(self, x, y, width, height): # bbox
|
||||
return (x, y, width, height)
|
||||
return ((x, y, width, height),)
|
||||
# return bbox
|
||||
|
||||
|
||||
@@ -44,6 +44,7 @@ class BboxFromMask:
|
||||
return {
|
||||
"required": {
|
||||
"mask": ("MASK",),
|
||||
"invert": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
@@ -61,7 +62,7 @@ class BboxFromMask:
|
||||
FUNCTION = "extract_bounding_box"
|
||||
CATEGORY = "mtb/crop"
|
||||
|
||||
def extract_bounding_box(self, mask: torch.Tensor, image=None):
|
||||
def extract_bounding_box(self, mask: torch.Tensor, invert: bool, image=None):
|
||||
# if image != None:
|
||||
# if mask.size(0) != image.size(0):
|
||||
# if mask.size(0) != 1:
|
||||
@@ -73,9 +74,8 @@ class BboxFromMask:
|
||||
# f"Batch count mismatch for mask and image, it can either be 1 mask for X images, or X masks for X images (mask: {mask.shape} | image: {image.shape})"
|
||||
# )
|
||||
|
||||
_mask = tensor2pil(1.0 - mask)[0]
|
||||
|
||||
# we invert it
|
||||
_mask = tensor2pil(1.0 - mask)[0] if invert else tensor2pil(mask)[0]
|
||||
alpha_channel = np.array(_mask)
|
||||
|
||||
non_zero_indices = np.nonzero(alpha_channel)
|
||||
@@ -141,19 +141,23 @@ class Crop:
|
||||
self, image: torch.Tensor, mask=None, x=0, y=0, width=256, height=256, bbox=None
|
||||
):
|
||||
image = image.numpy()
|
||||
if mask:
|
||||
if mask is not None:
|
||||
mask = mask.numpy()
|
||||
|
||||
if bbox != None:
|
||||
if bbox is not None:
|
||||
x, y, width, height = bbox
|
||||
|
||||
cropped_image = image[:, y : y + height, x : x + width, :]
|
||||
cropped_mask = mask[y : y + height, x : x + width] if mask != None else None
|
||||
cropped_mask = None
|
||||
if mask is not None:
|
||||
cropped_mask = (
|
||||
mask[:, y : y + height, x : x + width] if mask is not None else None
|
||||
)
|
||||
crop_data = (x, y, width, height)
|
||||
|
||||
return (
|
||||
torch.from_numpy(cropped_image),
|
||||
torch.from_numpy(cropped_mask) if mask != None else None,
|
||||
torch.from_numpy(cropped_mask) if cropped_mask is not None else None,
|
||||
crop_data,
|
||||
)
|
||||
|
||||
|
||||
+87
-34
@@ -1,10 +1,71 @@
|
||||
from ..utils import tensor2pil
|
||||
from ..log import log
|
||||
import io, base64
|
||||
import torch
|
||||
import folder_paths
|
||||
from typing import Optional
|
||||
import base64
|
||||
import io
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import folder_paths
|
||||
import torch
|
||||
|
||||
from ..log import log
|
||||
from ..utils import tensor2pil
|
||||
|
||||
|
||||
# region processors
|
||||
def process_tensor(tensor):
|
||||
log.debug(f"Tensor: {tensor.shape}")
|
||||
|
||||
image = tensor2pil(tensor)
|
||||
b64_imgs = []
|
||||
for im in image:
|
||||
buffered = io.BytesIO()
|
||||
im.save(buffered, format="PNG")
|
||||
b64_imgs.append(
|
||||
"data:image/png;base64,"
|
||||
+ base64.b64encode(buffered.getvalue()).decode("utf-8")
|
||||
)
|
||||
|
||||
return {"b64_images": b64_imgs}
|
||||
|
||||
|
||||
def process_list(anything):
|
||||
text = []
|
||||
if not anything:
|
||||
return {"text": []}
|
||||
|
||||
first_element = anything[0]
|
||||
if (
|
||||
isinstance(first_element, list)
|
||||
and first_element
|
||||
and isinstance(first_element[0], torch.Tensor)
|
||||
):
|
||||
text.append(
|
||||
f"List of List of Tensors: {first_element[0].shape} (x{len(anything)})"
|
||||
)
|
||||
|
||||
elif isinstance(first_element, torch.Tensor):
|
||||
text.append(f"List of Tensors: {first_element.shape} (x{len(anything)})")
|
||||
|
||||
return {"text": text}
|
||||
|
||||
|
||||
def process_dict(anything):
|
||||
text = []
|
||||
if "samples" in anything:
|
||||
is_empty = "(empty)" if torch.count_nonzero(anything["samples"]) == 0 else ""
|
||||
text.append(f"Latent Samples: {anything['samples'].shape} {is_empty}")
|
||||
|
||||
return {"text": text}
|
||||
|
||||
|
||||
def process_bool(anything):
|
||||
return {"text": ["True" if anything else "False"]}
|
||||
|
||||
|
||||
def process_text(anything):
|
||||
return {"text": [str(anything)]}
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
class Debug:
|
||||
@@ -13,46 +74,38 @@ class Debug:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"anything_1": ("*")},
|
||||
"required": {"output_to_console": ("BOOLEAN", {"default": False})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_TYPES = ()
|
||||
FUNCTION = "do_debug"
|
||||
CATEGORY = "mtb/debug"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def do_debug(self, **kwargs):
|
||||
def do_debug(self, output_to_console, **kwargs):
|
||||
output = {
|
||||
"ui": {"b64_images": [], "text": []},
|
||||
"result": ("A"),
|
||||
# "result": ("A"),
|
||||
}
|
||||
for k, v in kwargs.items():
|
||||
anything = v
|
||||
text = ""
|
||||
if isinstance(anything, torch.Tensor):
|
||||
log.debug(f"Tensor: {anything.shape}")
|
||||
|
||||
# write the images to temp
|
||||
processors = {
|
||||
torch.Tensor: process_tensor,
|
||||
list: process_list,
|
||||
dict: process_dict,
|
||||
bool: process_bool,
|
||||
}
|
||||
if output_to_console:
|
||||
print("bouh!")
|
||||
|
||||
image = tensor2pil(anything)
|
||||
b64_imgs = []
|
||||
for im in image:
|
||||
buffered = io.BytesIO()
|
||||
im.save(buffered, format="JPEG")
|
||||
b64_imgs.append(
|
||||
"data:image/jpeg;base64,"
|
||||
+ base64.b64encode(buffered.getvalue()).decode("utf-8")
|
||||
)
|
||||
for anything in kwargs.values():
|
||||
processor = processors.get(type(anything), process_text)
|
||||
processed_data = processor(anything)
|
||||
|
||||
output["ui"]["b64_images"] += b64_imgs
|
||||
log.debug(f"Input {k} contains {len(b64_imgs)} images")
|
||||
elif isinstance(anything, bool):
|
||||
log.debug(f"Input {k} contains boolean: {anything}")
|
||||
output["ui"]["text"] += ["True" if anything else "False"]
|
||||
else:
|
||||
text = str(anything)
|
||||
log.debug(f"Input {k} contains text: {text}")
|
||||
output["ui"]["text"] += [text]
|
||||
for ui_key, ui_value in processed_data.items():
|
||||
output["ui"][ui_key].extend(ui_value)
|
||||
# log.debug(
|
||||
# f"Processed input {k}, found {len(processed_data.get('b64_images', []))} images and {len(processed_data.get('text', []))} text items."
|
||||
# )
|
||||
|
||||
return output
|
||||
|
||||
|
||||
+83
-31
@@ -1,23 +1,38 @@
|
||||
import onnxruntime as ort
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pathlib
|
||||
import onnxruntime as ort
|
||||
import numpy as np
|
||||
from .. import utils as utils_inference
|
||||
from ..log import log
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..errors import ModelNotFound
|
||||
from ..log import mklog
|
||||
from ..utils import get_model_path, tensor2pil, tiles_infer, tiles_merge, tiles_split
|
||||
|
||||
# Disable MS telemetry
|
||||
ort.disable_telemetry_events()
|
||||
log = mklog(__name__)
|
||||
|
||||
|
||||
# - COLOR to NORMALS
|
||||
def color_to_normals(color_img, overlap, progress_callback):
|
||||
def color_to_normals(color_img, overlap, progress_callback, save_temp=False):
|
||||
"""Computes a normal map from the given color map. 'color_img' must be a numpy array
|
||||
in C,H,W format (with C as RGB). 'overlap' must be one of 'SMALL', 'MEDIUM', 'LARGE'.
|
||||
"""
|
||||
temp_dir = Path(tempfile.mkdtemp()) if save_temp else None
|
||||
|
||||
# Remove alpha & convert to grayscale
|
||||
img = np.mean(color_img[:3], axis=0, keepdimss=True)
|
||||
img = np.mean(color_img[:3], axis=0, keepdims=True)
|
||||
|
||||
if temp_dir:
|
||||
Image.fromarray((img[0] * 255).astype(np.uint8)).save(
|
||||
temp_dir / "grayscale_img.png"
|
||||
)
|
||||
|
||||
log.debug(
|
||||
f"Converting color image to grayscale by taking the mean over color channels: {img.shape}"
|
||||
)
|
||||
|
||||
# Split image in tiles
|
||||
log.debug("DeepBump Color → Normals : tilling")
|
||||
@@ -28,32 +43,56 @@ def color_to_normals(color_img, overlap, progress_callback):
|
||||
"LARGE": tile_size // 2,
|
||||
}
|
||||
stride_size = tile_size - overlaps[overlap]
|
||||
tiles, paddings = utils_inference.tiles_split(
|
||||
tiles, paddings = tiles_split(
|
||||
img, (tile_size, tile_size), (stride_size, stride_size)
|
||||
)
|
||||
if temp_dir:
|
||||
for i, tile in enumerate(tiles):
|
||||
Image.fromarray((tile[0] * 255).astype(np.uint8)).save(
|
||||
temp_dir / f"tile_{i}.png"
|
||||
)
|
||||
|
||||
# Load model
|
||||
log.debug("DeepBump Color → Normals : loading model")
|
||||
addon_path = str(pathlib.Path(__file__).parent.absolute())
|
||||
ort_session = ort.InferenceSession(f"{addon_path}/models/deepbump256.onnx")
|
||||
model = get_model_path("deepbump", "deepbump256.onnx")
|
||||
if not model or not model.exists():
|
||||
raise ModelNotFound(f"deepbump ({model})")
|
||||
|
||||
ort_session = ort.InferenceSession(model)
|
||||
|
||||
# Predict normal map for each tile
|
||||
log.debug("DeepBump Color → Normals : generating")
|
||||
pred_tiles = utils_inference.tiles_infer(
|
||||
tiles, ort_session, progress_callback=progress_callback
|
||||
)
|
||||
pred_tiles = tiles_infer(tiles, ort_session, progress_callback=progress_callback)
|
||||
|
||||
if temp_dir:
|
||||
for i, pred_tile in enumerate(pred_tiles):
|
||||
Image.fromarray((pred_tile.transpose(1, 2, 0) * 255).astype(np.uint8)).save(
|
||||
temp_dir / f"pred_tile_{i}.png"
|
||||
)
|
||||
|
||||
# Merge tiles
|
||||
log.debug("DeepBump Color → Normals : merging")
|
||||
pred_img = utils_inference.tiles_merge(
|
||||
pred_img = tiles_merge(
|
||||
pred_tiles,
|
||||
(stride_size, stride_size),
|
||||
(3, img.shape[1], img.shape[2]),
|
||||
paddings,
|
||||
)
|
||||
|
||||
if temp_dir:
|
||||
Image.fromarray((pred_img.transpose(1, 2, 0) * 255).astype(np.uint8)).save(
|
||||
temp_dir / "merged_img.png"
|
||||
)
|
||||
|
||||
# Normalize each pixel to unit vector
|
||||
pred_img = utils_inference.normalize(pred_img)
|
||||
pred_img = normalize(pred_img)
|
||||
|
||||
if temp_dir:
|
||||
Image.fromarray((pred_img.transpose(1, 2, 0) * 255).astype(np.uint8)).save(
|
||||
temp_dir / "final_img.png"
|
||||
)
|
||||
|
||||
log.debug(f"Debug images saved in {temp_dir}")
|
||||
|
||||
return pred_img
|
||||
|
||||
@@ -261,7 +300,7 @@ class DeepBump:
|
||||
"LARGEST",
|
||||
],
|
||||
),
|
||||
"normals_to_height_seamless": ("BOOL", {"default": False}),
|
||||
"normals_to_height_seamless": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -278,25 +317,38 @@ class DeepBump:
|
||||
normals_to_curvature_blur_radius="SMALL",
|
||||
normals_to_height_seamless=True,
|
||||
):
|
||||
image = utils_inference.tensor2pil(image)
|
||||
images = tensor2pil(image)
|
||||
out_images = []
|
||||
|
||||
in_img = np.transpose(image, (2, 0, 1)) / 255
|
||||
for image in images:
|
||||
log.debug(f"Input image shape: {image}")
|
||||
|
||||
log.debug(f"Input image shape: {in_img.shape}")
|
||||
in_img = np.transpose(image, (2, 0, 1)) / 255
|
||||
log.debug(f"transposed for deep image shape: {in_img.shape}")
|
||||
out_img = None
|
||||
|
||||
# Apply processing
|
||||
if mode == "Color to Normals":
|
||||
out_img = color_to_normals(in_img, color_to_normals_overlap, None)
|
||||
if mode == "Normals to Curvature":
|
||||
out_img = normals_to_curvature(
|
||||
in_img, normals_to_curvature_blur_radius, None
|
||||
)
|
||||
if mode == "Normals to Height":
|
||||
out_img = normals_to_height(in_img, normals_to_height_seamless, None)
|
||||
# Apply processing
|
||||
if mode == "Color to Normals":
|
||||
out_img = color_to_normals(in_img, color_to_normals_overlap, None)
|
||||
if mode == "Normals to Curvature":
|
||||
out_img = normals_to_curvature(
|
||||
in_img, normals_to_curvature_blur_radius, None
|
||||
)
|
||||
if mode == "Normals to Height":
|
||||
out_img = normals_to_height(in_img, normals_to_height_seamless, None)
|
||||
|
||||
out_img = (np.transpose(out_img, (1, 2, 0)) * 255).astype(np.uint8)
|
||||
|
||||
return (utils_inference.pil2tensor(out_img),)
|
||||
if out_img is not None:
|
||||
log.debug(f"Output image shape: {out_img.shape}")
|
||||
out_images.append(
|
||||
torch.from_numpy(
|
||||
np.transpose(out_img, (1, 2, 0)).astype(np.float32)
|
||||
).unsqueeze(0)
|
||||
)
|
||||
else:
|
||||
log.error("No out img... This should not happen")
|
||||
for outi in out_images:
|
||||
log.debug(f"Shape fed to utils: {outi.shape}")
|
||||
return (torch.cat(out_images, dim=0),)
|
||||
|
||||
|
||||
__nodes__ = [DeepBump]
|
||||
|
||||
+49
-25
@@ -1,18 +1,19 @@
|
||||
from gfpgan import GFPGANer
|
||||
import cv2
|
||||
import numpy as np
|
||||
import os
|
||||
from pathlib import Path
|
||||
import folder_paths
|
||||
from basicsr.utils import imwrite
|
||||
from PIL import Image
|
||||
from ..utils import pil2tensor, tensor2pil, np2tensor, tensor2np
|
||||
import torch
|
||||
from ..log import NullWriter, log
|
||||
from comfy import model_management
|
||||
from typing import Tuple
|
||||
|
||||
import comfy
|
||||
import comfy.utils
|
||||
from typing import Tuple
|
||||
import cv2
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import torch
|
||||
from comfy import model_management
|
||||
from gfpgan import GFPGANer
|
||||
from PIL import Image
|
||||
|
||||
from ..log import NullWriter, log
|
||||
from ..utils import get_model_path, np2tensor, pil2tensor, tensor2np
|
||||
|
||||
|
||||
class LoadFaceEnhanceModel:
|
||||
@@ -23,19 +24,40 @@ class LoadFaceEnhanceModel:
|
||||
|
||||
@classmethod
|
||||
def get_models_root(cls):
|
||||
return Path(folder_paths.models_dir) / "upscale_models"
|
||||
fr = get_model_path("face_restore")
|
||||
# fr = Path(folder_paths.models_dir) / "face_restore"
|
||||
if fr.exists():
|
||||
return (fr, None)
|
||||
|
||||
um = get_model_path("upscale_models")
|
||||
return (fr, um) if um.exists() else (None, None)
|
||||
|
||||
@classmethod
|
||||
def get_models(cls):
|
||||
models_path = cls.get_models_root()
|
||||
|
||||
if not models_path.exists():
|
||||
log.warning(f"No models found at {models_path}")
|
||||
fr_models_path, um_models_path = cls.get_models_root()
|
||||
|
||||
if fr_models_path is None and um_models_path is None:
|
||||
log.warning("Face restoration models not found.")
|
||||
return []
|
||||
if not fr_models_path.exists():
|
||||
log.warning(
|
||||
f"No Face Restore checkpoints found at {fr_models_path} (if you've used mtb before these checkpoints were saved in upscale_models before)"
|
||||
)
|
||||
log.warning(
|
||||
"For now we fallback to upscale_models but this will be removed in a future version"
|
||||
)
|
||||
if um_models_path.exists():
|
||||
return [
|
||||
x
|
||||
for x in um_models_path.iterdir()
|
||||
if x.name.endswith(".pth")
|
||||
and ("GFPGAN" in x.name or "RestoreFormer" in x.name)
|
||||
]
|
||||
return []
|
||||
|
||||
return [
|
||||
x
|
||||
for x in models_path.iterdir()
|
||||
for x in fr_models_path.iterdir()
|
||||
if x.name.endswith(".pth")
|
||||
and ("GFPGAN" in x.name or "RestoreFormer" in x.name)
|
||||
]
|
||||
@@ -61,7 +83,7 @@ class LoadFaceEnhanceModel:
|
||||
def load_model(self, model_name, upscale=2, bg_upsampler=None):
|
||||
basic = "RestoreFormer" not in model_name
|
||||
|
||||
root = self.get_models_root()
|
||||
fr_root, um_root = self.get_models_root()
|
||||
|
||||
if bg_upsampler is not None:
|
||||
log.warning(
|
||||
@@ -72,7 +94,9 @@ class LoadFaceEnhanceModel:
|
||||
|
||||
sys.stdout = NullWriter()
|
||||
model = GFPGANer(
|
||||
model_path=(root / model_name).as_posix(),
|
||||
model_path=(
|
||||
(fr_root if fr_root.exists() else um_root) / model_name
|
||||
).as_posix(),
|
||||
upscale=upscale,
|
||||
arch="clean" if basic else "RestoreFormer", # or original for v1.0 only
|
||||
channel_multiplier=2, # 1 for v1.0 only
|
||||
@@ -140,12 +164,12 @@ class RestoreFace:
|
||||
"image": ("IMAGE",),
|
||||
"model": ("FACEENHANCE_MODEL",),
|
||||
# Input are aligned faces
|
||||
"aligned": ("BOOL", {"default": False}),
|
||||
"aligned": ("BOOLEAN", {"default": False}),
|
||||
# Only restore the center face
|
||||
"only_center_face": ("BOOL", {"default": False}),
|
||||
"only_center_face": ("BOOLEAN", {"default": False}),
|
||||
# Adjustable weights
|
||||
"weight": ("FLOAT", {"default": 0.5}),
|
||||
"save_tmp_steps": ("BOOL", {"default": True}),
|
||||
"save_tmp_steps": ("BOOLEAN", {"default": True}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -222,16 +246,16 @@ class RestoreFace:
|
||||
):
|
||||
face_id = idx + 1
|
||||
file = self.get_step_image_path("cropped_faces", face_id)
|
||||
imwrite(cropped_face, file)
|
||||
cv2.imwrite(file, cropped_face)
|
||||
|
||||
file = self.get_step_image_path("cropped_faces_restored", face_id)
|
||||
imwrite(restored_face, file)
|
||||
cv2.imwrite(file, restored_face)
|
||||
|
||||
file = self.get_step_image_path("cropped_faces_compare", face_id)
|
||||
|
||||
# save comparison image
|
||||
cmp_img = np.concatenate((cropped_face, restored_face), axis=1)
|
||||
imwrite(cmp_img, file)
|
||||
cv2.imwrite(file, cmp_img)
|
||||
|
||||
|
||||
__nodes__ = [RestoreFace, LoadFaceEnhanceModel]
|
||||
|
||||
+34
-33
@@ -1,37 +1,31 @@
|
||||
# Optional face enhance nodes
|
||||
# region imports
|
||||
import onnxruntime
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
from typing import List, Set, Tuple, Union, Optional
|
||||
from typing import List, Optional, Set, Union
|
||||
|
||||
import comfy.model_management as model_management
|
||||
import cv2
|
||||
import folder_paths
|
||||
import glob
|
||||
import insightface
|
||||
import numpy as np
|
||||
import os
|
||||
import tempfile
|
||||
import onnxruntime
|
||||
import torch
|
||||
from insightface.model_zoo.inswapper import INSwapper
|
||||
from ..utils import pil2tensor, tensor2pil
|
||||
from ..log import mklog, NullWriter
|
||||
import sys
|
||||
import comfy.model_management as model_management
|
||||
from PIL import Image
|
||||
|
||||
from ..errors import ModelNotFound
|
||||
from ..log import NullWriter, mklog
|
||||
from ..utils import download_antelopev2, get_model_path, pil2tensor, tensor2pil
|
||||
|
||||
# endregion
|
||||
|
||||
log = mklog(__name__)
|
||||
|
||||
|
||||
class LoadFaceAnalysisModel:
|
||||
"""Loads a face analysis model"""
|
||||
|
||||
models = []
|
||||
@staticmethod
|
||||
def get_models() -> List[str]:
|
||||
models_path = os.path.join(folder_paths.models_dir, "insightface/*")
|
||||
models = glob.glob(models_path)
|
||||
models = [Path(x).name for x in models if x.endswith(".onnx") or x.endswith(".pth")]
|
||||
return models
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -49,20 +43,23 @@ class LoadFaceAnalysisModel:
|
||||
CATEGORY = "mtb/facetools"
|
||||
|
||||
def load_model(self, faceswap_model: str):
|
||||
if faceswap_model == "antelopev2":
|
||||
download_antelopev2()
|
||||
|
||||
face_analyser = insightface.app.FaceAnalysis(
|
||||
name=faceswap_model, root=os.path.join(folder_paths.models_dir, "insightface")
|
||||
name=faceswap_model,
|
||||
root=get_model_path("insightface"),
|
||||
)
|
||||
return (face_analyser,)
|
||||
|
||||
|
||||
class LoadFaceSwapModel:
|
||||
"""Loads a faceswap model"""
|
||||
|
||||
@staticmethod
|
||||
def get_models() -> List[Path]:
|
||||
models_path = os.path.join(folder_paths.models_dir, "insightface/*")
|
||||
models = glob.glob(models_path)
|
||||
models = [Path(x) for x in models if x.endswith(".onnx") or x.endswith(".pth")]
|
||||
return models
|
||||
models_path = get_model_path("insightface").iterdir()
|
||||
return [x for x in models_path if x.suffix in [".onnx", ".pth"]]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -80,9 +77,10 @@ class LoadFaceSwapModel:
|
||||
CATEGORY = "mtb/facetools"
|
||||
|
||||
def load_model(self, faceswap_model: str):
|
||||
model_path = os.path.join(
|
||||
folder_paths.models_dir, "insightface", faceswap_model
|
||||
)
|
||||
model_path = get_model_path("insightface", faceswap_model)
|
||||
if not model_path or not model_path.exists():
|
||||
raise ModelNotFound(f"{faceswap_model} ({model_path})")
|
||||
|
||||
log.info(f"Loading model {model_path}")
|
||||
return (
|
||||
INSwapper(
|
||||
@@ -114,7 +112,6 @@ class FaceSwap:
|
||||
"faces_index": ("STRING", {"default": "0"}),
|
||||
"faceanalysis_model": ("FACE_ANALYSIS_MODEL", {"default": "None"}),
|
||||
"faceswap_model": ("FACESWAP_MODEL", {"default": "None"}),
|
||||
"debug": ("BOOL", {"default": False}),
|
||||
},
|
||||
"optional": {},
|
||||
}
|
||||
@@ -130,7 +127,6 @@ class FaceSwap:
|
||||
faces_index: str,
|
||||
faceanalysis_model,
|
||||
faceswap_model,
|
||||
debug=False,
|
||||
):
|
||||
def do_swap(img):
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
@@ -140,7 +136,7 @@ class FaceSwap:
|
||||
int(x) for x in faces_index.strip(",").split(",") if x.isnumeric()
|
||||
}
|
||||
sys.stdout = NullWriter()
|
||||
swapped = swap_face(faceanalysis_model,ref, img, faceswap_model, face_ids)
|
||||
swapped = swap_face(faceanalysis_model, ref, img, faceswap_model, face_ids)
|
||||
sys.stdout = sys.__stdout__
|
||||
return pil2tensor(swapped)
|
||||
|
||||
@@ -164,15 +160,18 @@ class FaceSwap:
|
||||
|
||||
|
||||
# region face swap utils
|
||||
def get_face_single(face_analyser,img_data: np.ndarray, face_index=0, det_size=(640, 640)):
|
||||
|
||||
def get_face_single(
|
||||
face_analyser, img_data: np.ndarray, face_index=0, det_size=(640, 640)
|
||||
):
|
||||
face_analyser.prepare(ctx_id=0, det_size=det_size)
|
||||
face = face_analyser.get(img_data)
|
||||
|
||||
if len(face) == 0 and det_size[0] > 320 and det_size[1] > 320:
|
||||
log.debug("No face ed, trying again with smaller image")
|
||||
det_size_half = (det_size[0] // 2, det_size[1] // 2)
|
||||
return get_face_single(face_analyser,img_data, face_index=face_index, det_size=det_size_half)
|
||||
return get_face_single(
|
||||
face_analyser, img_data, face_index=face_index, det_size=det_size_half
|
||||
)
|
||||
|
||||
try:
|
||||
return sorted(face, key=lambda x: x.bbox[0])[face_index]
|
||||
@@ -195,12 +194,14 @@ def swap_face(
|
||||
if face_swapper_model is not None:
|
||||
cv_source_img = cv2.cvtColor(np.array(source_img), cv2.COLOR_RGB2BGR)
|
||||
cv_target_img = cv2.cvtColor(np.array(target_img), cv2.COLOR_RGB2BGR)
|
||||
source_face = get_face_single(face_analyser,cv_source_img, face_index=0)
|
||||
source_face = get_face_single(face_analyser, cv_source_img, face_index=0)
|
||||
if source_face is not None:
|
||||
result = cv_target_img
|
||||
|
||||
for face_num in faces_index:
|
||||
target_face = get_face_single(face_analyser,cv_target_img, face_index=face_num)
|
||||
target_face = get_face_single(
|
||||
face_analyser, cv_target_img, face_index=face_num
|
||||
)
|
||||
if target_face is not None:
|
||||
sys.stdout = NullWriter()
|
||||
result = face_swapper_model.get(result, target_face, source_face)
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import threading
|
||||
from typing import cast
|
||||
|
||||
import qrcode
|
||||
from ..utils import pil2tensor
|
||||
from PIL import Image
|
||||
|
||||
from ..log import log
|
||||
from ..utils import comfy_dir, pil2tensor
|
||||
|
||||
# class MtbExamples:
|
||||
# """MTB Example Images"""
|
||||
@@ -72,9 +76,10 @@ class UnsplashImage:
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
def do_unsplash_image(self, width, height, random_seed, keyword=None):
|
||||
import requests
|
||||
import io
|
||||
|
||||
import requests
|
||||
|
||||
base_url = "https://source.unsplash.com/random/"
|
||||
|
||||
if width and height:
|
||||
@@ -121,7 +126,7 @@ class QrCode:
|
||||
"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": (("BOOL",), {"default": False}),
|
||||
"invert": (("BOOLEAN",), {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +135,9 @@ class QrCode:
|
||||
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":
|
||||
@@ -159,8 +167,158 @@ class QrCode:
|
||||
return (pil2tensor(code),)
|
||||
|
||||
|
||||
def bbox_dim(bbox):
|
||||
left, upper, right, lower = bbox
|
||||
width = right - left
|
||||
height = lower - upper
|
||||
return width, height
|
||||
|
||||
|
||||
class TextToImage:
|
||||
"""Utils to convert text to image using a font
|
||||
|
||||
|
||||
The tool looks for any .ttf file in the Comfy folder hierarchy.
|
||||
"""
|
||||
|
||||
fonts = {}
|
||||
|
||||
def __init__(self):
|
||||
# - This is executed when the graph is executed, we could conditionaly reload fonts there
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def CACHE_FONTS(cls):
|
||||
font_extensions = ["*.ttf", "*.otf", "*.woff", "*.woff2", "*.eot"]
|
||||
fonts = []
|
||||
|
||||
for extension in font_extensions:
|
||||
fonts.extend(comfy_dir.glob(f"**/{extension}"))
|
||||
|
||||
if not fonts:
|
||||
log.warn(
|
||||
"> No fonts found in the comfy folder, place at least one font file somewhere in ComfyUI's hierarchy"
|
||||
)
|
||||
else:
|
||||
log.debug(f"> Found {len(fonts)} fonts")
|
||||
|
||||
for font in fonts:
|
||||
log.debug(f"Adding font {font}")
|
||||
TextToImage.fonts[font.stem] = font.as_posix()
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
if not cls.fonts:
|
||||
thread = threading.Thread(target=cls.CACHE_FONTS)
|
||||
thread.start()
|
||||
else:
|
||||
log.debug(f"Using cached fonts (count: {len(cls.fonts)})")
|
||||
return {
|
||||
"required": {
|
||||
"text": (
|
||||
"STRING",
|
||||
{"default": "Hello world!"},
|
||||
),
|
||||
"font": ((sorted(cls.fonts.keys())),),
|
||||
"wrap": (
|
||||
"INT",
|
||||
{"default": 120, "min": 0, "max": 8096, "step": 1},
|
||||
),
|
||||
"font_size": (
|
||||
"INT",
|
||||
{"default": 12, "min": 1, "max": 2500, "step": 1},
|
||||
),
|
||||
"width": (
|
||||
"INT",
|
||||
{"default": 512, "min": 1, "max": 8096, "step": 1},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{"default": 512, "min": 1, "max": 8096, "step": 1},
|
||||
),
|
||||
"color": (
|
||||
"COLOR",
|
||||
{"default": "black"},
|
||||
),
|
||||
"background": (
|
||||
"COLOR",
|
||||
{"default": "white"},
|
||||
),
|
||||
"h_align": (("left", "center", "right"), {"default": "left"}),
|
||||
"v_align": (("top", "center", "bottom"), {"default": "top"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "text_to_image"
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
def text_to_image(
|
||||
self,
|
||||
text,
|
||||
font,
|
||||
wrap,
|
||||
font_size,
|
||||
width,
|
||||
height,
|
||||
color,
|
||||
background,
|
||||
h_align="left",
|
||||
v_align="top",
|
||||
):
|
||||
import textwrap
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
font_path = self.fonts[font]
|
||||
|
||||
# Handle word wrapping
|
||||
if wrap:
|
||||
lines = textwrap.wrap(text, width=wrap)
|
||||
else:
|
||||
lines = [text]
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
# font = ImageFont.truetype(font_path, font_size)
|
||||
# if wrap == 0:
|
||||
# wrap = width / font_size
|
||||
|
||||
log.debug(f"Lines: {lines}")
|
||||
img = Image.new("RGBA", (width, height), background)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
text_height = sum(font.getsize(line)[1] for line in lines)
|
||||
|
||||
# Vertical alignment
|
||||
if v_align == "top":
|
||||
y_text = 0
|
||||
elif v_align == "center":
|
||||
y_text = (height - text_height) // 2
|
||||
else: # bottom
|
||||
y_text = height - text_height
|
||||
|
||||
# Draw each line of text
|
||||
for line in lines:
|
||||
line_width, line_height = font.getsize(line)
|
||||
|
||||
# Horizontal alignment
|
||||
if h_align == "left":
|
||||
x_text = 0
|
||||
elif h_align == "center":
|
||||
x_text = (width - line_width) // 2
|
||||
else: # right
|
||||
x_text = width - line_width
|
||||
|
||||
draw.text((x_text, y_text), line, color, font=font)
|
||||
y_text += line_height
|
||||
|
||||
# img.save(os.path.join(folder_paths.base_path, f'{str(uuid.uuid4())}.png'))
|
||||
return (pil2tensor(img),)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
QrCode,
|
||||
UnsplashImage
|
||||
UnsplashImage,
|
||||
TextToImage
|
||||
# MtbExamples,
|
||||
]
|
||||
+262
-14
@@ -1,4 +1,141 @@
|
||||
import io, json, urllib.parse, urllib.request
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..log import log
|
||||
from ..utils import apply_easing, get_server_info, pil2tensor
|
||||
|
||||
|
||||
def get_image(filename, subfolder, folder_type):
|
||||
log.debug(
|
||||
f"Getting image {filename} from foldertype {folder_type} {f'in subfolder: {subfolder}' if subfolder else ''}"
|
||||
)
|
||||
data = {"filename": filename, "subfolder": subfolder, "type": folder_type}
|
||||
base_url, port = get_server_info()
|
||||
|
||||
url_values = urllib.parse.urlencode(data)
|
||||
url = f"http://{base_url}:{port}/view?{url_values}"
|
||||
log.debug(f"Fetching image from {url}")
|
||||
with urllib.request.urlopen(url) as response:
|
||||
return io.BytesIO(response.read())
|
||||
|
||||
|
||||
class GetBatchFromHistory:
|
||||
"""Very experimental node to load images from the history of the server.
|
||||
|
||||
Queue items without output are ignored in the count."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enable": ("BOOLEAN", {"default": True}),
|
||||
"count": ("INT", {"default": 1, "min": 0}),
|
||||
"offset": ("INT", {"default": 0, "min": -1e9, "max": 1e9}),
|
||||
"internal_count": ("INT", {"default": 0}),
|
||||
},
|
||||
"optional": {
|
||||
"passthrough_image": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("images",)
|
||||
CATEGORY = "mtb/animation"
|
||||
FUNCTION = "load_from_history"
|
||||
|
||||
def load_from_history(
|
||||
self,
|
||||
enable=True,
|
||||
count=0,
|
||||
offset=0,
|
||||
internal_count=0, # hacky way to invalidate the node
|
||||
passthrough_image=None,
|
||||
):
|
||||
if not enable or count == 0:
|
||||
if passthrough_image is not None:
|
||||
log.debug("Using passthrough image")
|
||||
return (passthrough_image,)
|
||||
log.debug("Load from history is disabled for this iteration")
|
||||
return (torch.zeros(0),)
|
||||
frames = []
|
||||
|
||||
base_url, port = get_server_info()
|
||||
|
||||
history_url = f"http://{base_url}:{port}/history"
|
||||
log.debug(f"Fetching history from {history_url}")
|
||||
output = torch.zeros(0)
|
||||
with urllib.request.urlopen(history_url) as response:
|
||||
output = self.load_batch_frames(response, offset, count, frames)
|
||||
|
||||
if output.size(0) == 0:
|
||||
log.warn("No output found in history")
|
||||
|
||||
return (output,)
|
||||
|
||||
def load_batch_frames(self, response, offset, count, frames):
|
||||
history = json.loads(response.read())
|
||||
|
||||
output_images = []
|
||||
|
||||
for run in history.values():
|
||||
for node_output in run["outputs"].values():
|
||||
if "images" in node_output:
|
||||
for image in node_output["images"]:
|
||||
image_data = get_image(
|
||||
image["filename"], image["subfolder"], image["type"]
|
||||
)
|
||||
output_images.append(image_data)
|
||||
|
||||
if not output_images:
|
||||
return torch.zeros(0)
|
||||
|
||||
# Directly get desired range of images
|
||||
start_index = max(len(output_images) - offset - count, 0)
|
||||
end_index = len(output_images) - offset
|
||||
selected_images = output_images[start_index:end_index]
|
||||
|
||||
frames = [Image.open(image) for image in selected_images]
|
||||
|
||||
if not frames:
|
||||
return torch.zeros(0)
|
||||
elif len(frames) != count:
|
||||
log.warning(f"Expected {count} images, got {len(frames)} instead")
|
||||
|
||||
return pil2tensor(frames)
|
||||
|
||||
|
||||
class AnyToString:
|
||||
"""Tries to take any input and convert it to a string"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"input": ("*")},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "do_str"
|
||||
CATEGORY = "mtb/converters"
|
||||
|
||||
def do_str(self, input):
|
||||
if isinstance(input, str):
|
||||
return (input,)
|
||||
elif isinstance(input, torch.Tensor):
|
||||
return (f"Tensor of shape {input.shape} and dtype {input.dtype}",)
|
||||
elif isinstance(input, Image.Image):
|
||||
return (f"PIL Image of size {input.size} and mode {input.mode}",)
|
||||
elif isinstance(input, np.ndarray):
|
||||
return (f"Numpy array of shape {input.shape} and dtype {input.dtype}",)
|
||||
|
||||
elif isinstance(input, dict):
|
||||
return (f"Dictionary of {len(input)} items, with keys {input.keys()}",)
|
||||
|
||||
else:
|
||||
log.debug(f"Falling back to string conversion of {input}")
|
||||
return (str(input),)
|
||||
|
||||
|
||||
class StringReplace:
|
||||
@@ -30,6 +167,52 @@ class StringReplace:
|
||||
return (string,)
|
||||
|
||||
|
||||
class MTB_MathExpression:
|
||||
"""Node to evaluate a simple math expression string"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"expression": ("STRING", {"default": "", "multiline": True}),
|
||||
}
|
||||
}
|
||||
|
||||
FUNCTION = "eval_expression"
|
||||
RETURN_TYPES = ("FLOAT", "INT")
|
||||
RETURN_NAMES = ("result (float)", "result (int)")
|
||||
CATEGORY = "mtb/math"
|
||||
DESCRIPTION = "evaluate a simple math expression string (!! Fallsback to eval)"
|
||||
|
||||
def eval_expression(self, expression, **kwargs):
|
||||
import math
|
||||
from ast import literal_eval
|
||||
|
||||
for key, value in kwargs.items():
|
||||
print(f"Replacing placeholder <{key}> with value {value}")
|
||||
expression = expression.replace(f"<{key}>", str(value))
|
||||
|
||||
result = -1
|
||||
try:
|
||||
result = literal_eval(expression)
|
||||
except SyntaxError as e:
|
||||
raise ValueError(
|
||||
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
|
||||
|
||||
return (result, int(result))
|
||||
|
||||
|
||||
class FitNumber:
|
||||
"""Fit the input float using a source and target range"""
|
||||
|
||||
@@ -38,17 +221,45 @@ class FitNumber:
|
||||
return {
|
||||
"required": {
|
||||
"value": ("FLOAT", {"default": 0, "forceInput": True}),
|
||||
"clamp": ("BOOL", {"default": False}),
|
||||
"source_min": ("FLOAT", {"default": 0.0}),
|
||||
"source_max": ("FLOAT", {"default": 1.0}),
|
||||
"target_min": ("FLOAT", {"default": 0.0}),
|
||||
"target_max": ("FLOAT", {"default": 1.0}),
|
||||
"clamp": ("BOOLEAN", {"default": False}),
|
||||
"source_min": ("FLOAT", {"default": 0.0, "step": 0.01}),
|
||||
"source_max": ("FLOAT", {"default": 1.0, "step": 0.01}),
|
||||
"target_min": ("FLOAT", {"default": 0.0, "step": 0.01}),
|
||||
"target_max": ("FLOAT", {"default": 1.0, "step": 0.01}),
|
||||
"easing": (
|
||||
[
|
||||
"Linear",
|
||||
"Sine In",
|
||||
"Sine Out",
|
||||
"Sine In/Out",
|
||||
"Quart In",
|
||||
"Quart Out",
|
||||
"Quart In/Out",
|
||||
"Cubic In",
|
||||
"Cubic Out",
|
||||
"Cubic In/Out",
|
||||
"Circ In",
|
||||
"Circ Out",
|
||||
"Circ In/Out",
|
||||
"Back In",
|
||||
"Back Out",
|
||||
"Back In/Out",
|
||||
"Elastic In",
|
||||
"Elastic Out",
|
||||
"Elastic In/Out",
|
||||
"Bounce In",
|
||||
"Bounce Out",
|
||||
"Bounce In/Out",
|
||||
],
|
||||
{"default": "Linear"},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
FUNCTION = "set_range"
|
||||
RETURN_TYPES = ("FLOAT",)
|
||||
CATEGORY = "mtb/math"
|
||||
DESCRIPTION = "Fit the input float using a source and target range"
|
||||
|
||||
def set_range(
|
||||
self,
|
||||
@@ -58,18 +269,55 @@ class FitNumber:
|
||||
source_max: float,
|
||||
target_min: float,
|
||||
target_max: float,
|
||||
easing: str,
|
||||
):
|
||||
res = target_min + (target_max - target_min) * (value - source_min) / (
|
||||
source_max - source_min
|
||||
)
|
||||
|
||||
if source_min == source_max:
|
||||
normalized_value = 0
|
||||
else:
|
||||
normalized_value = (value - source_min) / (source_max - source_min)
|
||||
if clamp:
|
||||
if target_min > target_max:
|
||||
res = max(min(res, target_min), target_max)
|
||||
else:
|
||||
res = max(min(res, target_max), target_min)
|
||||
normalized_value = max(min(normalized_value, 1), 0)
|
||||
|
||||
eased_value = apply_easing(normalized_value, easing)
|
||||
|
||||
# - Convert the eased value to the target range
|
||||
res = target_min + (target_max - target_min) * eased_value
|
||||
|
||||
return (res,)
|
||||
|
||||
|
||||
__nodes__ = [StringReplace, FitNumber]
|
||||
class ConcatImages:
|
||||
"""Add images to batch"""
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "concatenate_tensors"
|
||||
CATEGORY = "mtb/image"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"reverse": ("BOOLEAN", {"default": False})},
|
||||
}
|
||||
|
||||
def concatenate_tensors(self, reverse, **kwargs):
|
||||
tensors = tuple(kwargs.values())
|
||||
batch_sizes = [tensor.size(0) for tensor in tensors]
|
||||
|
||||
concatenated = torch.cat(tensors, dim=0)
|
||||
|
||||
# Update the batch size in the concatenated tensor
|
||||
concatenated_size = list(concatenated.size())
|
||||
concatenated_size[0] = sum(batch_sizes)
|
||||
concatenated = concatenated.view(*concatenated_size)
|
||||
|
||||
return (concatenated,)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
StringReplace,
|
||||
FitNumber,
|
||||
GetBatchFromHistory,
|
||||
AnyToString,
|
||||
ConcatImages,
|
||||
MTB_MathExpression,
|
||||
]
|
||||
|
||||
+21
-151
@@ -1,110 +1,20 @@
|
||||
from typing import List
|
||||
from pathlib import Path
|
||||
import os
|
||||
import glob
|
||||
import folder_paths
|
||||
from ..log import log
|
||||
import torch
|
||||
from frame_interpolation.eval import util, interpolator
|
||||
from ..utils import tensor2np
|
||||
import numpy as np
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import comfy
|
||||
from PIL import Image
|
||||
import urllib.request
|
||||
import urllib.parse
|
||||
import json
|
||||
import tensorflow as tf
|
||||
import comfy.model_management as model_management
|
||||
import io
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import tensorflow as tf
|
||||
import torch
|
||||
from frame_interpolation.eval import interpolator, util
|
||||
|
||||
from comfy.cli_args import args
|
||||
from ..utils import pil2tensor
|
||||
|
||||
|
||||
def get_image(filename, subfolder, folder_type):
|
||||
data = {"filename": filename, "subfolder": subfolder, "type": folder_type}
|
||||
url_values = urllib.parse.urlencode(data)
|
||||
with urllib.request.urlopen(
|
||||
"http://{}:{}/view?{}".format(args.listen, args.port, url_values)
|
||||
) as response:
|
||||
return io.BytesIO(response.read())
|
||||
|
||||
|
||||
class GetBatchFromHistory:
|
||||
"""Very experimental node to load images from the history of the server.
|
||||
|
||||
Queue items without output are ignore in the count."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enable": ("BOOL", {"default": True}),
|
||||
"count": ("INT", {"default": 1, "min": 0}),
|
||||
"offset": ("INT", {"default": 0, "min": -1e9, "max": 1e9}),
|
||||
},
|
||||
"optional": {"passthrough_image": ("IMAGE",)},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = "images"
|
||||
CATEGORY = "mtb/animation"
|
||||
FUNCTION = "load_from_history"
|
||||
|
||||
def load_from_history(
|
||||
self,
|
||||
enable=True,
|
||||
count=0,
|
||||
offset=0,
|
||||
passthrough_image=None,
|
||||
):
|
||||
if not enable or count == 0:
|
||||
if passthrough_image is not None:
|
||||
return (passthrough_image,)
|
||||
log.debug("Load from history is disabled for this iteration")
|
||||
return (torch.zeros(0),)
|
||||
frames = []
|
||||
|
||||
with urllib.request.urlopen(
|
||||
"http://{}:{}/history".format(args.listen, args.port)
|
||||
) as response:
|
||||
history = json.loads(response.read())
|
||||
|
||||
output_images = []
|
||||
for k, run in history.items():
|
||||
for o in run["outputs"]:
|
||||
for node_id in run["outputs"]:
|
||||
node_output = run["outputs"][node_id]
|
||||
if "images" in node_output:
|
||||
images_output = []
|
||||
for image in node_output["images"]:
|
||||
image_data = get_image(
|
||||
image["filename"], image["subfolder"], image["type"]
|
||||
)
|
||||
images_output.append(image_data)
|
||||
output_images.extend(images_output)
|
||||
if len(output_images) == 0:
|
||||
return (torch.zeros(0),)
|
||||
for i, image in enumerate(list(reversed(output_images))):
|
||||
if i < offset:
|
||||
continue
|
||||
if i >= offset + count:
|
||||
break
|
||||
# Decode image as tensor
|
||||
img = Image.open(image)
|
||||
log.debug(f"Image from history {i} of shape {img.size}")
|
||||
frames.append(img)
|
||||
|
||||
# Display the shape of the tensor
|
||||
# print("Tensor shape:", image_tensor.shape)
|
||||
|
||||
# return (output_images,)
|
||||
|
||||
output = pil2tensor(
|
||||
list(reversed(frames)),
|
||||
)
|
||||
|
||||
return (output,)
|
||||
from ..errors import ModelNotFound
|
||||
from ..log import log
|
||||
from ..utils import get_model_path
|
||||
|
||||
|
||||
class LoadFilmModel:
|
||||
@@ -112,10 +22,9 @@ class LoadFilmModel:
|
||||
|
||||
@staticmethod
|
||||
def get_models() -> List[Path]:
|
||||
models_path = os.path.join(folder_paths.models_dir, "FILM/*")
|
||||
models = glob.glob(models_path)
|
||||
models = [Path(x) for x in models if x.endswith(".onnx") or x.endswith(".pth")]
|
||||
return models
|
||||
models_paths = get_model_path("FILM").iterdir()
|
||||
|
||||
return [x for x in models_paths if x.suffix in [".onnx", ".pth"]]
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -133,7 +42,10 @@ class LoadFilmModel:
|
||||
CATEGORY = "mtb/frame iterpolation"
|
||||
|
||||
def load_model(self, film_model: str):
|
||||
model_path = Path(folder_paths.models_dir) / "FILM" / film_model
|
||||
model_path = get_model_path("FILM", film_model)
|
||||
if not model_path or not model_path.exists():
|
||||
raise ModelNotFound(f"FILM ({model_path})")
|
||||
|
||||
if not (model_path / "saved_model.pb").exists():
|
||||
model_path = model_path / "saved_model"
|
||||
|
||||
@@ -208,46 +120,4 @@ class FilmInterpolation:
|
||||
return (out_tensors,)
|
||||
|
||||
|
||||
class ConcatImages:
|
||||
"""Add images to batch"""
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "concat_images"
|
||||
CATEGORY = "mtb/image"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"imageA": ("IMAGE",),
|
||||
"imageB": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def concatenate_tensors(cls, A: torch.Tensor, B: torch.Tensor):
|
||||
# Get the batch sizes of A and B
|
||||
batch_size_A = A.size(0)
|
||||
batch_size_B = B.size(0)
|
||||
|
||||
# Concatenate the tensors along the batch dimension
|
||||
concatenated = torch.cat((A, B), dim=0)
|
||||
|
||||
# Update the batch size in the concatenated tensor
|
||||
concatenated_size = list(concatenated.size())
|
||||
concatenated_size[0] = batch_size_A + batch_size_B
|
||||
concatenated = concatenated.view(*concatenated_size)
|
||||
|
||||
return concatenated
|
||||
|
||||
def concat_images(self, imageA: torch.Tensor, imageB: torch.Tensor):
|
||||
log.debug(f"Concatenating A ({imageA.shape}) and B ({imageB.shape})")
|
||||
return (self.concatenate_tensors(imageA, imageB),)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
LoadFilmModel,
|
||||
FilmInterpolation,
|
||||
ConcatImages,
|
||||
GetBatchFromHistory,
|
||||
]
|
||||
__nodes__ = [LoadFilmModel, FilmInterpolation]
|
||||
|
||||
+276
-135
@@ -1,21 +1,20 @@
|
||||
import torch
|
||||
from skimage.filters import gaussian
|
||||
from skimage.restoration import denoise_tv_chambolle
|
||||
from skimage.util import compare_images
|
||||
from skimage.color import rgb2hsv, hsv2rgb
|
||||
import numpy as np
|
||||
import torchvision.transforms.functional as F
|
||||
from PIL import Image, ImageChops
|
||||
from ..utils import tensor2pil, pil2tensor, np2tensor, tensor2np
|
||||
import cv2
|
||||
import torch
|
||||
from ..log import log
|
||||
import folder_paths
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
import itertools
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import comfy.model_management as model_management
|
||||
|
||||
import cv2
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
from skimage.filters import gaussian
|
||||
from skimage.util import compare_images
|
||||
|
||||
from ..log import log
|
||||
from ..utils import pil2tensor, tensor2np, tensor2pil
|
||||
|
||||
# try:
|
||||
# from cv2.ximgproc import guidedFilter
|
||||
@@ -23,6 +22,18 @@ import comfy.model_management as model_management
|
||||
# log.warning("cv2.ximgproc.guidedFilter not found, use opencv-contrib-python")
|
||||
|
||||
|
||||
def gaussian_kernel(kernel_size: int, sigma_x: float, sigma_y: float, device=None):
|
||||
x, y = torch.meshgrid(
|
||||
torch.linspace(-1, 1, kernel_size, device=device),
|
||||
torch.linspace(-1, 1, kernel_size, device=device),
|
||||
indexing="ij",
|
||||
)
|
||||
d_x = x * x / (2.0 * sigma_x * sigma_x)
|
||||
d_y = y * y / (2.0 * sigma_y * sigma_y)
|
||||
g = torch.exp(-(d_x + d_y))
|
||||
return g / g.sum()
|
||||
|
||||
|
||||
class ColorCorrect:
|
||||
"""Various color correction methods"""
|
||||
|
||||
@@ -181,7 +192,7 @@ class ColorCorrect:
|
||||
return (image,)
|
||||
|
||||
|
||||
class ImageCompare:
|
||||
class ImageCompare_:
|
||||
"""Compare two images and return a difference image"""
|
||||
|
||||
@classmethod
|
||||
@@ -217,7 +228,7 @@ class ImageCompare:
|
||||
import requests
|
||||
|
||||
|
||||
class LoadImageFromUrl:
|
||||
class LoadImageFromUrl_:
|
||||
"""Load an image from the given URL"""
|
||||
|
||||
@classmethod
|
||||
@@ -243,7 +254,7 @@ class LoadImageFromUrl:
|
||||
return (pil2tensor(image),)
|
||||
|
||||
|
||||
class Blur:
|
||||
class Blur_:
|
||||
"""Blur an image using a Gaussian filter."""
|
||||
|
||||
@classmethod
|
||||
@@ -274,6 +285,78 @@ class Blur:
|
||||
return (torch.from_numpy(image),)
|
||||
|
||||
|
||||
class Sharpen_:
|
||||
"""Sharpens an image using a Gaussian kernel."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"sharpen_radius": (
|
||||
"INT",
|
||||
{"default": 1, "min": 1, "max": 31, "step": 1},
|
||||
),
|
||||
"sigma_x": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1},
|
||||
),
|
||||
"sigma_y": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.1, "max": 10.0, "step": 0.1},
|
||||
),
|
||||
"alpha": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.0, "max": 5.0, "step": 0.1},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "do_sharp"
|
||||
CATEGORY = "mtb/image processing"
|
||||
|
||||
def do_sharp(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
sharpen_radius: int,
|
||||
sigma_x: float,
|
||||
sigma_y: float,
|
||||
alpha: float,
|
||||
):
|
||||
if sharpen_radius == 0:
|
||||
return (image,)
|
||||
|
||||
channels = image.shape[3]
|
||||
|
||||
kernel_size = 2 * sharpen_radius + 1
|
||||
kernel = gaussian_kernel(kernel_size, sigma_x, sigma_y) * -(alpha * 10)
|
||||
|
||||
# Modify center of kernel to make it a sharpening kernel
|
||||
center = kernel_size // 2
|
||||
kernel[center, center] = kernel[center, center] - kernel.sum() + 1.0
|
||||
|
||||
kernel = kernel.repeat(channels, 1, 1).unsqueeze(1)
|
||||
tensor_image = image.permute(0, 3, 1, 2)
|
||||
|
||||
tensor_image = F.pad(
|
||||
tensor_image,
|
||||
(sharpen_radius, sharpen_radius, sharpen_radius, sharpen_radius),
|
||||
"reflect",
|
||||
)
|
||||
sharpened = F.conv2d(tensor_image, kernel, padding=center, groups=channels)
|
||||
|
||||
# Remove padding
|
||||
sharpened = sharpened[
|
||||
:, :, sharpen_radius:-sharpen_radius, sharpen_radius:-sharpen_radius
|
||||
]
|
||||
|
||||
sharpened = sharpened.permute(0, 2, 3, 1)
|
||||
result = torch.clamp(sharpened, 0, 1)
|
||||
|
||||
return (result,)
|
||||
|
||||
|
||||
# https://github.com/lllyasviel/AdverseCleaner/blob/main/clean.py
|
||||
# def deglaze_np_img(np_img):
|
||||
# y = np_img.copy()
|
||||
@@ -320,22 +403,26 @@ class MaskToImage:
|
||||
FUNCTION = "render_mask"
|
||||
|
||||
def render_mask(self, mask, color, background):
|
||||
mask = tensor2np(mask)
|
||||
mask = Image.fromarray(mask).convert("L")
|
||||
masks = tensor2np(mask)
|
||||
images = []
|
||||
for m in masks:
|
||||
_mask = Image.fromarray(m).convert("L")
|
||||
|
||||
image = Image.new("RGBA", mask.size, color=color)
|
||||
# apply the mask
|
||||
image = Image.composite(
|
||||
image, Image.new("RGBA", mask.size, color=background), mask
|
||||
)
|
||||
log.debug(f"Converted mask to PIL Image format, size: {_mask.size}")
|
||||
|
||||
# image = ImageChops.multiply(image, mask)
|
||||
# apply over background
|
||||
# image = Image.alpha_composite(Image.new("RGBA", image.size, color=background), image)
|
||||
image = Image.new("RGBA", _mask.size, color=color)
|
||||
# apply the mask
|
||||
image = Image.composite(
|
||||
image, Image.new("RGBA", _mask.size, color=background), _mask
|
||||
)
|
||||
|
||||
image = pil2tensor(image.convert("RGB"))
|
||||
# image = ImageChops.multiply(image, mask)
|
||||
# apply over background
|
||||
# image = Image.alpha_composite(Image.new("RGBA", image.size, color=background), image)
|
||||
|
||||
return (image,)
|
||||
images.append(image.convert("RGB"))
|
||||
|
||||
return (pil2tensor(images),)
|
||||
|
||||
|
||||
class ColoredImage:
|
||||
@@ -351,7 +438,11 @@ class ColoredImage:
|
||||
"color": ("COLOR",),
|
||||
"width": ("INT", {"default": 512, "min": 16, "max": 8160}),
|
||||
"height": ("INT", {"default": 512, "min": 16, "max": 8160}),
|
||||
}
|
||||
},
|
||||
"optional": {
|
||||
"foreground_image": ("IMAGE",),
|
||||
"foreground_mask": ("MASK",),
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY = "mtb/generate"
|
||||
@@ -360,12 +451,46 @@ class ColoredImage:
|
||||
|
||||
FUNCTION = "render_img"
|
||||
|
||||
def render_img(self, color, width, height):
|
||||
image = Image.new("RGB", (width, height), color=color)
|
||||
def render_img(
|
||||
self, color, width, height, foreground_image=None, foreground_mask=None
|
||||
):
|
||||
image = Image.new("RGBA", (width, height), color=color)
|
||||
output = []
|
||||
if foreground_image is not None:
|
||||
if foreground_mask is None:
|
||||
fg_images = tensor2pil(foreground_image)
|
||||
for img in fg_images:
|
||||
if image.size != img.size:
|
||||
raise ValueError(
|
||||
f"Dimension mismatch: image {image.size}, img {img.size}"
|
||||
)
|
||||
|
||||
image = pil2tensor(image)
|
||||
if img.mode != "RGBA":
|
||||
raise ValueError(
|
||||
f"Foreground image must be in 'RGBA' mode when no mask is provided, got {img.mode}"
|
||||
)
|
||||
|
||||
return (image,)
|
||||
output.append(Image.alpha_composite(image, img).convert("RGB"))
|
||||
|
||||
elif foreground_image.size[0] != foreground_mask.size[0]:
|
||||
raise ValueError("Foreground image and mask must have same batch size")
|
||||
else:
|
||||
fg_images = tensor2pil(foreground_image)
|
||||
fg_masks = tensor2pil(foreground_mask)
|
||||
output.extend(
|
||||
Image.composite(
|
||||
fg_image.convert("RGBA"),
|
||||
image,
|
||||
fg_mask,
|
||||
).convert("RGB")
|
||||
for fg_image, fg_mask in zip(fg_images, fg_masks)
|
||||
)
|
||||
elif foreground_mask is not None:
|
||||
log.warn("Mask ignored because no foreground image is given")
|
||||
|
||||
output = pil2tensor(output)
|
||||
|
||||
return (output,)
|
||||
|
||||
|
||||
class ImagePremultiply:
|
||||
@@ -377,25 +502,19 @@ class ImagePremultiply:
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"mask": ("MASK",),
|
||||
"invert": ("BOOL", {"default": False}),
|
||||
"invert": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
CATEGORY = "mtb/image"
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("RGBA",)
|
||||
FUNCTION = "premultiply"
|
||||
|
||||
def premultiply(self, image, mask, invert):
|
||||
images = tensor2pil(image)
|
||||
if invert:
|
||||
masks = tensor2pil(mask) # .convert("L")
|
||||
else:
|
||||
masks = tensor2pil(1.0 - mask)
|
||||
|
||||
single = False
|
||||
if len(mask) == 1:
|
||||
single = True
|
||||
|
||||
masks = tensor2pil(mask) if invert else tensor2pil(1.0 - mask)
|
||||
single = len(mask) == 1
|
||||
masks = [x.convert("L") for x in masks]
|
||||
|
||||
out = []
|
||||
@@ -425,10 +544,18 @@ class ImageResizeFactor:
|
||||
"FLOAT",
|
||||
{"default": 2, "min": 0.01, "max": 16.0, "step": 0.01},
|
||||
),
|
||||
"supersample": ("BOOL", {"default": True}),
|
||||
"supersample": ("BOOLEAN", {"default": True}),
|
||||
"resampling": (
|
||||
["lanczos", "nearest", "bilinear", "bicubic"],
|
||||
{"default": "lanczos"},
|
||||
[
|
||||
"nearest",
|
||||
"linear",
|
||||
"bilinear",
|
||||
"bicubic",
|
||||
"trilinear",
|
||||
"area",
|
||||
"nearest-exact",
|
||||
],
|
||||
{"default": "nearest"},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
@@ -440,71 +567,6 @@ class ImageResizeFactor:
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
FUNCTION = "resize"
|
||||
|
||||
def resize_image(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
factor: float = 0.5,
|
||||
supersample=False,
|
||||
resample="lanczos",
|
||||
mask=None,
|
||||
) -> torch.Tensor:
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
batch_count = 1
|
||||
img = tensor2pil(image)
|
||||
|
||||
if isinstance(img, list):
|
||||
log.debug("Multiple images detected (list)")
|
||||
out = []
|
||||
for im in img:
|
||||
im = self.resize_image(
|
||||
pil2tensor(im), factor, supersample, resample, mask
|
||||
)
|
||||
out.append(im)
|
||||
return torch.cat(out, dim=0)
|
||||
elif isinstance(img, torch.Tensor):
|
||||
if len(image.shape) > 3:
|
||||
batch_count = image.size(0)
|
||||
|
||||
if batch_count > 1:
|
||||
log.debug("Multiple images detected (batch count)")
|
||||
out = [
|
||||
self.resize_image(image[i], factor, supersample, resample, mask)
|
||||
for i in range(batch_count)
|
||||
]
|
||||
return torch.cat(out, dim=0)
|
||||
|
||||
log.debug("Resizing image")
|
||||
# Get the current width and height of the image
|
||||
current_width, current_height = img.size
|
||||
|
||||
log.debug(f"Current width: {current_width}, Current height: {current_height}")
|
||||
|
||||
# Calculate the new width and height based on the given mode and parameters
|
||||
new_width, new_height = int(factor * current_width), int(
|
||||
factor * current_height
|
||||
)
|
||||
|
||||
log.debug(f"New width: {new_width}, New height: {new_height}")
|
||||
|
||||
# Define a dictionary of resampling filters
|
||||
resample_filters = {"nearest": 0, "bilinear": 2, "bicubic": 3, "lanczos": 1}
|
||||
|
||||
# Apply supersample
|
||||
if supersample:
|
||||
super_size = (new_width * 8, new_height * 8)
|
||||
log.debug(f"Applying supersample: {super_size}")
|
||||
img = img.resize(
|
||||
super_size, resample=Image.Resampling(resample_filters[resample])
|
||||
)
|
||||
|
||||
# Resize the image using the given resampling filter
|
||||
resized_image = img.resize(
|
||||
(new_width, new_height),
|
||||
resample=Image.Resampling(resample_filters[resample]),
|
||||
)
|
||||
|
||||
return pil2tensor(resized_image)
|
||||
|
||||
def resize(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
@@ -513,27 +575,56 @@ class ImageResizeFactor:
|
||||
resampling: str,
|
||||
mask=None,
|
||||
):
|
||||
log.debug(f"Resizing image with factor {factor} and resampling {resampling}")
|
||||
# Check if the tensor has the correct dimension
|
||||
if len(image.shape) not in [3, 4]: # HxWxC or BxHxWxC
|
||||
raise ValueError("Expected image tensor of shape (H, W, C) or (B, H, W, C)")
|
||||
|
||||
batch_count = image.size(0)
|
||||
log.debug(f"Batch count: {batch_count}")
|
||||
if batch_count == 1:
|
||||
log.debug("Batch count is 1, returning single image")
|
||||
return (self.resize_image(image, factor, supersample, resampling),)
|
||||
# Transpose to CxHxW or BxCxHxW for PyTorch
|
||||
if len(image.shape) == 3:
|
||||
image = image.permute(2, 0, 1).unsqueeze(0) # CxHxW
|
||||
else:
|
||||
log.debug("Batch count is greater than 1, returning multiple images")
|
||||
images = [
|
||||
self.resize_image(image[i], factor, supersample, resampling)
|
||||
for i in range(batch_count)
|
||||
]
|
||||
images = torch.cat(images, dim=0)
|
||||
return (images,)
|
||||
image = image.permute(0, 3, 1, 2) # BxCxHxW
|
||||
|
||||
# Compute new dimensions
|
||||
B, C, H, W = image.shape
|
||||
new_H, new_W = int(H * factor), int(W * factor)
|
||||
|
||||
align_corner_filters = ("linear", "bilinear", "bicubic", "trilinear")
|
||||
# Resize the image
|
||||
resized_image = F.interpolate(
|
||||
image,
|
||||
size=(new_H, new_W),
|
||||
mode=resampling,
|
||||
align_corners=resampling in align_corner_filters,
|
||||
)
|
||||
|
||||
# Optionally supersample
|
||||
if supersample:
|
||||
resized_image = F.interpolate(
|
||||
resized_image,
|
||||
scale_factor=2,
|
||||
mode=resampling,
|
||||
align_corners=resampling in align_corner_filters,
|
||||
)
|
||||
|
||||
# Transpose back to the original format: BxHxWxC or HxWxC
|
||||
if len(image.shape) == 4:
|
||||
resized_image = resized_image.permute(0, 2, 3, 1)
|
||||
else:
|
||||
resized_image = resized_image.squeeze(0).permute(1, 2, 0)
|
||||
|
||||
# Apply mask if provided
|
||||
if mask is not None:
|
||||
if len(mask.shape) != len(resized_image.shape):
|
||||
raise ValueError(
|
||||
"Mask tensor should have the same dimensions as the image tensor"
|
||||
)
|
||||
resized_image = resized_image * mask
|
||||
|
||||
return (resized_image,)
|
||||
|
||||
|
||||
import math
|
||||
|
||||
|
||||
class SaveImageGrid:
|
||||
class SaveImageGrid_:
|
||||
"""Save all the images in the input batch as a grid of images."""
|
||||
|
||||
def __init__(self):
|
||||
@@ -546,7 +637,7 @@ class SaveImageGrid:
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"filename_prefix": ("STRING", {"default": "ComfyUI"}),
|
||||
"save_intermediate": ("BOOL", {"default": False}),
|
||||
"save_intermediate": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
@@ -635,15 +726,65 @@ class SaveImageGrid:
|
||||
return {"ui": {"images": results}}
|
||||
|
||||
|
||||
class ImageTileOffset:
|
||||
"""Mimics an old photoshop technique to check for seamless textures"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"tiles": ("INT", {"default": 2}),
|
||||
}
|
||||
}
|
||||
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
|
||||
FUNCTION = "tile_image"
|
||||
|
||||
def tile_image(self, image: torch.Tensor, tiles: int = 2):
|
||||
if tiles < 1:
|
||||
raise ValueError("The number of tiles must be at least 1.")
|
||||
|
||||
batch_size, height, width, channels = image.shape
|
||||
tile_height = height // tiles
|
||||
tile_width = width // tiles
|
||||
|
||||
output_image = torch.zeros_like(image)
|
||||
|
||||
for i, j in itertools.product(range(tiles), range(tiles)):
|
||||
start_h = i * tile_height
|
||||
end_h = start_h + tile_height
|
||||
start_w = j * tile_width
|
||||
end_w = start_w + tile_width
|
||||
|
||||
tile = image[:, start_h:end_h, start_w:end_w, :]
|
||||
|
||||
output_start_h = (i + 1) % tiles * tile_height
|
||||
output_start_w = (j + 1) % tiles * tile_width
|
||||
output_end_h = output_start_h + tile_height
|
||||
output_end_w = output_start_w + tile_width
|
||||
|
||||
output_image[
|
||||
:, output_start_h:output_end_h, output_start_w:output_end_w, :
|
||||
] = tile
|
||||
|
||||
return (output_image,)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
ColorCorrect,
|
||||
ImageCompare,
|
||||
Blur,
|
||||
ImageCompare_,
|
||||
ImageTileOffset,
|
||||
Blur_,
|
||||
# DeglazeImage,
|
||||
MaskToImage,
|
||||
ColoredImage,
|
||||
ImagePremultiply,
|
||||
ImageResizeFactor,
|
||||
SaveImageGrid,
|
||||
LoadImageFromUrl,
|
||||
SaveImageGrid_,
|
||||
LoadImageFromUrl_,
|
||||
Sharpen_,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import torch
|
||||
|
||||
from ..log import log
|
||||
|
||||
|
||||
class StackImages:
|
||||
"""Stack the input images horizontally or vertically"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {"required": {"vertical": ("BOOLEAN", {"default": False})}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "stack"
|
||||
CATEGORY = "mtb/image utils"
|
||||
|
||||
def stack(self, vertical, **kwargs):
|
||||
if not kwargs:
|
||||
raise ValueError("At least one tensor must be provided.")
|
||||
|
||||
tensors = list(kwargs.values())
|
||||
log.debug(
|
||||
f"Stacking {len(tensors)} tensors {'vertically' if vertical else 'horizontally'}"
|
||||
)
|
||||
log.debug(list(kwargs.keys()))
|
||||
|
||||
ref_shape = tensors[0].shape
|
||||
for tensor in tensors[1:]:
|
||||
if tensor.shape[1:] != ref_shape[1:]:
|
||||
raise ValueError(
|
||||
"All tensors must have the same dimensions except for the stacking dimension."
|
||||
)
|
||||
|
||||
dim = 1 if vertical else 2
|
||||
|
||||
stacked_tensor = torch.cat(tensors, dim=dim)
|
||||
|
||||
return (stacked_tensor,)
|
||||
|
||||
|
||||
class PickFromBatch:
|
||||
"""Pick a specific number of images from a batch, either from the start or end."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"from_direction": (["end", "start"], {"default": "start"}),
|
||||
"count": ("INT", {"default": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "pick_from_batch"
|
||||
CATEGORY = "mtb/image utils"
|
||||
|
||||
def pick_from_batch(self, image, from_direction, count):
|
||||
batch_size = image.size(0)
|
||||
|
||||
# Limit count to the available number of images in the batch
|
||||
count = min(count, batch_size)
|
||||
if count < batch_size:
|
||||
log.warning(
|
||||
f"Requested {count} images, but only {batch_size} are available."
|
||||
)
|
||||
|
||||
if from_direction == "end":
|
||||
selected_tensors = image[-count:]
|
||||
else:
|
||||
selected_tensors = image[:count]
|
||||
|
||||
return (selected_tensors,)
|
||||
|
||||
|
||||
__nodes__ = [StackImages, PickFromBatch]
|
||||
+231
-67
@@ -1,26 +1,129 @@
|
||||
from ..utils import tensor2np
|
||||
import uuid
|
||||
import folder_paths
|
||||
from ..log import log
|
||||
import comfy.model_management as model_management
|
||||
import subprocess
|
||||
import torch
|
||||
import json, subprocess, uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import comfy.model_management as model_management
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..log import log
|
||||
from ..utils import PIL_FILTER_MAP, output_dir, session_id, tensor2np
|
||||
|
||||
|
||||
class ExportToProres:
|
||||
"""Export to ProRes 4444 (Experimental)"""
|
||||
def get_playlist_path(playlist_name: str, persistant_playlist=False):
|
||||
if persistant_playlist:
|
||||
return output_dir / "playlists" / f"{playlist_name}.json"
|
||||
|
||||
return output_dir / "playlists" / session_id / f"{playlist_name}.json"
|
||||
|
||||
|
||||
class ReadPlaylist:
|
||||
"""Read a playlist"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enable": ("BOOLEAN", {"default": True}),
|
||||
"persistant_playlist": ("BOOLEAN", {"default": False}),
|
||||
"playlist_name": ("STRING", {"default": "playlist_{index:04d}"}),
|
||||
"index": ("INT", {"default": 0, "min": 0}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("PLAYLIST",)
|
||||
FUNCTION = "read_playlist"
|
||||
CATEGORY = "mtb/IO"
|
||||
|
||||
def read_playlist(
|
||||
self, enable: bool, persistant_playlist: bool, playlist_name: str, index: int
|
||||
):
|
||||
playlist_name = playlist_name.format(index=index)
|
||||
playlist_path = get_playlist_path(playlist_name, persistant_playlist)
|
||||
if not enable:
|
||||
return (None,)
|
||||
|
||||
if not playlist_path.exists():
|
||||
log.warning(f"Playlist {playlist_path} does not exist, skipping")
|
||||
return (None,)
|
||||
|
||||
log.debug(f"Reading playlist {playlist_path}")
|
||||
return (json.loads(playlist_path.read_text(encoding="utf-8")),)
|
||||
|
||||
|
||||
class AddToPlaylist:
|
||||
"""Add a video to the playlist"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"relative_paths": ("BOOLEAN", {"default": False}),
|
||||
"persistant_playlist": ("BOOLEAN", {"default": False}),
|
||||
"playlist_name": ("STRING", {"default": "playlist_{index:04d}"}),
|
||||
"index": ("INT", {"default": 0, "min": 0}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "add_to_playlist"
|
||||
CATEGORY = "mtb/IO"
|
||||
|
||||
def add_to_playlist(
|
||||
self,
|
||||
relative_paths: bool,
|
||||
persistant_playlist: bool,
|
||||
playlist_name: str,
|
||||
index: int,
|
||||
**kwargs,
|
||||
):
|
||||
playlist_name = playlist_name.format(index=index)
|
||||
playlist_path = get_playlist_path(playlist_name, persistant_playlist)
|
||||
|
||||
if not playlist_path.parent.exists():
|
||||
playlist_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
playlist = []
|
||||
if not playlist_path.exists():
|
||||
playlist_path.write_text("[]")
|
||||
else:
|
||||
playlist = json.loads(playlist_path.read_text())
|
||||
log.debug(f"Playlist {playlist_path} has {len(playlist)} items")
|
||||
for video in kwargs.values():
|
||||
if relative_paths:
|
||||
video = Path(video).relative_to(output_dir).as_posix()
|
||||
|
||||
log.debug(f"Adding {video} to playlist")
|
||||
playlist.append(video)
|
||||
|
||||
log.debug(f"Writing playlist {playlist_path}")
|
||||
playlist_path.write_text(json.dumps(playlist), encoding="utf-8")
|
||||
return ()
|
||||
|
||||
|
||||
class ExportWithFfmpeg:
|
||||
"""Export with FFmpeg (Experimental)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"optional": {
|
||||
"images": ("IMAGE",),
|
||||
"playlist": ("PLAYLIST",),
|
||||
},
|
||||
"required": {
|
||||
# "frames": ("FRAMES",),
|
||||
"fps": ("FLOAT", {"default": 24, "min": 1}),
|
||||
"prefix": ("STRING", {"default": "export"}),
|
||||
}
|
||||
"format": (["mov", "mp4", "mkv", "avi"], {"default": "mov"}),
|
||||
"codec": (
|
||||
["prores_ks", "libx264", "libx265"],
|
||||
{"default": "prores_ks"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
@@ -30,16 +133,61 @@ class ExportToProres:
|
||||
|
||||
def export_prores(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
fps: float,
|
||||
prefix: str,
|
||||
format: str,
|
||||
codec: str,
|
||||
images: Optional[torch.Tensor] = None,
|
||||
playlist: Optional[List[str]] = None,
|
||||
):
|
||||
if images.size(0) == 0:
|
||||
return ("",)
|
||||
output_dir = Path(folder_paths.get_output_directory())
|
||||
id = f"{prefix}_{uuid.uuid4()}.mov"
|
||||
pix_fmt = "rgb48le" if codec == "prores_ks" else "yuv420p"
|
||||
file_ext = format
|
||||
file_id = f"{prefix}_{uuid.uuid4()}.{file_ext}"
|
||||
|
||||
log.debug(f"Exporting to {output_dir / id}")
|
||||
if playlist is not None and images is not None:
|
||||
log.info(f"Exporting to {output_dir / file_id}")
|
||||
|
||||
if playlist is not None:
|
||||
if len(playlist) == 0:
|
||||
log.debug("Playlist is empty, skipping")
|
||||
return ("",)
|
||||
|
||||
temp_playlist_path = output_dir / f"temp_playlist_{uuid.uuid4()}.txt"
|
||||
log.debug(
|
||||
f"Create a temporary file to list the videos for concatenation to {temp_playlist_path}"
|
||||
)
|
||||
|
||||
with open(temp_playlist_path, "w") as f:
|
||||
for video_path in playlist:
|
||||
f.write(f"file '{video_path}'\n")
|
||||
|
||||
out_path = (output_dir / file_id).as_posix()
|
||||
|
||||
# Prepare the FFmpeg command for concatenating videos from the playlist
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-f",
|
||||
"concat",
|
||||
"-safe",
|
||||
"0",
|
||||
"-i",
|
||||
temp_playlist_path.as_posix(),
|
||||
"-c",
|
||||
"copy",
|
||||
"-y",
|
||||
out_path,
|
||||
]
|
||||
log.debug(f"Executing {command}")
|
||||
subprocess.run(command)
|
||||
|
||||
temp_playlist_path.unlink()
|
||||
|
||||
return (out_path,)
|
||||
|
||||
if (
|
||||
images is None or images.size(0) == 0
|
||||
): # the is None check is just for the type checker
|
||||
return ("",)
|
||||
|
||||
frames = tensor2np(images)
|
||||
log.debug(f"Frames type {type(frames[0])}")
|
||||
@@ -49,7 +197,7 @@ class ExportToProres:
|
||||
|
||||
height, width, _ = frames[0].shape
|
||||
|
||||
out_path = (output_dir / id).as_posix()
|
||||
out_path = (output_dir / file_id).as_posix()
|
||||
|
||||
# Prepare the FFmpeg command
|
||||
command = [
|
||||
@@ -62,17 +210,13 @@ class ExportToProres:
|
||||
"-s",
|
||||
f"{width}x{height}",
|
||||
"-pix_fmt",
|
||||
"rgb48le",
|
||||
pix_fmt,
|
||||
"-r",
|
||||
str(fps),
|
||||
"-i",
|
||||
"-",
|
||||
"-c:v",
|
||||
"prores_ks",
|
||||
"-profile:v",
|
||||
"4",
|
||||
"-pix_fmt",
|
||||
"yuva444p10le",
|
||||
codec,
|
||||
"-r",
|
||||
str(fps),
|
||||
"-y",
|
||||
@@ -91,6 +235,37 @@ class ExportToProres:
|
||||
return (out_path,)
|
||||
|
||||
|
||||
def prepare_animated_batch(
|
||||
batch: torch.Tensor,
|
||||
pingpong=False,
|
||||
resize_by=1.0,
|
||||
resample_filter: Optional[Image.Resampling] = None,
|
||||
image_type=np.uint8,
|
||||
) -> List[Image.Image]:
|
||||
images = tensor2np(batch)
|
||||
images = [frame.astype(image_type) for frame in images]
|
||||
|
||||
height, width, _ = batch[0].shape
|
||||
|
||||
if pingpong:
|
||||
reversed_frames = images[::-1]
|
||||
images.extend(reversed_frames)
|
||||
pil_images = [Image.fromarray(frame) for frame in images]
|
||||
|
||||
# Resize frames if necessary
|
||||
if abs(resize_by - 1.0) > 1e-6:
|
||||
new_width = int(width * resize_by)
|
||||
new_height = int(height * resize_by)
|
||||
pil_images_resized = [
|
||||
frame.resize((new_width, new_height), resample=resample_filter)
|
||||
for frame in pil_images
|
||||
]
|
||||
pil_images = pil_images_resized
|
||||
|
||||
return pil_images
|
||||
|
||||
|
||||
# todo: deprecate for apng
|
||||
class SaveGif:
|
||||
"""Save the images from the batch as a GIF"""
|
||||
|
||||
@@ -101,8 +276,12 @@ class SaveGif:
|
||||
"image": ("IMAGE",),
|
||||
"fps": ("INT", {"default": 12, "min": 1, "max": 120}),
|
||||
"resize_by": ("FLOAT", {"default": 1.0, "min": 0.1}),
|
||||
"pingpong": ("BOOL", {"default": False}),
|
||||
}
|
||||
"optimize": ("BOOLEAN", {"default": False}),
|
||||
"pingpong": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"resample_filter": (list(PIL_FILTER_MAP.keys()),),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
@@ -110,59 +289,44 @@ class SaveGif:
|
||||
CATEGORY = "mtb/IO"
|
||||
FUNCTION = "save_gif"
|
||||
|
||||
def save_gif(self, image, fps=12, resize_by=1.0, pingpong=False):
|
||||
def save_gif(
|
||||
self,
|
||||
image,
|
||||
fps=12,
|
||||
resize_by=1.0,
|
||||
optimize=False,
|
||||
pingpong=False,
|
||||
resample_filter=None,
|
||||
):
|
||||
if image.size(0) == 0:
|
||||
return ("",)
|
||||
|
||||
images = tensor2np(image)
|
||||
images = [frame.astype(np.uint8) for frame in images]
|
||||
if pingpong:
|
||||
reversed_frames = images[::-1]
|
||||
images.extend(reversed_frames)
|
||||
if resample_filter is not None:
|
||||
resample_filter = PIL_FILTER_MAP.get(resample_filter)
|
||||
|
||||
height, width, _ = image[0].shape
|
||||
pil_images = prepare_animated_batch(
|
||||
image,
|
||||
pingpong,
|
||||
resize_by,
|
||||
resample_filter,
|
||||
)
|
||||
|
||||
ruuid = uuid.uuid4()
|
||||
|
||||
ruuid = ruuid.hex[:10]
|
||||
|
||||
out_path = f"{folder_paths.output_directory}/{ruuid}.gif"
|
||||
|
||||
log.debug(f"Saving a gif file {width}x{height} as {ruuid}.gif")
|
||||
|
||||
# Prepare the FFmpeg command
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-vcodec",
|
||||
"rawvideo",
|
||||
"-s",
|
||||
f"{width}x{height}",
|
||||
"-pix_fmt",
|
||||
"rgb24", # GIF only supports rgb24
|
||||
"-r",
|
||||
str(fps),
|
||||
"-i",
|
||||
"-",
|
||||
"-vf",
|
||||
f"fps={fps},scale={width * resize_by}:-1", # Set frame rate and resize if necessary
|
||||
"-y",
|
||||
# Create the GIF from PIL images
|
||||
pil_images[0].save(
|
||||
out_path,
|
||||
]
|
||||
save_all=True,
|
||||
append_images=pil_images[1:],
|
||||
optimize=optimize,
|
||||
duration=int(1000 / fps),
|
||||
loop=0,
|
||||
)
|
||||
|
||||
process = subprocess.Popen(command, stdin=subprocess.PIPE)
|
||||
|
||||
for frame in images:
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
process.stdin.write(frame.tobytes())
|
||||
|
||||
process.stdin.close()
|
||||
process.wait()
|
||||
results = []
|
||||
results.append({"filename": f"{ruuid}.gif", "subfolder": "", "type": "output"})
|
||||
results = [{"filename": f"{ruuid}.gif", "subfolder": "", "type": "output"}]
|
||||
return {"ui": {"gif": results}}
|
||||
|
||||
|
||||
__nodes__ = [SaveGif, ExportToProres]
|
||||
__nodes__ = [SaveGif, ExportWithFfmpeg, AddToPlaylist, ReadPlaylist]
|
||||
|
||||
+9
-6
@@ -1,7 +1,8 @@
|
||||
from rembg import remove
|
||||
from ..utils import pil2tensor, tensor2pil
|
||||
from PIL import Image
|
||||
import comfy.utils
|
||||
from PIL import Image
|
||||
from rembg import remove
|
||||
|
||||
from ..utils import pil2tensor, tensor2pil
|
||||
|
||||
|
||||
class ImageRemoveBackgroundRembg:
|
||||
@@ -13,7 +14,7 @@ class ImageRemoveBackgroundRembg:
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"alpha_matting": (
|
||||
"BOOL",
|
||||
"BOOLEAN",
|
||||
{"default": False},
|
||||
),
|
||||
"alpha_matting_foreground_threshold": (
|
||||
@@ -29,12 +30,12 @@ class ImageRemoveBackgroundRembg:
|
||||
{"default": 10, "min": 0, "max": 255},
|
||||
),
|
||||
"post_process_mask": (
|
||||
"BOOL",
|
||||
"BOOLEAN",
|
||||
{"default": False},
|
||||
),
|
||||
"bgcolor": (
|
||||
"COLOR",
|
||||
{"default": "black"},
|
||||
{"default": "#000000"},
|
||||
),
|
||||
},
|
||||
}
|
||||
@@ -91,6 +92,8 @@ class ImageRemoveBackgroundRembg:
|
||||
|
||||
image_on_bg.paste(img_rm, mask=mask)
|
||||
|
||||
image_on_bg = image_on_bg.convert("RGB")
|
||||
|
||||
out_img.append(img_rm)
|
||||
out_mask.append(mask)
|
||||
out_img_on_bg.append(image_on_bg)
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import copy
|
||||
|
||||
import torch
|
||||
|
||||
from ..log import log
|
||||
|
||||
|
||||
class VaeDecode_:
|
||||
"""Wrapper for the 2 core decoders but also adding the sd seamless hack, taken from: FlyingFireCo/tiled_ksampler"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"samples": ("LATENT",),
|
||||
"vae": ("VAE",),
|
||||
"seamless_model": ("BOOLEAN", {"default": False}),
|
||||
"use_tiling_decoder": ("BOOLEAN", {"default": True}),
|
||||
"tile_size": (
|
||||
"INT",
|
||||
{"default": 512, "min": 320, "max": 4096, "step": 64},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "decode"
|
||||
|
||||
CATEGORY = "mtb/decode"
|
||||
|
||||
def decode(
|
||||
self, vae, samples, seamless_model, use_tiling_decoder=True, tile_size=512
|
||||
):
|
||||
if seamless_model:
|
||||
if use_tiling_decoder:
|
||||
log.error(
|
||||
"You cannot use seamless mode with tiling decoder together, skipping tiling."
|
||||
)
|
||||
use_tiling_decoder = False
|
||||
for layer in [
|
||||
layer
|
||||
for layer in vae.first_stage_model.modules()
|
||||
if isinstance(layer, torch.nn.Conv2d)
|
||||
]:
|
||||
layer.padding_mode = "circular"
|
||||
if use_tiling_decoder:
|
||||
return (
|
||||
vae.decode_tiled(
|
||||
samples["samples"],
|
||||
tile_x=tile_size // 8,
|
||||
tile_y=tile_size // 8,
|
||||
),
|
||||
)
|
||||
else:
|
||||
return (vae.decode(samples["samples"]),)
|
||||
|
||||
|
||||
class ModelPatchSeamless:
|
||||
"""Uses the stable diffusion 'hack' to infer seamless images by setting the model layers padding mode to circular (experimental)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model": ("MODEL",),
|
||||
"tiling": (
|
||||
"BOOLEAN",
|
||||
{"default": True},
|
||||
), # kept for testing not sure why it should be false
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("MODEL", "MODEL")
|
||||
RETURN_NAMES = (
|
||||
"Original Model (passthrough)",
|
||||
"Patched Model",
|
||||
)
|
||||
FUNCTION = "hack"
|
||||
|
||||
CATEGORY = "mtb/textures"
|
||||
|
||||
def apply_circular(self, model, enable):
|
||||
for layer in [
|
||||
layer for layer in model.modules() if isinstance(layer, torch.nn.Conv2d)
|
||||
]:
|
||||
layer.padding_mode = "circular" if enable else "zeros"
|
||||
return model
|
||||
|
||||
def hack(
|
||||
self,
|
||||
model,
|
||||
tiling,
|
||||
):
|
||||
hacked_model = copy.deepcopy(model)
|
||||
self.apply_circular(hacked_model.model, tiling)
|
||||
return (model, hacked_model)
|
||||
|
||||
|
||||
__nodes__ = [ModelPatchSeamless, VaeDecode_]
|
||||
+1
-1
@@ -14,7 +14,7 @@ class IntToBool:
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("BOOL",)
|
||||
RETURN_TYPES = ("BOOLEAN",)
|
||||
FUNCTION = "int_to_bool"
|
||||
CATEGORY = "mtb/number"
|
||||
|
||||
|
||||
+71
-15
@@ -1,5 +1,9 @@
|
||||
import torch
|
||||
import torchvision.transforms.functional as F
|
||||
import torchvision.transforms.functional as TF
|
||||
from ..utils import log, hex_to_rgb, tensor2pil, pil2tensor
|
||||
from math import sqrt, ceil
|
||||
from typing import cast
|
||||
from PIL import Image
|
||||
|
||||
|
||||
class TransformImage:
|
||||
@@ -14,11 +18,19 @@ class TransformImage:
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"x": ("FLOAT", {"default": 0}),
|
||||
"y": ("FLOAT", {"default": 0}),
|
||||
"zoom": ("FLOAT", {"default": 1.0, "min": 0.001}),
|
||||
"angle": ("FLOAT", {"default": 0}),
|
||||
"shear": ("FLOAT", {"default": 0}),
|
||||
"x": ("FLOAT", {"default": 0, "step": 1, "min": -4096, "max": 4096}),
|
||||
"y": ("FLOAT", {"default": 0, "step": 1, "min": -4096, "max": 4096}),
|
||||
"zoom": ("FLOAT", {"default": 1.0, "min": 0.001, "step": 0.01}),
|
||||
"angle": ("FLOAT", {"default": 0, "step": 1, "min": -360, "max": 360}),
|
||||
"shear": (
|
||||
"FLOAT",
|
||||
{"default": 0, "step": 1, "min": -4096, "max": 4096},
|
||||
),
|
||||
"border_handling": (
|
||||
["edge", "constant", "reflect", "symmetric"],
|
||||
{"default": "edge"},
|
||||
),
|
||||
"constant_color": ("COLOR", {"default": "#000000"}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -32,23 +44,67 @@ class TransformImage:
|
||||
x: float,
|
||||
y: float,
|
||||
zoom: float,
|
||||
angle: int,
|
||||
shear,
|
||||
angle: float,
|
||||
shear: float,
|
||||
border_handling="edge",
|
||||
constant_color=None,
|
||||
):
|
||||
x = int(x)
|
||||
y = int(y)
|
||||
angle = int(angle)
|
||||
|
||||
log.debug(f"Zoom: {zoom} | x: {x}, y: {y}, angle: {angle}, shear: {shear}")
|
||||
|
||||
if image.size(0) == 0:
|
||||
return (torch.zeros(0),)
|
||||
transformed_images = []
|
||||
for img in image:
|
||||
img = img.transpose(0, 2)
|
||||
frames_count, frame_height, frame_width, frame_channel_count = image.size()
|
||||
|
||||
transformed_image = F.affine(
|
||||
img, angle=angle, scale=zoom, translate=[int(y), int(x)], shear=shear
|
||||
new_height, new_width = int(frame_height * zoom), int(frame_width * zoom)
|
||||
|
||||
log.debug(f"New height: {new_height}, New width: {new_width}")
|
||||
|
||||
# - Calculate diagonal of the original image
|
||||
diagonal = sqrt(frame_width**2 + frame_height**2)
|
||||
max_padding = ceil(diagonal * zoom - min(frame_width, frame_height))
|
||||
# Calculate padding for zoom
|
||||
pw = int(frame_width - new_width)
|
||||
ph = int(frame_height - new_height)
|
||||
|
||||
pw += abs(max_padding)
|
||||
ph += abs(max_padding)
|
||||
|
||||
padding = [max(0, pw + x), max(0, ph + y), max(0, pw - x), max(0, ph - y)]
|
||||
|
||||
constant_color = hex_to_rgb(constant_color)
|
||||
log.debug(f"Fill Tuple: {constant_color}")
|
||||
|
||||
for img in tensor2pil(image):
|
||||
img = TF.pad(
|
||||
img, # transformed_frame,
|
||||
padding=padding,
|
||||
padding_mode=border_handling,
|
||||
fill=constant_color or 0,
|
||||
)
|
||||
|
||||
transformed_image = transformed_image.transpose(2, 0)
|
||||
transformed_images.append(transformed_image.unsqueeze(0))
|
||||
img = cast(
|
||||
Image.Image,
|
||||
TF.affine(img, angle=angle, scale=zoom, translate=[x, y], shear=shear),
|
||||
)
|
||||
|
||||
return (torch.cat(transformed_images, dim=0),)
|
||||
left = abs(padding[0])
|
||||
upper = abs(padding[1])
|
||||
right = img.width - abs(padding[2])
|
||||
bottom = img.height - abs(padding[3])
|
||||
|
||||
# log.debug("crop is [:,top:bottom, left:right] for tensors")
|
||||
log.debug("crop is [left, top, right, bottom] for PIL")
|
||||
log.debug(f"crop is {left}, {upper}, {right}, {bottom}")
|
||||
img = img.crop((left, upper, right, bottom))
|
||||
|
||||
transformed_images.append(img)
|
||||
|
||||
return (pil2tensor(transformed_images),)
|
||||
|
||||
|
||||
__nodes__ = [TransformImage]
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
onnxruntime-gpu==1.15.1
|
||||
imageio===2.28.1
|
||||
qrcode[pil]
|
||||
numpy==1.23.5
|
||||
rembg==2.0.37
|
||||
# on windows non WSL 2.10 is the last version with GPU support
|
||||
tensorflow<2.11.0; platform_system == "Windows"
|
||||
tb-nightly==2.12.0a20230126; platform_system == "Windows"
|
||||
tensorflow; platform_system != "Windows"
|
||||
# the old tf version on windows comes with a breaking protobuf version
|
||||
protobuf==3.20.2; platform_system == "Windows"
|
||||
gdown @ git+https://github.com/melMass/gdown@main
|
||||
mmdet==3.0.0
|
||||
facexlib==0.3.0
|
||||
insightface==0.7.3
|
||||
mmcv==2.0.0
|
||||
basicsr==1.4.2
|
||||
@@ -0,0 +1,8 @@
|
||||
qrcode[pil]
|
||||
onnxruntime-gpu
|
||||
requirements-parser
|
||||
# opencv-contrib
|
||||
rembg
|
||||
imageio_ffmpeg
|
||||
rich
|
||||
rich_argparse
|
||||
@@ -2,6 +2,8 @@ import os
|
||||
import requests
|
||||
from rich.console import Console
|
||||
from tqdm import tqdm
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
@@ -30,13 +32,13 @@ models_to_download = {
|
||||
"size": 332,
|
||||
"download_url": [
|
||||
"https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.3.pth",
|
||||
"https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth"
|
||||
# TODO: provide a way to selectively download models from "packs"
|
||||
# https://github.com/TencentARC/GFPGAN/releases/download/v0.1.0/GFPGANv1.pth
|
||||
# https://github.com/TencentARC/GFPGAN/releases/download/v0.2.0/GFPGANCleanv1-NoCE-C2.pth
|
||||
# https://github.com/TencentARC/GFPGAN/releases/download/v1.3.0/GFPGANv1.4.pth
|
||||
# https://github.com/TencentARC/GFPGAN/releases/download/v1.3.4/RestoreFormer.pth
|
||||
],
|
||||
"destination": "upscale_models",
|
||||
"destination": "face_restore",
|
||||
},
|
||||
"FILM: Frame Interpolation for Large Motion": {
|
||||
"size": 402,
|
||||
@@ -51,7 +53,6 @@ console = Console()
|
||||
|
||||
from urllib.parse import urlparse
|
||||
from pathlib import Path
|
||||
import gdown
|
||||
|
||||
|
||||
def download_model(download_url, destination):
|
||||
@@ -63,6 +64,21 @@ def download_model(download_url, destination):
|
||||
filename = os.path.basename(urlparse(download_url).path)
|
||||
response = None
|
||||
if "drive.google.com" in download_url:
|
||||
try:
|
||||
import gdown
|
||||
except ImportError:
|
||||
print("Installing gdown")
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"git+https://github.com/melMass/gdown@main",
|
||||
]
|
||||
)
|
||||
import gdown
|
||||
|
||||
if "/folders/" in download_url:
|
||||
# download folder
|
||||
try:
|
||||
|
||||
@@ -1,11 +1,125 @@
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import torch
|
||||
import contextlib, functools, math, os, shlex, shutil, socket, subprocess, sys, uuid
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from typing import List, Optional, Union
|
||||
|
||||
from typing import Union, List
|
||||
from .log import log
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import requests
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from .install import pip_map
|
||||
|
||||
try:
|
||||
from .log import log
|
||||
except ImportError:
|
||||
try:
|
||||
from log import log
|
||||
|
||||
log.warn("Imported log without relative path")
|
||||
except ImportError:
|
||||
import logging
|
||||
|
||||
log = logging.getLogger("comfy mtb utils")
|
||||
log.warn("[comfy mtb] You probably called the file outside a module.")
|
||||
|
||||
|
||||
# region SANITY_CHECK Utilities
|
||||
|
||||
|
||||
def make_report():
|
||||
pass
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region SERVER Utilities
|
||||
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()
|
||||
|
||||
def get_working_ip(self, test_url_template):
|
||||
for ip in self.ips:
|
||||
if ip not in self.checked_ips:
|
||||
self.checked_ips.add(ip)
|
||||
test_url = test_url_template.format(ip)
|
||||
if self._test_url(test_url):
|
||||
return ip
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_local_ips(prefix="192.168."):
|
||||
hostname = socket.gethostname()
|
||||
log.debug(f"Getting local ips for {hostname}")
|
||||
for info in socket.getaddrinfo(hostname, None):
|
||||
# Filter out IPv6 addresses if you only want IPv4
|
||||
log.debug(info)
|
||||
# if info[1] == socket.SOCK_STREAM and
|
||||
if info[0] == socket.AF_INET and info[4][0].startswith(prefix):
|
||||
yield info[4][0]
|
||||
|
||||
def _test_url(self, url):
|
||||
try:
|
||||
response = requests.get(url)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def get_server_info():
|
||||
from comfy.cli_args import args
|
||||
|
||||
ip_checker = IPChecker()
|
||||
base_url = 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(f"http://{{}}:{args.port}/history")
|
||||
log.debug(f"Setting ip to {base_url}")
|
||||
return (base_url, args.port)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region MISC Utilities
|
||||
def backup_file(
|
||||
fp: Path,
|
||||
target: Optional[Path] = None,
|
||||
backup_dir: str = ".bak",
|
||||
suffix: Optional[str] = None,
|
||||
prefix: Optional[str] = None,
|
||||
):
|
||||
if not fp.exists():
|
||||
raise FileNotFoundError(f"No file found at {fp}")
|
||||
|
||||
backup_directory = target or fp.parent / backup_dir
|
||||
backup_directory.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
stem = fp.stem
|
||||
|
||||
if suffix or prefix:
|
||||
new_stem = f"{prefix or ''}{stem}{suffix or ''}"
|
||||
else:
|
||||
new_stem = f"{stem}_{uuid.uuid4()}"
|
||||
|
||||
backup_file_path = backup_directory / f"{new_stem}{fp.suffix}"
|
||||
|
||||
# Perform the backup
|
||||
shutil.copy(fp, backup_file_path)
|
||||
log.debug(f"File backed up to {backup_file_path}")
|
||||
|
||||
|
||||
def hex_to_rgb(hex_color):
|
||||
try:
|
||||
hex_color = hex_color.lstrip("#")
|
||||
return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4))
|
||||
except ValueError:
|
||||
log.error(f"Invalid hex color: {hex_color}")
|
||||
return (0, 0, 0)
|
||||
|
||||
|
||||
def add_path(path, prepend=False):
|
||||
@@ -24,33 +138,128 @@ def add_path(path, prepend=False):
|
||||
sys.path.append(path)
|
||||
|
||||
|
||||
# Get the absolute path of the parent directory of the current script
|
||||
here = Path(__file__).parent.resolve()
|
||||
def run_command(cmd, ignored_lines_start=None):
|
||||
if ignored_lines_start is None:
|
||||
ignored_lines_start = []
|
||||
|
||||
# Construct the absolute path to the ComfyUI directory
|
||||
comfy_dir = here.parent.parent
|
||||
if isinstance(cmd, str):
|
||||
shell_cmd = cmd
|
||||
elif isinstance(cmd, list):
|
||||
shell_cmd = " ".join(
|
||||
arg.as_posix() if isinstance(arg, Path) else shlex.quote(str(arg))
|
||||
for arg in cmd
|
||||
)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Invalid 'cmd' argument. It must be a string or a list of arguments."
|
||||
)
|
||||
|
||||
# Construct the path to the font file
|
||||
try:
|
||||
_run_command(shell_cmd, ignored_lines_start)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Command failed with return code: {e.returncode}", file=sys.stderr)
|
||||
print(e.stderr.strip(), file=sys.stderr)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("Command execution interrupted.")
|
||||
|
||||
|
||||
def _run_command(shell_cmd, ignored_lines_start):
|
||||
log.debug(f"Running {shell_cmd}")
|
||||
|
||||
result = subprocess.run(
|
||||
shell_cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
shell=True,
|
||||
check=True,
|
||||
)
|
||||
|
||||
stdout_lines = result.stdout.strip().split("\n")
|
||||
stderr_lines = result.stderr.strip().split("\n")
|
||||
|
||||
# Print stdout, skipping ignored lines
|
||||
for line in stdout_lines:
|
||||
if not any(line.startswith(ign) for ign in ignored_lines_start):
|
||||
print(line)
|
||||
|
||||
# Print stderr
|
||||
for line in stderr_lines:
|
||||
print(line, file=sys.stderr)
|
||||
|
||||
print("Command executed successfully!")
|
||||
|
||||
|
||||
# todo use the requirements library
|
||||
reqs_map = {value: key for key, value in pip_map.items()}
|
||||
|
||||
import importlib
|
||||
|
||||
|
||||
def import_install(package_name):
|
||||
package_spec = reqs_map.get(package_name, package_name)
|
||||
|
||||
try:
|
||||
importlib.import_module(package_name)
|
||||
|
||||
except Exception: # (ImportError, ModuleNotFoundError):
|
||||
run_command(
|
||||
[Path(sys.executable).as_posix(), "-m", "pip", "install", package_spec]
|
||||
)
|
||||
importlib.import_module(package_name)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region GLOBAL VARIABLES
|
||||
# - detect mode
|
||||
comfy_mode = None
|
||||
if os.environ.get("COLAB_GPU"):
|
||||
comfy_mode = "colab"
|
||||
elif "python_embeded" in sys.executable:
|
||||
comfy_mode = "embeded"
|
||||
elif ".venv" in sys.executable:
|
||||
comfy_mode = "venv"
|
||||
|
||||
# - Get the absolute path of the parent directory of the current script
|
||||
here = Path(__file__).parent.absolute()
|
||||
|
||||
# - Construct the absolute path to the ComfyUI directory
|
||||
comfy_dir = Path(folder_paths.base_path)
|
||||
models_dir = Path(folder_paths.models_dir)
|
||||
output_dir = Path(folder_paths.output_directory)
|
||||
styles_dir = comfy_dir / "styles"
|
||||
session_id = str(uuid.uuid4())
|
||||
# - Construct the path to the font file
|
||||
font_path = here / "font.ttf"
|
||||
|
||||
# Add extern folder to path
|
||||
# - Add extern folder to path
|
||||
extern_root = here / "extern"
|
||||
add_path(extern_root)
|
||||
for pth in extern_root.iterdir():
|
||||
if pth.is_dir():
|
||||
add_path(pth)
|
||||
|
||||
|
||||
# Add the ComfyUI directory and custom nodes path to the sys.path list
|
||||
# - Add the ComfyUI directory and custom nodes path to the sys.path list
|
||||
add_path(comfy_dir)
|
||||
add_path((comfy_dir / "custom_nodes"))
|
||||
|
||||
PIL_FILTER_MAP = {
|
||||
"nearest": Image.Resampling.NEAREST,
|
||||
"box": Image.Resampling.BOX,
|
||||
"bilinear": Image.Resampling.BILINEAR,
|
||||
"hamming": Image.Resampling.HAMMING,
|
||||
"bicubic": Image.Resampling.BICUBIC,
|
||||
"lanczos": Image.Resampling.LANCZOS,
|
||||
}
|
||||
# endregion
|
||||
|
||||
|
||||
# region TENSOR Utilities
|
||||
def tensor2pil(image: torch.Tensor) -> List[Image.Image]:
|
||||
batch_count = 1
|
||||
if len(image.shape) > 3:
|
||||
batch_count = image.size(0)
|
||||
|
||||
batch_count = image.size(0) if len(image.shape) > 3 else 1
|
||||
if batch_count > 1:
|
||||
out = []
|
||||
for i in range(batch_count):
|
||||
@@ -64,14 +273,14 @@ def tensor2pil(image: torch.Tensor) -> List[Image.Image]:
|
||||
]
|
||||
|
||||
|
||||
def pil2tensor(image: Image.Image | List[Image.Image]) -> torch.Tensor:
|
||||
def pil2tensor(image: Union[Image.Image, List[Image.Image]]) -> torch.Tensor:
|
||||
if isinstance(image, list):
|
||||
return torch.cat([pil2tensor(img) for img in image], dim=0)
|
||||
|
||||
return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
|
||||
|
||||
|
||||
def np2tensor(img_np: np.ndarray | List[np.ndarray]) -> torch.Tensor:
|
||||
def np2tensor(img_np: Union[np.ndarray, List[np.ndarray]]) -> torch.Tensor:
|
||||
if isinstance(img_np, list):
|
||||
return torch.cat([np2tensor(img) for img in img_np], dim=0)
|
||||
|
||||
@@ -79,9 +288,7 @@ def np2tensor(img_np: np.ndarray | List[np.ndarray]) -> torch.Tensor:
|
||||
|
||||
|
||||
def tensor2np(tensor: torch.Tensor) -> List[np.ndarray]:
|
||||
batch_count = 1
|
||||
if len(tensor.shape) > 3:
|
||||
batch_count = tensor.size(0)
|
||||
batch_count = tensor.size(0) if len(tensor.shape) > 3 else 1
|
||||
if batch_count > 1:
|
||||
out = []
|
||||
for i in range(batch_count):
|
||||
@@ -89,3 +296,449 @@ def tensor2np(tensor: torch.Tensor) -> List[np.ndarray]:
|
||||
return out
|
||||
|
||||
return [np.clip(255.0 * tensor.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)]
|
||||
|
||||
|
||||
def pad(img, left, right, top, bottom):
|
||||
pad_width = np.array(((0, 0), (top, bottom), (left, right)))
|
||||
print(f"pad_width: {pad_width}, shape: {pad_width.shape}") # Debugging line
|
||||
return np.pad(img, pad_width, mode="wrap")
|
||||
|
||||
|
||||
def tiles_infer(tiles, ort_session, progress_callback=None):
|
||||
"""Infer each tile with the given model. progress_callback will be called with
|
||||
arguments : current tile idx and total tiles amount (used to show progress on
|
||||
cursor in Blender)."""
|
||||
|
||||
out_channels = 3 # normal map RGB channels
|
||||
tiles_nb = tiles.shape[0]
|
||||
pred_tiles = np.empty((tiles_nb, out_channels, tiles.shape[2], tiles.shape[3]))
|
||||
|
||||
for i in range(tiles_nb):
|
||||
if progress_callback != None:
|
||||
progress_callback(i + 1, tiles_nb)
|
||||
pred_tiles[i] = ort_session.run(
|
||||
None, {"input": tiles[i : i + 1].astype(np.float32)}
|
||||
)[0]
|
||||
|
||||
return pred_tiles
|
||||
|
||||
|
||||
def generate_mask(tile_size, stride_size):
|
||||
"""Generates a pyramidal-like mask. Used for mixing overlapping predicted tiles."""
|
||||
|
||||
tile_h, tile_w = tile_size
|
||||
stride_h, stride_w = stride_size
|
||||
ramp_h = tile_h - stride_h
|
||||
ramp_w = tile_w - stride_w
|
||||
|
||||
mask = np.ones((tile_h, tile_w))
|
||||
|
||||
# ramps in width direction
|
||||
mask[ramp_h:-ramp_h, :ramp_w] = np.linspace(0, 1, num=ramp_w)
|
||||
mask[ramp_h:-ramp_h, -ramp_w:] = np.linspace(1, 0, num=ramp_w)
|
||||
# ramps in height direction
|
||||
mask[:ramp_h, ramp_w:-ramp_w] = np.transpose(
|
||||
np.linspace(0, 1, num=ramp_h)[None], (1, 0)
|
||||
)
|
||||
mask[-ramp_h:, ramp_w:-ramp_w] = np.transpose(
|
||||
np.linspace(1, 0, num=ramp_h)[None], (1, 0)
|
||||
)
|
||||
|
||||
# Assume tiles are squared
|
||||
assert ramp_h == ramp_w
|
||||
# top left corner
|
||||
corner = np.rot90(corner_mask(ramp_h), 2)
|
||||
mask[:ramp_h, :ramp_w] = corner
|
||||
# top right corner
|
||||
corner = np.flip(corner, 1)
|
||||
mask[:ramp_h, -ramp_w:] = corner
|
||||
# bottom right corner
|
||||
corner = np.flip(corner, 0)
|
||||
mask[-ramp_h:, -ramp_w:] = corner
|
||||
# bottom right corner
|
||||
corner = np.flip(corner, 1)
|
||||
mask[-ramp_h:, :ramp_w] = corner
|
||||
|
||||
return mask
|
||||
|
||||
|
||||
def corner_mask(side_length):
|
||||
"""Generates the corner part of the pyramidal-like mask.
|
||||
Currently, only for square shapes."""
|
||||
|
||||
corner = np.zeros([side_length, side_length])
|
||||
|
||||
for h in range(0, side_length):
|
||||
for w in range(0, side_length):
|
||||
if h >= w:
|
||||
sh = h / (side_length - 1)
|
||||
corner[h, w] = 1 - sh
|
||||
if h <= w:
|
||||
sw = w / (side_length - 1)
|
||||
corner[h, w] = 1 - sw
|
||||
|
||||
return corner - 0.25 * scaling_mask(side_length)
|
||||
|
||||
|
||||
def scaling_mask(side_length):
|
||||
scaling = np.zeros([side_length, side_length])
|
||||
|
||||
for h in range(0, side_length):
|
||||
for w in range(0, side_length):
|
||||
sh = h / (side_length - 1)
|
||||
sw = w / (side_length - 1)
|
||||
if h >= w and h <= side_length - w:
|
||||
scaling[h, w] = sw
|
||||
if h <= w and h <= side_length - w:
|
||||
scaling[h, w] = sh
|
||||
if h >= w and h >= side_length - w:
|
||||
scaling[h, w] = 1 - sh
|
||||
if h <= w and h >= side_length - w:
|
||||
scaling[h, w] = 1 - sw
|
||||
|
||||
return 2 * scaling
|
||||
|
||||
|
||||
def tiles_merge(tiles, stride_size, img_size, paddings):
|
||||
"""Merges the list of tiles into one image. img_size is the original size, before
|
||||
padding."""
|
||||
|
||||
_, tile_h, tile_w = tiles[0].shape
|
||||
pad_left, pad_right, pad_top, pad_bottom = paddings
|
||||
height = img_size[1] + pad_top + pad_bottom
|
||||
width = img_size[2] + pad_left + pad_right
|
||||
stride_h, stride_w = stride_size
|
||||
|
||||
# stride must be even
|
||||
assert (stride_h % 2 == 0) and (stride_w % 2 == 0)
|
||||
# stride must be greater or equal than half tile
|
||||
assert (stride_h >= tile_h / 2) and (stride_w >= tile_w / 2)
|
||||
# stride must be smaller or equal tile size
|
||||
assert (stride_h <= tile_h) and (stride_w <= tile_w)
|
||||
|
||||
merged = np.zeros((img_size[0], height, width))
|
||||
mask = generate_mask((tile_h, tile_w), stride_size)
|
||||
|
||||
h_range = ((height - tile_h) // stride_h) + 1
|
||||
w_range = ((width - tile_w) // stride_w) + 1
|
||||
|
||||
idx = 0
|
||||
for h in range(0, h_range):
|
||||
for w in range(0, w_range):
|
||||
h_from, h_to = h * stride_h, h * stride_h + tile_h
|
||||
w_from, w_to = w * stride_w, w * stride_w + tile_w
|
||||
merged[:, h_from:h_to, w_from:w_to] += tiles[idx] * mask
|
||||
idx += 1
|
||||
|
||||
return merged[:, pad_top:-pad_bottom, pad_left:-pad_right]
|
||||
|
||||
|
||||
def tiles_split(img, tile_size, stride_size):
|
||||
"""Returns list of tiles from the given image and the padding used to fit the tiles
|
||||
in it. Input image must have dimension C,H,W."""
|
||||
log.debug(f"Splitting img: tile {tile_size}, stride {stride_size} ")
|
||||
tile_h, tile_w = tile_size
|
||||
stride_h, stride_w = stride_size
|
||||
img_h, img_w = img.shape[0], img.shape[1]
|
||||
|
||||
# stride must be even
|
||||
assert (stride_h % 2 == 0) and (stride_w % 2 == 0)
|
||||
# stride must be greater or equal than half tile
|
||||
assert (stride_h >= tile_h / 2) and (stride_w >= tile_w / 2)
|
||||
# stride must be smaller or equal tile size
|
||||
assert (stride_h <= tile_h) and (stride_w <= tile_w)
|
||||
|
||||
# find total height & width padding sizes
|
||||
pad_h, pad_w = 0, 0
|
||||
remainer_h = (img_h - tile_h) % stride_h
|
||||
remainer_w = (img_w - tile_w) % stride_w
|
||||
if remainer_h != 0:
|
||||
pad_h = stride_h - remainer_h
|
||||
if remainer_w != 0:
|
||||
pad_w = stride_w - remainer_w
|
||||
|
||||
# if tile bigger than image, pad image to tile size
|
||||
if tile_h > img_h:
|
||||
pad_h = tile_h - img_h
|
||||
if tile_w > img_w:
|
||||
pad_w = tile_w - img_w
|
||||
|
||||
# pad image, add extra stride to padding to avoid pyramid
|
||||
# weighting leaking onto the valid part of the picture
|
||||
pad_left = pad_w // 2 + stride_w
|
||||
pad_right = pad_left if pad_w % 2 == 0 else pad_left + 1
|
||||
pad_top = pad_h // 2 + stride_h
|
||||
pad_bottom = pad_top if pad_h % 2 == 0 else pad_top + 1
|
||||
img = pad(img, pad_left, pad_right, pad_top, pad_bottom)
|
||||
img_h, img_w = img.shape[1], img.shape[2]
|
||||
|
||||
# extract tiles
|
||||
h_range = ((img_h - tile_h) // stride_h) + 1
|
||||
w_range = ((img_w - tile_w) // stride_w) + 1
|
||||
tiles = np.empty([h_range * w_range, img.shape[0], tile_h, tile_w])
|
||||
idx = 0
|
||||
for h in range(0, h_range):
|
||||
for w in range(0, w_range):
|
||||
h_from, h_to = h * stride_h, h * stride_h + tile_h
|
||||
w_from, w_to = w * stride_w, w * stride_w + tile_w
|
||||
tiles[idx] = img[:, h_from:h_to, w_from:w_to]
|
||||
idx += 1
|
||||
|
||||
return tiles, (pad_left, pad_right, pad_top, pad_bottom)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region MODEL Utilities
|
||||
def download_antelopev2():
|
||||
antelopev2_url = "https://drive.google.com/uc?id=18wEUfMNohBJ4K3Ly5wpTejPfDzp-8fI8"
|
||||
|
||||
try:
|
||||
import gdown
|
||||
|
||||
log.debug("Loading antelopev2 model")
|
||||
|
||||
dest = get_model_path("insightface")
|
||||
archive = dest / "antelopev2.zip"
|
||||
final_path = dest / "models" / "antelopev2"
|
||||
if not final_path.exists():
|
||||
log.info(f"antelopev2 not found, downloading to {dest}")
|
||||
gdown.download(
|
||||
antelopev2_url,
|
||||
archive.as_posix(),
|
||||
resume=True,
|
||||
)
|
||||
|
||||
log.info(f"Unzipping antelopev2 to {final_path}")
|
||||
|
||||
if archive.exists():
|
||||
# we unzip it
|
||||
import zipfile
|
||||
|
||||
with zipfile.ZipFile(archive.as_posix(), "r") as zip_ref:
|
||||
zip_ref.extractall(final_path.parent.as_posix())
|
||||
|
||||
except Exception as e:
|
||||
log.error(
|
||||
f"Could not load or download antelopev2 model, download it manually from {antelopev2_url}"
|
||||
)
|
||||
raise e
|
||||
|
||||
|
||||
def get_model_path(fam, model=None):
|
||||
log.debug(f"Requesting {fam} with model {model}")
|
||||
res = None
|
||||
if model:
|
||||
res = folder_paths.get_full_path(fam, model)
|
||||
else:
|
||||
# this one can raise errors...
|
||||
with contextlib.suppress(KeyError):
|
||||
res = folder_paths.get_folder_paths(fam)
|
||||
|
||||
if res:
|
||||
if isinstance(res, list):
|
||||
if len(res) > 1:
|
||||
log.warning(
|
||||
f"Found multiple match, we will pick the first {res[0]}\n{res}"
|
||||
)
|
||||
res = res[0]
|
||||
res = Path(res)
|
||||
log.debug(f"Resolved model path from folder_paths: {res}")
|
||||
else:
|
||||
res = models_dir / fam
|
||||
if model:
|
||||
res /= model
|
||||
|
||||
return res
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region UV Utilities
|
||||
|
||||
|
||||
def create_uv_map_tensor(width=512, height=512):
|
||||
u = torch.linspace(0.0, 1.0, steps=width)
|
||||
v = torch.linspace(0.0, 1.0, steps=height)
|
||||
|
||||
U, V = torch.meshgrid(u, v)
|
||||
|
||||
uv_map = torch.zeros(height, width, 3, dtype=torch.float32)
|
||||
uv_map[:, :, 0] = U.t()
|
||||
uv_map[:, :, 1] = V.t()
|
||||
|
||||
return uv_map.unsqueeze(0)
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
|
||||
# region ANIMATION Utilities
|
||||
def apply_easing(value, easing_type):
|
||||
if easing_type == "Linear":
|
||||
return value
|
||||
|
||||
# Back easing functions
|
||||
def easeInBack(t):
|
||||
s = 1.70158
|
||||
return t * t * ((s + 1) * t - s)
|
||||
|
||||
def easeOutBack(t):
|
||||
s = 1.70158
|
||||
return ((t - 1) * t * ((s + 1) * t + s)) + 1
|
||||
|
||||
def easeInOutBack(t):
|
||||
s = 1.70158 * 1.525
|
||||
if t < 0.5:
|
||||
return (t * t * (t * (s + 1) - s)) * 2
|
||||
return ((t - 2) * t * ((s + 1) * t + s) + 2) * 2
|
||||
|
||||
# Elastic easing functions
|
||||
def easeInElastic(t):
|
||||
if t == 0:
|
||||
return 0
|
||||
if t == 1:
|
||||
return 1
|
||||
p = 0.3
|
||||
s = p / 4
|
||||
return -(math.pow(2, 10 * (t - 1)) * math.sin((t - 1 - s) * (2 * math.pi) / p))
|
||||
|
||||
def easeOutElastic(t):
|
||||
if t == 0:
|
||||
return 0
|
||||
if t == 1:
|
||||
return 1
|
||||
p = 0.3
|
||||
s = p / 4
|
||||
return math.pow(2, -10 * t) * math.sin((t - s) * (2 * math.pi) / p) + 1
|
||||
|
||||
def easeInOutElastic(t):
|
||||
if t == 0:
|
||||
return 0
|
||||
if t == 1:
|
||||
return 1
|
||||
p = 0.3 * 1.5
|
||||
s = p / 4
|
||||
t = t * 2
|
||||
if t < 1:
|
||||
return -0.5 * (
|
||||
math.pow(2, 10 * (t - 1)) * math.sin((t - 1 - s) * (2 * math.pi) / p)
|
||||
)
|
||||
return (
|
||||
0.5 * math.pow(2, -10 * (t - 1)) * math.sin((t - 1 - s) * (2 * math.pi) / p)
|
||||
+ 1
|
||||
)
|
||||
|
||||
# Bounce easing functions
|
||||
def easeInBounce(t):
|
||||
return 1 - easeOutBounce(1 - t)
|
||||
|
||||
def easeOutBounce(t):
|
||||
if t < (1 / 2.75):
|
||||
return 7.5625 * t * t
|
||||
elif t < (2 / 2.75):
|
||||
t -= 1.5 / 2.75
|
||||
return 7.5625 * t * t + 0.75
|
||||
elif t < (2.5 / 2.75):
|
||||
t -= 2.25 / 2.75
|
||||
return 7.5625 * t * t + 0.9375
|
||||
else:
|
||||
t -= 2.625 / 2.75
|
||||
return 7.5625 * t * t + 0.984375
|
||||
|
||||
def easeInOutBounce(t):
|
||||
if t < 0.5:
|
||||
return easeInBounce(t * 2) * 0.5
|
||||
return easeOutBounce(t * 2 - 1) * 0.5 + 0.5
|
||||
|
||||
# Quart easing functions
|
||||
def easeInQuart(t):
|
||||
return t * t * t * t
|
||||
|
||||
def easeOutQuart(t):
|
||||
t -= 1
|
||||
return -(t**2 * t * t - 1)
|
||||
|
||||
def easeInOutQuart(t):
|
||||
t *= 2
|
||||
if t < 1:
|
||||
return 0.5 * t * t * t * t
|
||||
t -= 2
|
||||
return -0.5 * (t**2 * t * t - 2)
|
||||
|
||||
# Cubic easing functions
|
||||
def easeInCubic(t):
|
||||
return t * t * t
|
||||
|
||||
def easeOutCubic(t):
|
||||
t -= 1
|
||||
return t**2 * t + 1
|
||||
|
||||
def easeInOutCubic(t):
|
||||
t *= 2
|
||||
if t < 1:
|
||||
return 0.5 * t * t * t
|
||||
t -= 2
|
||||
return 0.5 * (t**2 * t + 2)
|
||||
|
||||
# Circ easing functions
|
||||
def easeInCirc(t):
|
||||
return -(math.sqrt(1 - t * t) - 1)
|
||||
|
||||
def easeOutCirc(t):
|
||||
t -= 1
|
||||
return math.sqrt(1 - t**2)
|
||||
|
||||
def easeInOutCirc(t):
|
||||
t *= 2
|
||||
if t < 1:
|
||||
return -0.5 * (math.sqrt(1 - t**2) - 1)
|
||||
t -= 2
|
||||
return 0.5 * (math.sqrt(1 - t**2) + 1)
|
||||
|
||||
# Sine easing functions
|
||||
def easeInSine(t):
|
||||
return -math.cos(t * (math.pi / 2)) + 1
|
||||
|
||||
def easeOutSine(t):
|
||||
return math.sin(t * (math.pi / 2))
|
||||
|
||||
def easeInOutSine(t):
|
||||
return -0.5 * (math.cos(math.pi * t) - 1)
|
||||
|
||||
easing_functions = {
|
||||
"Sine In": easeInSine,
|
||||
"Sine Out": easeOutSine,
|
||||
"Sine In/Out": easeInOutSine,
|
||||
"Quart In": easeInQuart,
|
||||
"Quart Out": easeOutQuart,
|
||||
"Quart In/Out": easeInOutQuart,
|
||||
"Cubic In": easeInCubic,
|
||||
"Cubic Out": easeOutCubic,
|
||||
"Cubic In/Out": easeInOutCubic,
|
||||
"Circ In": easeInCirc,
|
||||
"Circ Out": easeOutCirc,
|
||||
"Circ In/Out": easeInOutCirc,
|
||||
"Back In": easeInBack,
|
||||
"Back Out": easeOutBack,
|
||||
"Back In/Out": easeInOutBack,
|
||||
"Elastic In": easeInElastic,
|
||||
"Elastic Out": easeOutElastic,
|
||||
"Elastic In/Out": easeInOutElastic,
|
||||
"Bounce In": easeInBounce,
|
||||
"Bounce Out": easeOutBounce,
|
||||
"Bounce In/Out": easeInOutBounce,
|
||||
}
|
||||
|
||||
function_ease = easing_functions.get(easing_type)
|
||||
if function_ease:
|
||||
return function_ease(value)
|
||||
|
||||
log.error(f"Unknown easing type: {easing_type}")
|
||||
log.error(f"Available easing types: {list(easing_functions.keys())}")
|
||||
raise ValueError(f"Unknown easing type: {easing_type}")
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
@@ -13,6 +13,10 @@ data otherwise:
|
||||

|
||||
|
||||
|
||||
**note +**
|
||||
A basic HTML note mainly to add better looking notes/instructions for workflow makers:
|
||||

|
||||
|
||||
|
||||
## Standalone
|
||||
These scripts can be taken and placed independently of `comfy_mtb` or any other files, mimicking what pythongosss did for their
|
||||
|
||||
+101
-14
@@ -7,7 +7,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { app } from '/scripts/app.js'
|
||||
import { app } from '../../scripts/app.js'
|
||||
|
||||
export const log = (...args) => {
|
||||
if (window.MTB?.DEBUG) {
|
||||
@@ -18,6 +18,30 @@ export const log = (...args) => {
|
||||
//- WIDGET UTILS
|
||||
export const CONVERTED_TYPE = 'converted-widget'
|
||||
|
||||
export const hasWidgets = (node) => {
|
||||
if (!node.widgets || !node.widgets?.[Symbol.iterator]) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export const cleanupNode = (node) => {
|
||||
if (!hasWidgets(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
for (const w of node.widgets) {
|
||||
if (w.canvas) {
|
||||
w.canvas.remove()
|
||||
}
|
||||
if (w.inputEl) {
|
||||
w.inputEl.remove()
|
||||
}
|
||||
// calls the widget remove callback
|
||||
w.onRemoved?.()
|
||||
}
|
||||
}
|
||||
|
||||
export function offsetDOMWidget(
|
||||
widget,
|
||||
ctx,
|
||||
@@ -49,7 +73,7 @@ export function offsetDOMWidget(
|
||||
position: 'absolute',
|
||||
background: !node.color ? '' : node.color,
|
||||
color: !node.color ? '' : 'white',
|
||||
zIndex: app.graph._nodes.indexOf(node),
|
||||
zIndex: 5, //app.graph._nodes.indexOf(node),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,7 +84,7 @@ export function offsetDOMWidget(
|
||||
*/
|
||||
export function getWidgetType(config) {
|
||||
// Special handling for COMBO so we restrict links based on the entries
|
||||
let type = config[0]
|
||||
let type = config?.[0]
|
||||
let linkType = type
|
||||
if (type instanceof Array) {
|
||||
type = 'COMBO'
|
||||
@@ -68,14 +92,38 @@ export function getWidgetType(config) {
|
||||
}
|
||||
return { type, linkType }
|
||||
}
|
||||
export const setupDynamicConnections = (nodeType, prefix, inputType) => {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, arguments) : undefined
|
||||
this.addInput(`${prefix}_1`, inputType)
|
||||
return r
|
||||
}
|
||||
|
||||
const onConnectionsChange = nodeType.prototype.onConnectionsChange
|
||||
nodeType.prototype.onConnectionsChange = function (
|
||||
type,
|
||||
index,
|
||||
connected,
|
||||
link_info
|
||||
) {
|
||||
const r = onConnectionsChange
|
||||
? onConnectionsChange.apply(this, arguments)
|
||||
: undefined
|
||||
dynamic_connection(this, index, connected, `${prefix}_`, inputType)
|
||||
}
|
||||
}
|
||||
export const dynamic_connection = (
|
||||
node,
|
||||
index,
|
||||
connected,
|
||||
connectionPrefix = 'input_',
|
||||
connectionType = 'PSDLAYER'
|
||||
connectionType = 'PSDLAYER',
|
||||
nameArray = []
|
||||
) => {
|
||||
if (!node.inputs[index].name.startsWith(connectionPrefix)) {
|
||||
return
|
||||
}
|
||||
// remove all non connected inputs
|
||||
if (!connected && node.inputs.length > 1) {
|
||||
log(`Removing input ${index} (${node.inputs[index].name})`)
|
||||
@@ -90,22 +138,24 @@ export const dynamic_connection = (
|
||||
|
||||
// make inputs sequential again
|
||||
for (let i = 0; i < node.inputs.length; i++) {
|
||||
node.inputs[i].label = `${connectionPrefix}${i + 1}`
|
||||
const name =
|
||||
i < nameArray.length ? nameArray[i] : `${connectionPrefix}${i + 1}`
|
||||
node.inputs[i].label = name
|
||||
node.inputs[i].name = name
|
||||
}
|
||||
}
|
||||
|
||||
// add an extra input
|
||||
if (node.inputs[node.inputs.length - 1].link != undefined) {
|
||||
log(
|
||||
`Adding input ${node.inputs.length + 1} (${connectionPrefix}${
|
||||
node.inputs.length + 1
|
||||
})`
|
||||
)
|
||||
const nextIndex = node.inputs.length
|
||||
const name =
|
||||
nextIndex < nameArray.length
|
||||
? nameArray[nextIndex]
|
||||
: `${connectionPrefix}${nextIndex + 1}`
|
||||
|
||||
node.addInput(
|
||||
`${connectionPrefix}${node.inputs.length + 1}`,
|
||||
connectionType
|
||||
)
|
||||
log(`Adding input ${nextIndex + 1} (${name})`)
|
||||
|
||||
node.addInput(name, connectionType)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -284,6 +334,43 @@ function getBrightness(rgbObj) {
|
||||
}
|
||||
|
||||
//- HTML / CSS UTILS
|
||||
export const loadScript = (
|
||||
FILE_URL,
|
||||
async = true,
|
||||
type = 'text/javascript'
|
||||
) => {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
// Check if the script already exists
|
||||
const existingScript = document.querySelector(`script[src="${FILE_URL}"]`)
|
||||
if (existingScript) {
|
||||
resolve({ status: true, message: 'Script already loaded' })
|
||||
return
|
||||
}
|
||||
|
||||
const scriptEle = document.createElement('script')
|
||||
scriptEle.type = type
|
||||
scriptEle.async = async
|
||||
scriptEle.src = FILE_URL
|
||||
|
||||
scriptEle.addEventListener('load', (ev) => {
|
||||
resolve({ status: true })
|
||||
})
|
||||
|
||||
scriptEle.addEventListener('error', (ev) => {
|
||||
reject({
|
||||
status: false,
|
||||
message: `Failed to load the script ${FILE_URL}`,
|
||||
})
|
||||
})
|
||||
|
||||
document.body.appendChild(scriptEle)
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function defineClass(className, classStyles) {
|
||||
const styleSheets = document.styleSheets
|
||||
|
||||
|
||||
+31
-8
@@ -7,17 +7,35 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { app } from '/scripts/app.js'
|
||||
import * as shared from '/extensions/mtb/comfy_shared.js'
|
||||
import { log } from '/extensions/mtb/comfy_shared.js'
|
||||
import { MtbWidgets } from '/extensions/mtb/mtb_widgets.js'
|
||||
import { app } from '../../scripts/app.js'
|
||||
|
||||
import * as shared from './comfy_shared.js'
|
||||
import { log } from './comfy_shared.js'
|
||||
import { MtbWidgets } from './mtb_widgets.js'
|
||||
|
||||
// TODO: respect inputs order...
|
||||
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
app.registerExtension({
|
||||
name: 'mtb.Debug',
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === 'Debug (mtb)') {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated
|
||||
? onNodeCreated.apply(this, arguments)
|
||||
: undefined
|
||||
this.addInput(`anything_1`, '*')
|
||||
return r
|
||||
}
|
||||
|
||||
const onConnectionsChange = nodeType.prototype.onConnectionsChange
|
||||
nodeType.prototype.onConnectionsChange = function (
|
||||
type,
|
||||
@@ -57,15 +75,18 @@ app.registerExtension({
|
||||
// const pos = this.widgets.findIndex((w) => w.name === "anything_1");
|
||||
// if (pos !== -1) {
|
||||
for (let i = 0; i < this.widgets.length; i++) {
|
||||
this.widgets[i].onRemoved?.()
|
||||
if (this.widgets[i].name !== 'output_to_console') {
|
||||
this.widgets[i].onRemoved?.()
|
||||
}
|
||||
}
|
||||
this.widgets.length = 0
|
||||
this.widgets.length = 1
|
||||
}
|
||||
let widgetI = 1
|
||||
|
||||
if (message.text) {
|
||||
for (const txt of message.text) {
|
||||
const w = this.addCustomWidget(
|
||||
MtbWidgets.DEBUG_STRING(`${prefix}_${widgetI}`, txt)
|
||||
MtbWidgets.DEBUG_STRING(`${prefix}_${widgetI}`, escapeHtml(txt))
|
||||
)
|
||||
w.parent = this
|
||||
widgetI++
|
||||
@@ -81,15 +102,17 @@ app.registerExtension({
|
||||
}
|
||||
// this.onResize?.(this.size);
|
||||
// this.resize?.(this.size)
|
||||
this.setSize(this.computeSize())
|
||||
}
|
||||
|
||||
this.setSize(this.computeSize())
|
||||
|
||||
this.onRemoved = function () {
|
||||
// When removing this node we need to remove the input from the DOM
|
||||
for (let y in this.widgets) {
|
||||
if (this.widgets[y].canvas) {
|
||||
this.widgets[y].canvas.remove()
|
||||
}
|
||||
shared.cleanupNode(this)
|
||||
this.widgets[y].onRemoved?.()
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -9,8 +9,8 @@
|
||||
|
||||
// forked from pysssss's imageFeed.js
|
||||
|
||||
import { api } from '/scripts/api.js'
|
||||
import { app } from '/scripts/app.js'
|
||||
import { api } from '../../scripts/api.js'
|
||||
import { app } from '../../scripts/app.js'
|
||||
|
||||
const styles = {
|
||||
lighbox: {
|
||||
@@ -31,7 +31,7 @@ const styles = {
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
zIndex: 9999999,
|
||||
zIndex: 1000,
|
||||
fontSize: '30px',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
@@ -43,7 +43,7 @@ const styles = {
|
||||
width: '100vw',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
zIndex: 9999999,
|
||||
zIndex: 10,
|
||||
background: '#333',
|
||||
overflow: 'auto',
|
||||
},
|
||||
|
||||
+171
-189
@@ -7,13 +7,46 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { app } from '/scripts/app.js'
|
||||
import parseCss from '/extensions/mtb/extern/parse-css.js'
|
||||
import * as shared from '/extensions/mtb/comfy_shared.js'
|
||||
import { log } from '/extensions/mtb/comfy_shared.js'
|
||||
import { api } from '/scripts/api.js'
|
||||
// TODO: Use the builtin addDOMWidget everywhere appropriate
|
||||
|
||||
const newTypes = ['BOOL', 'COLOR', 'BBOX']
|
||||
import { app } from '../../scripts/app.js'
|
||||
import { api } from '../../scripts/api.js'
|
||||
|
||||
import parseCss from './extern/parse-css.js'
|
||||
import * as shared from './comfy_shared.js'
|
||||
import { log } from './comfy_shared.js'
|
||||
|
||||
const newTypes = [, /*'BOOL'*/ 'COLOR', 'BBOX']
|
||||
|
||||
const withFont = (ctx, font, cb) => {
|
||||
const oldFont = ctx.font
|
||||
ctx.font = font
|
||||
cb()
|
||||
ctx.font = oldFont
|
||||
}
|
||||
|
||||
const calculateTextDimensions = (ctx, value, width, fontSize = 16) => {
|
||||
const words = value.split(' ')
|
||||
const lines = []
|
||||
let currentLine = ''
|
||||
for (const word of words) {
|
||||
const testLine = currentLine.length === 0 ? word : `${currentLine} ${word}`
|
||||
const testWidth = ctx.measureText(testLine).width
|
||||
if (testWidth > width) {
|
||||
lines.push(currentLine)
|
||||
currentLine = word
|
||||
} else {
|
||||
currentLine = testLine
|
||||
}
|
||||
}
|
||||
if (lines.length === 0) lines.push(value)
|
||||
const textHeight = (lines.length + 1) * fontSize
|
||||
const maxLineWidth = lines.reduce(
|
||||
(maxWidth, line) => Math.max(maxWidth, ctx.measureText(line).width),
|
||||
0
|
||||
)
|
||||
return { textHeight, maxLineWidth }
|
||||
}
|
||||
|
||||
export const MtbWidgets = {
|
||||
BBOX: (key, val) => {
|
||||
@@ -204,116 +237,7 @@ export const MtbWidgets = {
|
||||
widget.desc = 'Represents a Bounding Box with x, y, width, and height.'
|
||||
return widget
|
||||
},
|
||||
BOOL: (key, val, compute = false) => {
|
||||
/** @type {import("/types/litegraph").IWidget} */
|
||||
const widget = {
|
||||
name: key,
|
||||
type: 'BOOL',
|
||||
options: { default: false },
|
||||
y: 0,
|
||||
|
||||
draw: function (ctx, node, widget_width, widgetY, height) {
|
||||
const hide = this.type !== 'BOOL' && app.canvas.ds.scale > 0.5
|
||||
if (hide) {
|
||||
return
|
||||
}
|
||||
const outline_color = LiteGraph.WIDGET_OUTLINE_COLOR
|
||||
const background_color = LiteGraph.WIDGET_BGCOLOR
|
||||
const text_color = LiteGraph.WIDGET_TEXT_COLOR
|
||||
const H = LiteGraph.NODE_WIDGET_HEIGHT
|
||||
// const arrowSize = 8
|
||||
|
||||
let margin = 15
|
||||
if (hide) return
|
||||
|
||||
let currentY = widgetY
|
||||
|
||||
ctx.textAlign = 'left'
|
||||
ctx.strokeStyle = outline_color
|
||||
ctx.fillStyle = background_color
|
||||
ctx.beginPath()
|
||||
// ctx.roundRect(margin, currentY, widget_width - margin * 2, H, [H * 0.5]);
|
||||
ctx.rect(margin, currentY, H, H) // Draw checkbox square
|
||||
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = text_color
|
||||
// ctx.fillText(this.label || this.name, margin * 2 + 5, currentY + H * 0.7);
|
||||
ctx.fillText(
|
||||
this.label || this.name,
|
||||
H + margin * 2,
|
||||
currentY + H * 0.7
|
||||
)
|
||||
|
||||
// Draw arrow if the value is true
|
||||
// Draw checkmark if the value is true
|
||||
if (this.value) {
|
||||
ctx.fillStyle = text_color
|
||||
ctx.beginPath()
|
||||
ctx.moveTo(margin + H * 0.15, currentY + H * 0.5)
|
||||
ctx.lineTo(margin + H * 0.4, currentY + H * 0.8)
|
||||
ctx.lineTo(margin + H * 0.85, currentY + H * 0.2)
|
||||
ctx.stroke()
|
||||
}
|
||||
},
|
||||
get value() {
|
||||
return this.inputEl.value === 'true'
|
||||
},
|
||||
set value(x) {
|
||||
this.inputEl.value = x
|
||||
},
|
||||
computeSize: function (width) {
|
||||
return [width, 32]
|
||||
},
|
||||
mouse: function (event, pos, node) {
|
||||
// let x = pos[0] - node.pos[0];
|
||||
// let y = pos[1] - node.pos[1];
|
||||
// let width = node.size[0];
|
||||
// let H = LiteGraph.NODE_WIDGET_HEIGHT;
|
||||
// let margin = 15;
|
||||
|
||||
// if (event.type == LiteGraph.pointerevents_method + "down") {
|
||||
// if (x > margin && x < widget_width - margin && y > widgetY && y < widgetY + H) {
|
||||
// this.value = !this.value; // Toggle checkbox value
|
||||
// shared.inner_value_change(this, this.value, event);
|
||||
// app.canvas.setDirty(true);
|
||||
// }
|
||||
// }
|
||||
if (event.type === 'pointerdown') {
|
||||
// get widgets of type type : "COLOR"
|
||||
const widgets = node.widgets.filter((w) => w.type === 'BOOL')
|
||||
|
||||
for (const w of widgets) {
|
||||
// color picker
|
||||
const rect = [w.last_y, w.last_y + 32]
|
||||
if (pos[1] > rect[0] && pos[1] < rect[1]) {
|
||||
// picker.style.position = "absolute";
|
||||
// picker.style.left = ( pos[0]) + "px";
|
||||
// picker.style.top = ( pos[1]) + "px";
|
||||
|
||||
// place at screen center
|
||||
// picker.style.position = "absolute";
|
||||
// picker.style.left = (window.innerWidth / 2) + "px";
|
||||
// picker.style.top = (window.innerHeight / 2) + "px";
|
||||
// picker.style.transform = "translate(-50%, -50%)";
|
||||
// picker.style.zIndex = 1000;
|
||||
|
||||
this.value = this.value ? false : true
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// create a checkbox
|
||||
widget.inputEl = document.createElement('input')
|
||||
widget.inputEl.type = 'checkbox'
|
||||
widget.value = val || false
|
||||
|
||||
document.body.appendChild(widget.inputEl)
|
||||
return widget
|
||||
},
|
||||
COLOR: (key, val, compute = false) => {
|
||||
/** @type {import("/types/litegraph").IWidget} */
|
||||
const widget = {}
|
||||
@@ -425,46 +349,22 @@ export const MtbWidgets = {
|
||||
// const [cw, ch] = this.computeSize(widgetWidth)
|
||||
shared.offsetDOMWidget(this, ctx, node, widgetWidth, widgetY, height)
|
||||
},
|
||||
computeSize: function (width) {
|
||||
const value = this.inputEl.innerHTML
|
||||
if (!value) {
|
||||
computeSize(width) {
|
||||
if (!this.value) {
|
||||
return [32, 32]
|
||||
}
|
||||
if (!width) {
|
||||
log(`No width ${this.parent.size}`)
|
||||
console.debug(`No width ${this.parent.size}`)
|
||||
}
|
||||
|
||||
const oldFont = app.ctx.font
|
||||
app.ctx.font = `${fontSize}px monospace`
|
||||
|
||||
const words = value.split(' ')
|
||||
const lines = []
|
||||
let currentLine = ''
|
||||
for (const word of words) {
|
||||
const testLine =
|
||||
currentLine.length === 0 ? word : `${currentLine} ${word}`
|
||||
|
||||
const testWidth = app.ctx.measureText(testLine).width
|
||||
|
||||
if (testWidth > width) {
|
||||
lines.push(currentLine)
|
||||
currentLine = word
|
||||
} else {
|
||||
currentLine = testLine
|
||||
}
|
||||
}
|
||||
app.ctx.font = oldFont
|
||||
if (lines.length === 0) lines.push(currentLine)
|
||||
|
||||
const textHeight = (lines.length + 1) * fontSize
|
||||
|
||||
const maxLineWidth = lines.reduce(
|
||||
(maxWidth, line) =>
|
||||
Math.max(maxWidth, app.ctx.measureText(line).width),
|
||||
0
|
||||
let dimensions
|
||||
withFont(app.ctx, `${fontSize}px monospace`, () => {
|
||||
dimensions = calculateTextDimensions(app.ctx, this.value, width)
|
||||
})
|
||||
const widgetWidth = Math.max(
|
||||
width || this.width || 32,
|
||||
dimensions.maxLineWidth
|
||||
)
|
||||
const widgetWidth = Math.max(width || this.width || 32, maxLineWidth)
|
||||
const widgetHeight = textHeight * 1.5
|
||||
const widgetHeight = dimensions.textHeight * 1.5
|
||||
return [widgetWidth, widgetHeight]
|
||||
},
|
||||
onRemoved: function () {
|
||||
@@ -472,25 +372,23 @@ export const MtbWidgets = {
|
||||
this.inputEl.remove()
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
Object.defineProperty(w, 'value', {
|
||||
get() {
|
||||
get value() {
|
||||
return this.inputEl.innerHTML
|
||||
},
|
||||
set(value) {
|
||||
this.inputEl.innerHTML = value
|
||||
set value(val) {
|
||||
this.inputEl.innerHTML = val
|
||||
this.parent?.setSize?.(this.parent?.computeSize())
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
w.inputEl = document.createElement('p')
|
||||
w.inputEl.style.textAlign = 'center'
|
||||
w.inputEl.style.fontSize = `${fontSize}px`
|
||||
w.inputEl.style.color = 'var(--input-text)'
|
||||
w.inputEl.style.lineHeight = 0
|
||||
|
||||
w.inputEl.style.fontFamily = 'monospace'
|
||||
w.inputEl.style = `
|
||||
text-align: center;
|
||||
font-size: ${fontSize}px;
|
||||
color: var(--input-text);
|
||||
line-height: 0;
|
||||
font-family: monospace;
|
||||
`
|
||||
w.value = val
|
||||
document.body.appendChild(w.inputEl)
|
||||
|
||||
@@ -621,12 +519,7 @@ const mtb_widgets = {
|
||||
|
||||
this.onRemoved = function () {
|
||||
// When removing this node we need to remove the input from the DOM
|
||||
for (const w of this.widgets) {
|
||||
if (w.canvas) {
|
||||
w.canvas.remove()
|
||||
}
|
||||
w.onRemoved?.()
|
||||
}
|
||||
shared.cleanupNode(this)
|
||||
}
|
||||
return r
|
||||
}
|
||||
@@ -670,6 +563,10 @@ const mtb_widgets = {
|
||||
}
|
||||
}
|
||||
|
||||
if (!nodeData.name.endsWith('(mtb)')) {
|
||||
return
|
||||
}
|
||||
|
||||
//- Extending Python Nodes
|
||||
switch (nodeData.name) {
|
||||
case 'Psd Save (mtb)': {
|
||||
@@ -759,22 +656,14 @@ const mtb_widgets = {
|
||||
i++
|
||||
}
|
||||
}
|
||||
this.setSize?.(this.computeSize())
|
||||
return r
|
||||
}
|
||||
|
||||
const onRemoved = nodeType.prototype.onRemoved
|
||||
nodeType.prototype.onRemoved = function (message) {
|
||||
const r = onRemoved ? onRemoved.apply(this, message) : undefined
|
||||
if (!this.widgets) return r
|
||||
for (const w of this.widgets) {
|
||||
if (w.canvas) {
|
||||
w.canvas.remove()
|
||||
}
|
||||
w.onRemoved?.()
|
||||
const onRemoved = this.onRemoved
|
||||
this.onRemoved = () => {
|
||||
shared.cleanupNode(this)
|
||||
return onRemoved?.()
|
||||
}
|
||||
return r
|
||||
}
|
||||
this.setSize?.(this.computeSize())
|
||||
return r
|
||||
}
|
||||
|
||||
break
|
||||
@@ -842,12 +731,7 @@ const mtb_widgets = {
|
||||
})
|
||||
|
||||
this.onRemoved = () => {
|
||||
for (const w of this.widgets) {
|
||||
if (w.canvas) {
|
||||
w.canvas.remove()
|
||||
}
|
||||
w.onRemoved?.()
|
||||
}
|
||||
shared.cleanupNode(this)
|
||||
app.canvas.setDirty(true)
|
||||
}
|
||||
|
||||
@@ -890,6 +774,40 @@ const mtb_widgets = {
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Interpolate Clip Sequential (mtb)': {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated
|
||||
? onNodeCreated.apply(this, arguments)
|
||||
: undefined
|
||||
const addReplacement = () => {
|
||||
const input = this.addInput(
|
||||
`replacement_${this.widgets.length}`,
|
||||
'STRING',
|
||||
''
|
||||
)
|
||||
console.log(input)
|
||||
this.addWidget('STRING', `replacement_${this.widgets.length}`, '')
|
||||
}
|
||||
//- add
|
||||
this.addWidget('button', '+', 'add', function (value, widget, node) {
|
||||
console.log('Button clicked', value, widget, node)
|
||||
addReplacement()
|
||||
})
|
||||
//- remove
|
||||
this.addWidget(
|
||||
'button',
|
||||
'-',
|
||||
'remove',
|
||||
function (value, widget, node) {
|
||||
console.log(`Button clicked: ${value}`, widget, node)
|
||||
}
|
||||
)
|
||||
|
||||
return r
|
||||
}
|
||||
break
|
||||
}
|
||||
case 'Styles Loader (mtb)': {
|
||||
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions
|
||||
nodeType.prototype.getExtraMenuOptions = function (_, options) {
|
||||
@@ -965,6 +883,70 @@ const mtb_widgets = {
|
||||
|
||||
break
|
||||
}
|
||||
case 'Add To Playlist (mtb)': {
|
||||
shared.setupDynamicConnections(nodeType, 'video', 'VIDEO')
|
||||
break
|
||||
}
|
||||
case 'Stack Images (mtb)':
|
||||
case 'Concat Images (mtb)': {
|
||||
shared.setupDynamicConnections(nodeType, 'image', 'IMAGE')
|
||||
|
||||
break
|
||||
}
|
||||
case 'Batch Float Assemble (mtb)': {
|
||||
shared.setupDynamicConnections(nodeType, 'floats', 'FLOATS')
|
||||
break
|
||||
}
|
||||
case 'Batch Merge (mtb)': {
|
||||
shared.setupDynamicConnections(nodeType, 'batches', 'IMAGE')
|
||||
|
||||
break
|
||||
}
|
||||
// TODO: remove this, recommend pythongoss's version that is much better
|
||||
case 'Math Expression (mtb)': {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated
|
||||
? onNodeCreated.apply(this, arguments)
|
||||
: undefined
|
||||
this.addInput(`x`, '*')
|
||||
return r
|
||||
}
|
||||
|
||||
const onConnectionsChange = nodeType.prototype.onConnectionsChange
|
||||
nodeType.prototype.onConnectionsChange = function (
|
||||
type,
|
||||
index,
|
||||
connected,
|
||||
link_info
|
||||
) {
|
||||
const r = onConnectionsChange
|
||||
? onConnectionsChange.apply(this, arguments)
|
||||
: undefined
|
||||
shared.dynamic_connection(this, index, connected, 'var_', '*', [
|
||||
'x',
|
||||
'y',
|
||||
'z',
|
||||
])
|
||||
|
||||
//- infer type
|
||||
if (link_info) {
|
||||
const fromNode = this.graph._nodes.find(
|
||||
(otherNode) => otherNode.id == link_info.origin_id
|
||||
)
|
||||
const type = fromNode.outputs[link_info.origin_slot].type
|
||||
this.inputs[index].type = type
|
||||
// this.inputs[index].label = type.toLowerCase()
|
||||
}
|
||||
//- restore dynamic input
|
||||
if (!connected) {
|
||||
this.inputs[index].type = '*'
|
||||
this.inputs[index].label = `number_${index + 1}`
|
||||
}
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
case 'Save Tensors (mtb)': {
|
||||
const onDrawBackground = nodeType.prototype.onDrawBackground
|
||||
nodeType.prototype.onDrawBackground = function (ctx, canvas) {
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { app } from '../../scripts/app.js'
|
||||
import { api } from '../../scripts/api.js'
|
||||
import * as shared from './comfy_shared.js'
|
||||
|
||||
class NotePlus extends LiteGraph.LGraphNode {
|
||||
title = 'Note+ (mtb)'
|
||||
category = 'mtb/utils'
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
|
||||
this.isVirtualNode = true
|
||||
this.serialize_widgets = true
|
||||
|
||||
this.editing = false
|
||||
this.live = true
|
||||
this.rawVal = "<p style='color:red;font-family:monospace'\n> Note+\n</p>"
|
||||
|
||||
this.calculated_height = 36
|
||||
|
||||
const inner = document.createElement('div')
|
||||
inner.style.margin = '0'
|
||||
inner.style.padding = '0'
|
||||
this.html_widget = this.addDOMWidget('HTML', 'html', inner, {
|
||||
setValue: (v) => {
|
||||
// update our widget preview
|
||||
this.html_widget.element.innerHTML = v
|
||||
// calculate height
|
||||
this.calculated_height = this.html_widget.element.scrollHeight + 36
|
||||
},
|
||||
getValue: () => this.rawVal,
|
||||
getMinHeight: () => this.calculated_height, // (the edit button),
|
||||
})
|
||||
|
||||
// console.log(`Value of HTML: ${this.html_widget.value}`)
|
||||
this.html_widget.element.innerHTML = this.html_widget.value
|
||||
|
||||
//- ace based editor
|
||||
this.addWidget('button', 'Edit', 'Edit', () => {
|
||||
const container = document.createElement('div')
|
||||
Object.assign(container.style, {
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
})
|
||||
|
||||
dialog.show('')
|
||||
dialog.textElement.append(container)
|
||||
|
||||
const value = document.createElement('div')
|
||||
value.id = 'noteplus-editor'
|
||||
Object.assign(value.style, {
|
||||
width: '300px',
|
||||
height: '200px',
|
||||
backgroundColor: 'rgb(30,30,30)',
|
||||
color: 'whitesmoke',
|
||||
})
|
||||
|
||||
container.append(value)
|
||||
|
||||
const live_edit = document.createElement('input')
|
||||
live_edit.type = 'checkbox'
|
||||
live_edit.checked = this.live
|
||||
live_edit.onchange = () => {
|
||||
this.live = live_edit.checked
|
||||
}
|
||||
|
||||
const live_edit_label = document.createElement('label')
|
||||
live_edit_label.textContent = 'Live Edit'
|
||||
live_edit_label.append(live_edit)
|
||||
|
||||
value.after(live_edit_label)
|
||||
|
||||
this.setupEditor()
|
||||
this.editor.setValue(this.html_widget.element.innerHTML)
|
||||
})
|
||||
|
||||
const dialog = new app.ui.dialog.constructor()
|
||||
dialog.element.classList.add('comfy-settings')
|
||||
|
||||
const closeButton = dialog.element.querySelector('button')
|
||||
closeButton.textContent = 'CANCEL'
|
||||
const saveButton = document.createElement('button')
|
||||
saveButton.textContent = 'SAVE'
|
||||
saveButton.onclick = () => {
|
||||
this.updateHTML(this.editor.getValue())
|
||||
|
||||
this.editor.destroy()
|
||||
this.editor.container.remove()
|
||||
|
||||
dialog.close()
|
||||
}
|
||||
|
||||
closeButton.before(saveButton)
|
||||
|
||||
shared
|
||||
.loadScript(
|
||||
'https://cdn.jsdelivr.net/npm/ace-builds@1.16.0/src-min-noconflict/ace.min.js'
|
||||
)
|
||||
.catch((e) => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
setupEditor() {
|
||||
this.editor = ace.edit('noteplus-editor')
|
||||
this.editor.setTheme('ace/theme/dracula')
|
||||
this.editor.session.setMode('ace/mode/html')
|
||||
|
||||
this.editor.setShowPrintMargin(false)
|
||||
this.editor.session.setUseWrapMode(true)
|
||||
this.editor.renderer.setShowGutter(false)
|
||||
this.editor.session.setTabSize(4)
|
||||
this.editor.session.setUseSoftTabs(true)
|
||||
this.editor.setFontSize(14)
|
||||
this.editor.setReadOnly(false)
|
||||
this.editor.setHighlightActiveLine(false)
|
||||
this.editor.setShowFoldWidgets(true)
|
||||
|
||||
this.editor.session.on('change', (delta) => {
|
||||
// delta.start, delta.end, delta.lines, delta.action
|
||||
if (this.live) {
|
||||
this.updateHTML(this.editor.getValue())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
updateHTML(val) {
|
||||
// if (CONTAINER_HTML.includes('${html}')) {
|
||||
// console.log('found template')
|
||||
// val = CONTAINER_HTML.replace('${html}', val)
|
||||
// }
|
||||
|
||||
this.html_widget.value = val
|
||||
this.rawVal = val
|
||||
|
||||
this.calculated_height = this.html_widget.element.scrollHeight
|
||||
|
||||
this.setSize(this.computeSize())
|
||||
}
|
||||
|
||||
// // onRemoved() {
|
||||
// // console.log('Removing', this)
|
||||
// // for (const w of this.widgets) {
|
||||
// // console.log('Removing', w)
|
||||
// // w.onRemove?.()
|
||||
// // w.onRemoved?.()
|
||||
// // }
|
||||
// // }
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'mtb.noteplus',
|
||||
|
||||
setup() {
|
||||
// app.ui.settings.addSetting({
|
||||
// id: "mtb.noteplus.Container",
|
||||
// name: "📦 HTML container",
|
||||
// type: "text",
|
||||
// defaultValue: "<div>${html}</div>",
|
||||
// tooltip:
|
||||
// "This defines the wrapper for the noteplus html content, use '${html}' to define the location of the placeholder",
|
||||
// attrs: {
|
||||
// style: {
|
||||
// fontFamily: "monospace",
|
||||
// },
|
||||
// },
|
||||
// onChange(value) {
|
||||
// if (!value) {
|
||||
// CONTAINER_HTML = null;
|
||||
// return;
|
||||
// }
|
||||
// console.log(`NOTEPLUS| value changed: ${value}`)
|
||||
// CONTAINER_HTML = value
|
||||
// },
|
||||
// });
|
||||
},
|
||||
|
||||
registerCustomNodes() {
|
||||
LiteGraph.registerNodeType('Note Plus (mtb)', NotePlus)
|
||||
},
|
||||
})
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { app } from '/scripts/app.js'
|
||||
import { app } from '../../scripts/app.js'
|
||||
|
||||
const log = (...args) => {
|
||||
if (window.MTB?.TRACE) {
|
||||
|
||||
Reference in New Issue
Block a user