Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c291325de9 | ||
|
|
133da705c9 | ||
|
|
1b29aad360 | ||
|
|
2465ffb0d3 | ||
|
|
48f91b74e2 | ||
|
|
54ff6583de | ||
|
|
8221c49942 | ||
|
|
9fccdee82d | ||
|
|
af2175a1fc | ||
|
|
fe49312cbe | ||
|
|
c28181f161 | ||
|
|
b7c8582458 | ||
|
|
d202da0e92 | ||
|
|
91fcdb1c61 | ||
|
|
8371867dea | ||
|
|
514c0d2eda | ||
|
|
915b7444a9 | ||
|
|
0d817bf326 | ||
|
|
cd32f26b16 | ||
|
|
501c330105 | ||
|
|
30c4311b69 | ||
|
|
6c5e5d3637 | ||
|
|
90f3bc2d95 | ||
|
|
4b29395000 | ||
|
|
c43a661ba3 | ||
|
|
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
-1
@@ -2,4 +2,7 @@ __pycache__
|
||||
*.py[cod]
|
||||
*.onnx
|
||||
wheels/
|
||||
node_modules/
|
||||
node_modules/
|
||||
compose.yaml
|
||||
comfy_mtb.wsb
|
||||
Dockerfile
|
||||
|
||||
+402
@@ -0,0 +1,402 @@
|
||||
# Changelog
|
||||
|
||||
This is an automated changelog based on the commits in this repository.
|
||||
|
||||
Check the notes in the [releases](https://github.com/melMass/comfy_mtb/releases) for more information.
|
||||
## [main] - 2024-03-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🐛 font fallback ([9fccdee](https://github.com/melMass/comfy_mtb/commit/9fccdee82d721e88c64d2292c209fec869524dd2))
|
||||
- ✨ optional inputs of colored image ([cd32f26](https://github.com/melMass/comfy_mtb/commit/cd32f26b167088d6b489e43b260c187ea5e4d223)) by [@ScottNealon](https://github.com/ScottNealon) in [#147](https://github.com/melMass/comfy_mtb/pull/147)
|
||||
- 📝 adds a way to not load the imagefeed ([501c330](https://github.com/melMass/comfy_mtb/commit/501c3301056b2851555cccd75ab3ff15b1ab8e0c))
|
||||
- 🐛 colored image mask input ([30c4311](https://github.com/melMass/comfy_mtb/commit/30c4311b69f6481a34f968cb67a9b5ce5d2e9fda))
|
||||
- 🐛 handle font cache errors ([c43a661](https://github.com/melMass/comfy_mtb/commit/c43a661ba31dcd7720b4f32d8e96760e6191fbd9))
|
||||
- 💄 register the COLOR type even for external extensions ([12b134a](https://github.com/melMass/comfy_mtb/commit/12b134ab4c937c192aaf4a3667d9885dd4fe43ca))
|
||||
- ✨ mask crop output ([59a361a](https://github.com/melMass/comfy_mtb/commit/59a361af5870b8ffc984c6680dd3282d3553dcf9))
|
||||
- 🚑️ thread font loading ([e4da832](https://github.com/melMass/comfy_mtb/commit/e4da832b99bd640b72c31b67178a3168e3238fa0))
|
||||
- 📦 changed way of creating bbox from mask ([14ee9e2](https://github.com/melMass/comfy_mtb/commit/14ee9e23c009ab55fa3b2fc6ec60fb683c46d57d)) by [@Yurchikian](https://github.com/Yurchikian) in [#124](https://github.com/melMass/comfy_mtb/pull/124)
|
||||
- ✨ expose invert of bboxfrommask ([53cb503](https://github.com/melMass/comfy_mtb/commit/53cb503866da6d83b47eaeb8073039ace2ae0a95))
|
||||
- ✨ less strict csv parsing ([d5c4c5f](https://github.com/melMass/comfy_mtb/commit/d5c4c5f2649ecdb4bf7b517c5b33bbf8df753047))
|
||||
- 🐛 fit number regression ([c8658df](https://github.com/melMass/comfy_mtb/commit/c8658dfbdd3a0ca8c3e88cd1adfddc55c7444045))
|
||||
- 🐛 remove uneeded installs ([4e07450](https://github.com/melMass/comfy_mtb/commit/4e07450bcabb0105b5610e52f7d4692ea07f9c1d))
|
||||
- 🐛 import issue ([255ac03](https://github.com/melMass/comfy_mtb/commit/255ac036bab1d776301857843d0e7a85e9a9dcb8))
|
||||
- 🐛 wrong output for bbox ([8d12b59](https://github.com/melMass/comfy_mtb/commit/8d12b59844958fbc696d01d51162f97262664ae9))
|
||||
- 🚑️ fallback when symlink detection fails ([278f22c](https://github.com/melMass/comfy_mtb/commit/278f22c2093b6eca63d2d00f7936774918707e4e))
|
||||
- ✨ handle malformed styles.csv ([e6f6502](https://github.com/melMass/comfy_mtb/commit/e6f65026735770df8aced4a3acb75550ff1c84da))
|
||||
- 🐛 encoding ([5af2840](https://github.com/melMass/comfy_mtb/commit/5af284067c65042bcdfff04a5d5a2360bf9e4af7))
|
||||
- ⚡️ add the cli deps ([bb90e04](https://github.com/melMass/comfy_mtb/commit/bb90e0415f6a1ececbf468815dc0f5959d9a34e8))
|
||||
- 🚑️ check for symlink ([25b933c](https://github.com/melMass/comfy_mtb/commit/25b933c698b250a411549d2600fae49bec225b7a))
|
||||
- 🚑️ remove problematic dependencies ([5dfea51](https://github.com/melMass/comfy_mtb/commit/5dfea51dd8db2a4829e559eadeda22374b51c8a4))
|
||||
- 🐛 batch support ([f1ff9fc](https://github.com/melMass/comfy_mtb/commit/f1ff9fc7c4684ad673c3178df3b8142dcf0b16ac))
|
||||
- 🐛 automatically disable tiling if seamless is on ([4605f74](https://github.com/melMass/comfy_mtb/commit/4605f74f370d4d221ab1d50f21b72910fa6909c7))
|
||||
- 🐛 debug node ([dc500b7](https://github.com/melMass/comfy_mtb/commit/dc500b788e885205f017956da6a71a677f822941))
|
||||
- ⚡️ hack to handle prompt validation ([d49b257](https://github.com/melMass/comfy_mtb/commit/d49b2578c247dcba9b09b374d99f5cc45cac172d))
|
||||
- ✨ deepbump update ([87b245c](https://github.com/melMass/comfy_mtb/commit/87b245c6a6895490e3612b235879fa90b62dea2b))
|
||||
- 👷 user folder_paths to retrieve comfy root ([38df58a](https://github.com/melMass/comfy_mtb/commit/38df58a78c363ef2657011893d4d811676b1c664))
|
||||
- 🐛 typo ([90aee83](https://github.com/melMass/comfy_mtb/commit/90aee83797a863cf4797cdbe187f949061cbd176))
|
||||
- 🐛 do not resolve symlink for "here" ([a50b11b](https://github.com/melMass/comfy_mtb/commit/a50b11bdaa66f4e805811b1676c937ade11318c2))
|
||||
- ✏️ use Union to allow support for <3.10 ([88a2779](https://github.com/melMass/comfy_mtb/commit/88a277968745ac990406b14d300a8ada9c575b11)) by [@M1kep](https://github.com/M1kep) in [#91](https://github.com/melMass/comfy_mtb/pull/91)
|
||||
- ⚡️ simplify widgets cleanup ([cdd098e](https://github.com/melMass/comfy_mtb/commit/cdd098e10258401402b8023c9143532cfa4a1745))
|
||||
- ✨ don't assume the install was ran ([cc43654](https://github.com/melMass/comfy_mtb/commit/cc43654af2987bc8860557caa99cde91e8309b21))
|
||||
- 🐛 install ([616b2bf](https://github.com/melMass/comfy_mtb/commit/616b2bfc6c629cef1d30cb0d717bd805c3a086aa))
|
||||
- 🐛 properly escape paths ([22cac9b](https://github.com/melMass/comfy_mtb/commit/22cac9b2d95910197941b73e7548735470bd3b17))
|
||||
- 🐛 use relative paths in JS ([e2773ff](https://github.com/melMass/comfy_mtb/commit/e2773ff22e43e7756ad618344a03d661a576cf35))
|
||||
- 💄 BatchFromHistory when "listening" ([3b07984](https://github.com/melMass/comfy_mtb/commit/3b07984716402fbbf5da41020bf73befd52e7ebf))
|
||||
- ✨ save gif widget removal ([fe8f519](https://github.com/melMass/comfy_mtb/commit/fe8f519f8860b0610d8cafcd9b843b4171c2b3d4))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📝 add changelog ([0d817bf](https://github.com/melMass/comfy_mtb/commit/0d817bf326b4a22e2221264a414af50c3b7048b9))
|
||||
- 📄 add note+ screenshot ([90d9636](https://github.com/melMass/comfy_mtb/commit/90d96366c8b7637b55d1b4f88cb9aca217c1414b))
|
||||
- 📝 add cover image ([6b993b8](https://github.com/melMass/comfy_mtb/commit/6b993b84071bbb80ba1b8bd63576f31e35d05590))
|
||||
- 📝 fix image size ([3e8c2fe](https://github.com/melMass/comfy_mtb/commit/3e8c2fe789925e7017c2f8c8d9164c139588aba4))
|
||||
- 📝 add image ([3e93ea6](https://github.com/melMass/comfy_mtb/commit/3e93ea6f2c73353891b1a3f6223b5730bc69df37))
|
||||
- 📝 explain optional nodes ([cea0b08](https://github.com/melMass/comfy_mtb/commit/cea0b08eb044756ab1b408f630435095b8969d36))
|
||||
- 📝 add the example previews from the wiki ([8f90986](https://github.com/melMass/comfy_mtb/commit/8f909864bfaa9f2d0fbdcf3942eacb9d78ee8fb8))
|
||||
- 📝 update node list ([4917e31](https://github.com/melMass/comfy_mtb/commit/4917e31c427c74d28c830fd7b2423cab393ba0f8))
|
||||
- 📝 add some deprecation warnings and recommendations ([e11df9d](https://github.com/melMass/comfy_mtb/commit/e11df9d45c81d93f4334841de036b4aa3364375a))
|
||||
- 📝 add a reference to SlickComfy for colab ([bb35098](https://github.com/melMass/comfy_mtb/commit/bb35098c656b0b2d30909b83df0a3b65c5975f78))
|
||||
|
||||
### Features
|
||||
|
||||
- ✨ add "To Device" ([c28181f](https://github.com/melMass/comfy_mtb/commit/c28181f1615d2e183767aa76cc2350934330e546))
|
||||
- ✨ add note+ example ([90f3bc2](https://github.com/melMass/comfy_mtb/commit/90f3bc2d953b299ea34e9e3a925f1a824b488855))
|
||||
- 💄 node+ improvements ([4b29395](https://github.com/melMass/comfy_mtb/commit/4b29395000254382882c0d1be115b2ed80cd7c99))
|
||||
- 📝 add note plus ([605c8db](https://github.com/melMass/comfy_mtb/commit/605c8db320e1531c6347f6888606fa50d8eb268b))
|
||||
- 🚧 add playlist nodes ([cf96572](https://github.com/melMass/comfy_mtb/commit/cf965727e8e7064328704d88cd0410c61f1e686e))
|
||||
- 🚨 add missing node ([16c1a59](https://github.com/melMass/comfy_mtb/commit/16c1a59312b1d9841f5f8a814eff93a1ddf04edb))
|
||||
- ✨ Math Expression node ([142624e](https://github.com/melMass/comfy_mtb/commit/142624eea616a5622387b1b641c02605455ee6f1))
|
||||
- 🚀 add optional inputs to colored image ([049983d](https://github.com/melMass/comfy_mtb/commit/049983dbe2dbce6b772908468c4042d2bfde5eb2))
|
||||
- ✨ Add support for extra_model_paths.yaml ([d7b8ac8](https://github.com/melMass/comfy_mtb/commit/d7b8ac8e0c98b0d7a2e21889d35aad9f6b093560))
|
||||
- ✨ add batch shake ([af94203](https://github.com/melMass/comfy_mtb/commit/af94203d1b461d934ca1c44211ca0f71a5d05d48))
|
||||
- ✨ enhance concat images ([a798eb0](https://github.com/melMass/comfy_mtb/commit/a798eb07d0d891cfbd47013b442ef2fa3d7cc5bc))
|
||||
- 💄 add a few more batch nodes ([c1d42de](https://github.com/melMass/comfy_mtb/commit/c1d42de0fcde86d2a167fb4b5e781ee987814da2))
|
||||
- ✨ Batch node utilities ([cef5023](https://github.com/melMass/comfy_mtb/commit/cef5023efc17366a2e937ef43944de3587707fac))
|
||||
- 🚨 Image Stack node (horizontal and vertical stack) ([bb3277d](https://github.com/melMass/comfy_mtb/commit/bb3277d85f4ca21735cb1f5237cb1430db88c183))
|
||||
- 🚀 add seamless model hack ([21acc87](https://github.com/melMass/comfy_mtb/commit/21acc87ff0a84b7588f4b5aae0aeb5ae94bbbfbe))
|
||||
- 🔧 debug handle a few more types ([638498c](https://github.com/melMass/comfy_mtb/commit/638498c6b47c2b2cab82f76aec1f3d46df67f263))
|
||||
- 🎨 Add an editor for the styles loader ([2faa2f2](https://github.com/melMass/comfy_mtb/commit/2faa2f2a148a4dbf5525e4945f688a239f244546))
|
||||
- ✨ add a static assets path ([6a00d1d](https://github.com/melMass/comfy_mtb/commit/6a00d1da5a8a5fa47af1bf1ab5d3cd206c599841))
|
||||
- ✨ add Interpolate Clip Sequential ([a71c273](https://github.com/melMass/comfy_mtb/commit/a71c273baf450ad7e2a7e032451f015d3be3e9e9))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- 🧹 applied some linting ([fe49312](https://github.com/melMass/comfy_mtb/commit/fe49312cbef03c6540304448fa88aa7a88391efa))
|
||||
- 📝 header links not parsed ([514c0d2](https://github.com/melMass/comfy_mtb/commit/514c0d2eda9990435eb18258d4bbd1aa137feb3d))
|
||||
- 📝 hardcode links in changelog ([915b744](https://github.com/melMass/comfy_mtb/commit/915b7444a9db83f349d83b636304af0d276f529f))
|
||||
- 🔖 local updates ([6c5e5d3](https://github.com/melMass/comfy_mtb/commit/6c5e5d36379bdab223b4503e42b7956b55a82ab0))
|
||||
- 📝 update node list ([dd27f99](https://github.com/melMass/comfy_mtb/commit/dd27f990c72fa94aff205eb314a8ea360f57479e))
|
||||
- ✨ update node_list ([537a0d8](https://github.com/melMass/comfy_mtb/commit/537a0d8108d0caa3ab2daeafd1d25d680214ef26))
|
||||
- ✨ local stuff ([9afad1a](https://github.com/melMass/comfy_mtb/commit/9afad1a1680073006d946be10f8c97b75ddfe253))
|
||||
- 📝 fix update issue template ([da290db](https://github.com/melMass/comfy_mtb/commit/da290dbcf2952a56be9334f7bf9dc4d8fa64a21d))
|
||||
- 📝 update issue template ([b949bb4](https://github.com/melMass/comfy_mtb/commit/b949bb406bc1929634600465ea389eaedefe6e6f))
|
||||
|
||||
### Refactor
|
||||
|
||||
- ⚡️ small local fixes ([bcac665](https://github.com/melMass/comfy_mtb/commit/bcac66508d2e788cc437da289d1ccede19465b8c))
|
||||
- 🗑️ remove unused code in install script ([5b75436](https://github.com/melMass/comfy_mtb/commit/5b75436610c6312adf47c6baa3e9fe9cc7d56dcf))
|
||||
|
||||
### Merge
|
||||
|
||||
- 🔀 pull request #109 from melMass/dev/0.2.0 ([87e301d](https://github.com/melMass/comfy_mtb/commit/87e301d120a542d5aabe544bec10d38dbd19b2f6)) in [#109](https://github.com/melMass/comfy_mtb/pull/109)
|
||||
- 🔀 pull request #86 from melMass/feature/styles-editor ([cbdb816](https://github.com/melMass/comfy_mtb/commit/cbdb816164900061ddaa1671f4287763d0b79ee1)) in [#86](https://github.com/melMass/comfy_mtb/pull/86)
|
||||
|
||||
### Wip
|
||||
|
||||
- 🚧 add text template node ([af2175a](https://github.com/melMass/comfy_mtb/commit/af2175a1fc0c2fb29ef3493f242fe45ec6fcabac))
|
||||
|
||||
## New Contributors
|
||||
* [@ScottNealon](https://github.com/ScottNealon) made their first contribution in [#147](https://github.com/melMass/comfy_mtb/pull/147)
|
||||
* [@Yurchikian](https://github.com/Yurchikian) made their first contribution in [#124](https://github.com/melMass/comfy_mtb/pull/124)
|
||||
* [@M1kep](https://github.com/M1kep) made their first contribution in [#91](https://github.com/melMass/comfy_mtb/pull/91)
|
||||
## [0.1.4] - 2023-08-12
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🚀 pending fixes ([ea5d73d](https://github.com/melMass/comfy_mtb/commit/ea5d73d48cfa4046f48a52609cff7f754d8364ed))
|
||||
- 🚑️ image resize infinite loop ([30d6cfe](https://github.com/melMass/comfy_mtb/commit/30d6cfe81292d0f7702544b3c2cbad1820c4a926))
|
||||
- ✨ update example files ([610afe0](https://github.com/melMass/comfy_mtb/commit/610afe031f21d737b2fd5128e4be7100b6666181))
|
||||
- 🐛 simplify install steps ([4fc84d6](https://github.com/melMass/comfy_mtb/commit/4fc84d615dd0f546442c3537f00c52366db4ca9b))
|
||||
- ✨ refactor ([8523392](https://github.com/melMass/comfy_mtb/commit/8523392df74c586dc940841ddbb5069943b16f7d))
|
||||
- 🐛 debug rgba ([40560f8](https://github.com/melMass/comfy_mtb/commit/40560f8154d3ddeabf708be4d111370648d466ac))
|
||||
- 🎨 rename fun to generate ([e7f72f9](https://github.com/melMass/comfy_mtb/commit/e7f72f9825da58254e3084b4ba91f76e6cf2cf5f))
|
||||
- ✨ refactor existing ([1144466](https://github.com/melMass/comfy_mtb/commit/11444662b9198861b62aff06a08b9c9ea01dd8bd))
|
||||
- ⚡️ move getbatchfromhistory to graphutils ([2eccba4](https://github.com/melMass/comfy_mtb/commit/2eccba4e33b21d1d080cb2f415f76a93488120f0))
|
||||
- 🚧 wip dependency installer UI ([630b492](https://github.com/melMass/comfy_mtb/commit/630b492347f75d7308b31a000061b41d7dfa4a10))
|
||||
- 🐛 image feed zorder ([0fb2d4d](https://github.com/melMass/comfy_mtb/commit/0fb2d4da90a7e65f82b3f9c8942a68e360456cf7))
|
||||
- ⬇️ download_antelopev2 ([4dd5321](https://github.com/melMass/comfy_mtb/commit/4dd532185223a1fa5978446e7bb75d32d77ebdb5))
|
||||
- 🚑️ frontend pushed too early ([91f60d4](https://github.com/melMass/comfy_mtb/commit/91f60d4c463c474ac10e868e8e73e13fa019856b))
|
||||
- 🚑️ missing input ([84ac8ac](https://github.com/melMass/comfy_mtb/commit/84ac8ac852aeb962029bfd8369fe5ed59a203977))
|
||||
- 🐛 shell command bug ([3d5075f](https://github.com/melMass/comfy_mtb/commit/3d5075fea2e219a179271c9810017c7e38bff6cc))
|
||||
- 🚑️ remove pipe mode from the install.py ([b854a30](https://github.com/melMass/comfy_mtb/commit/b854a302ce4708d2ad2dac249860308dbdcae5a6))
|
||||
- ⚡️ colab install ([36d8e6b](https://github.com/melMass/comfy_mtb/commit/36d8e6bdb06edab72ccfb686266d2e644a9f028c))
|
||||
- 🚑️ install typo ([ffa1a87](https://github.com/melMass/comfy_mtb/commit/ffa1a87b9184df5a3699a6118714b39d359bde4d))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📝 link the actual action instead of badge ([098d74a](https://github.com/melMass/comfy_mtb/commit/098d74a3cd8449d836569a074995e20d775c6728))
|
||||
- 📝 add action badge ([e74314b](https://github.com/melMass/comfy_mtb/commit/e74314b04eb218c140482ccf704b61af06db3f4d))
|
||||
|
||||
### Features
|
||||
|
||||
- 💫 export to prores -> export with ffmpeg ([a4d99d9](https://github.com/melMass/comfy_mtb/commit/a4d99d966b1207191243a9749385b998d1a9c6b1))
|
||||
- 🔥 add any to string & refactor ([dbdb872](https://github.com/melMass/comfy_mtb/commit/dbdb872b74e18c16feb44bd037abc3aafbb4700f))
|
||||
- ✨ add UI for interpolate clip sequential ([5ec5511](https://github.com/melMass/comfy_mtb/commit/5ec551143302b2a94ca82e477f684ecee23f1459))
|
||||
- ✨ add portable reqs ([3f14b16](https://github.com/melMass/comfy_mtb/commit/3f14b1676d28f5ffa1f47fda00b9bc244951045c))
|
||||
- ✨ add border extension ([fb64484](https://github.com/melMass/comfy_mtb/commit/fb644847ca434123e8e8e4991d33949fd31e3cbe))
|
||||
- ✨ use PIL for gif saving ([2bc7ae8](https://github.com/melMass/comfy_mtb/commit/2bc7ae88bf4cdfa575d11233c0e6f7b07f9dfd23))
|
||||
- 🎨 update node list ([a54d7d5](https://github.com/melMass/comfy_mtb/commit/a54d7d5346c272898dd4e67c65495de7325ab3a0))
|
||||
- ✨ install fix ([512de60](https://github.com/melMass/comfy_mtb/commit/512de6023e55f2cc47516bf44436efe22157273f)) in [#41](https://github.com/melMass/comfy_mtb/pull/41)
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- 💄 encoding ([49c64c7](https://github.com/melMass/comfy_mtb/commit/49c64c74eb3e99f456b563bbd79e3fe47a85c70d))
|
||||
- 🚀 only fetch controlnet_preprocessor deps ([414beb9](https://github.com/melMass/comfy_mtb/commit/414beb99a1f9bf719eca6ac139c9b2ccdfd6d743))
|
||||
- 🚀 add controlnetpreprocessors to tests ([63b3aec](https://github.com/melMass/comfy_mtb/commit/63b3aece2ba05adc2b655afeb41e3d47e7887b33))
|
||||
- ✨ remove unused input ([d4f791d](https://github.com/melMass/comfy_mtb/commit/d4f791d7a14ba9cb8abd7c95ba70b081fee5fb7c))
|
||||
- ✨ use the same cwd as manager ([2ff0467](https://github.com/melMass/comfy_mtb/commit/2ff04672daff773d52e1552dca1bf616bc32daa6))
|
||||
- 🎨 no brace glob ([bbfcb62](https://github.com/melMass/comfy_mtb/commit/bbfcb62c398de39058bcb6e18161425059d53e8e))
|
||||
- 🎨 extract txt ([a22fd01](https://github.com/melMass/comfy_mtb/commit/a22fd01d664276e4cd833ae1326feeece1d1deaf))
|
||||
- 🎨 also push wheels_order to releases ([8e5b776](https://github.com/melMass/comfy_mtb/commit/8e5b7765cc0c6730bd5517ccfd56e817ea39bd3a))
|
||||
- 🚧 more info for bug reports ([3dadc11](https://github.com/melMass/comfy_mtb/commit/3dadc119f44fca1029ec4b349d71ce99fb20a4b6))
|
||||
- ✨ individual wheels ([346ff64](https://github.com/melMass/comfy_mtb/commit/346ff649d50c9f0286ad2243938406fefb62853b))
|
||||
|
||||
### Refactor
|
||||
|
||||
- 🚧 tidy ([4f30829](https://github.com/melMass/comfy_mtb/commit/4f30829e06c41b3685644bfe7bece07e0bcfb70e))
|
||||
- ♻️ get batch from history ([13d255a](https://github.com/melMass/comfy_mtb/commit/13d255a730b08c4903647875350b9b3dcd61b4a6))
|
||||
|
||||
### Revert
|
||||
|
||||
- 💄 use BOOLEAN instead of BOOL ([cfb3b23](https://github.com/melMass/comfy_mtb/commit/cfb3b237cf64b512414a17f71e6d89c3355aa8ef))
|
||||
|
||||
### Testing
|
||||
|
||||
- 🧪 remove sha input ([c5bbe83](https://github.com/melMass/comfy_mtb/commit/c5bbe83008bb194cbd6ad5e3dc70cb3850b18985))
|
||||
- 🧪 ci for comfy embedded ([7b3afca](https://github.com/melMass/comfy_mtb/commit/7b3afca8179760e35e8a6fbf742080dee13e4fc7))
|
||||
|
||||
### Merge
|
||||
|
||||
- 🔀 pull request #50 from melMass/dev/august-refactor ([2ecd470](https://github.com/melMass/comfy_mtb/commit/2ecd4700d77c0727e6b5d2124e0a6ebd48ec96ed)) in [#50](https://github.com/melMass/comfy_mtb/pull/50)
|
||||
|
||||
## [0.1.3] - 2023-07-29
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🔥 manage pip from install only, remove requirements.txt ([247fbfb](https://github.com/melMass/comfy_mtb/commit/247fbfbc216b8259d607e0699d5b990b6a06ca71)) in [#38](https://github.com/melMass/comfy_mtb/pull/38)
|
||||
- 🎨 use image ratio for imagefeed ([f5cd56c](https://github.com/melMass/comfy_mtb/commit/f5cd56ce861c8c0a931744ae6cf2b96e9c8bca06))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📝 update imagefeed preview ([cbcacbe](https://github.com/melMass/comfy_mtb/commit/cbcacbe3c92ebb5f74d046b83504c3723710f130))
|
||||
- 📝 fix typo and add more details ([7c020ba](https://github.com/melMass/comfy_mtb/commit/7c020bab288aa7d17dc937b5f102319d43c3ebb3))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- ✨ use wheel order if present ([9b24edd](https://github.com/melMass/comfy_mtb/commit/9b24eddd9c51004af08d7ac6ff2b6473dd3ee161))
|
||||
- ✨ store order of install for wheels ([5053142](https://github.com/melMass/comfy_mtb/commit/505314294f02e7c19ac95e4d0ed37fd397a54b46))
|
||||
|
||||
## [0.1.2] - 2023-07-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- ✨ various small things ([0e311cf](https://github.com/melMass/comfy_mtb/commit/0e311cf2c64cf2b4861d4cc612a3409390e3039a))
|
||||
- 📝 last release ([889f08c](https://github.com/melMass/comfy_mtb/commit/889f08c08b721be8fdb4e4d7eacc47169d5692d6)) in [#36](https://github.com/melMass/comfy_mtb/pull/36)
|
||||
- 📝 narrow requirements ([5d661b2](https://github.com/melMass/comfy_mtb/commit/5d661b2509fecf3940c3c0fab25b16ec0eae7a2d))
|
||||
- ✨ Separate FaceAnalysis model loading ([d143e83](https://github.com/melMass/comfy_mtb/commit/d143e83dba3bffa16e1b98d7ad1e9cf92dc94db2))
|
||||
- ⚡️ update examples to match wiki ([3dfe98c](https://github.com/melMass/comfy_mtb/commit/3dfe98c7957df48723380de85e1242a424ec23de))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📝 add readme for web extensions features ([be162a2](https://github.com/melMass/comfy_mtb/commit/be162a20477258627fa0d742c97a478bd085ff4f))
|
||||
- 📝 link to the proper lang instructions ([232cf89](https://github.com/melMass/comfy_mtb/commit/232cf8966cc20291b60c68f487dfd37bf6aa4dfa)) in [#33](https://github.com/melMass/comfy_mtb/pull/33)
|
||||
- 📝 update readmes ([96a0618](https://github.com/melMass/comfy_mtb/commit/96a0618c5990a8559a9e2dd17c868d3465b8ca90))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- 🎉 bump version ([9e751a2](https://github.com/melMass/comfy_mtb/commit/9e751a242f4e9afee3dc5c871c414b29b9706ff6))
|
||||
- 👷 remove stale example ([c237737](https://github.com/melMass/comfy_mtb/commit/c2377374201fc34b107c8b7db1cdeb2f483d1e18))
|
||||
- 🐛 fix size ([c0cc557](https://github.com/melMass/comfy_mtb/commit/c0cc5572d8c727568eca8a3d0f116a1f540c31ff))
|
||||
|
||||
## [0.1.1] - 2023-07-24
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🎨 improve a bit the HTML response of endpoints ([50d51c7](https://github.com/melMass/comfy_mtb/commit/50d51c70d04e49e9df524975c171288c0fc0b20f))
|
||||
- 🐛 caching issues ([55c9736](https://github.com/melMass/comfy_mtb/commit/55c9736a9b2ca036926be4b06406121bfb9ebad2))
|
||||
- 🔥 remove notice ([abf1e82](https://github.com/melMass/comfy_mtb/commit/abf1e82adb9fac8cd70d5c409baad55309ef6fe1))
|
||||
- 🔥 use BOOL everywhere ([a393793](https://github.com/melMass/comfy_mtb/commit/a393793cfa93721eac46295723076a1dda940dcd))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📝 added lang links ([bbdac97](https://github.com/melMass/comfy_mtb/commit/bbdac97e49af4e90d22eeec3f63b96ecc126ffcf))
|
||||
- 📝 add comfyforum example ([10d0503](https://github.com/melMass/comfy_mtb/commit/10d05031b1791ab3534cf838be6eb75df638dfb6))
|
||||
|
||||
### Features
|
||||
|
||||
- 🚧 jupyter seems to require an __init__ there ([9a4eda3](https://github.com/melMass/comfy_mtb/commit/9a4eda3ef573bf382c13515f67ae8a415bf61abd))
|
||||
- ⚡️ use notify ([a2ecc11](https://github.com/melMass/comfy_mtb/commit/a2ecc11ebde79c2403959bf09c258f3a2465894a))
|
||||
- ✨ first version of Notify ([7e9c97e](https://github.com/melMass/comfy_mtb/commit/7e9c97ecb48672b25e5ed17b9b35dba9208ac311))
|
||||
- ⚡️ add an "actions" endpoint ([3de160a](https://github.com/melMass/comfy_mtb/commit/3de160af25b516c02aaa8cc32baec16e9ef358fb))
|
||||
- ✨ add Unsplash Image node ([8d3cc39](https://github.com/melMass/comfy_mtb/commit/8d3cc39b72dff1b5eb61bf7e2e395753c138ec8a))
|
||||
- ✨ add back Save Tensors ([7142b28](https://github.com/melMass/comfy_mtb/commit/7142b284adc7fba9a1bdafd1a52621bfc168bde1))
|
||||
- ✨ add TransformImage node ([11128ff](https://github.com/melMass/comfy_mtb/commit/11128ff85a7e0b4a54f405548969c2478da26df6))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- 🚀 bump version ([cf86552](https://github.com/melMass/comfy_mtb/commit/cf865529ab64b350cd7af964b41160e7d130d12d))
|
||||
- 🚀 Remove large files from release ([3b9190a](https://github.com/melMass/comfy_mtb/commit/3b9190a69b002b8933c097fd6655bb4fe07264d2))
|
||||
|
||||
### Refactor
|
||||
|
||||
- ✨ cleaned up frontend code a bit ([3801a44](https://github.com/melMass/comfy_mtb/commit/3801a443bc1e89c70fdb35ce0b1724d86fa22928))
|
||||
- ⚡️ remove empty inits ([21729b2](https://github.com/melMass/comfy_mtb/commit/21729b2784a50fcaf24a63ac283bdae475a53ce7))
|
||||
|
||||
### Merge
|
||||
|
||||
- 🔀 pull request #32 from melMass/dev/next ([8695cd3](https://github.com/melMass/comfy_mtb/commit/8695cd3f1b6d27b5cd6c616ed1215ea2f25c5304)) in [#32](https://github.com/melMass/comfy_mtb/pull/32)
|
||||
|
||||
## [0.1.0] - 2023-07-22
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🔥 properly match built wheels ([119b4d6](https://github.com/melMass/comfy_mtb/commit/119b4d6e16c2a90db1664ccaac748507feb73ea0)) in [#30](https://github.com/melMass/comfy_mtb/pull/30)
|
||||
- ✨ also try to copy web if symlink fails ([0df55de](https://github.com/melMass/comfy_mtb/commit/0df55def29fb992751010f6b8a707699f230ff37))
|
||||
- ✨ install process tested in comfy-manager (embed, colab) ([b40730d](https://github.com/melMass/comfy_mtb/commit/b40730ddbc3f8e3e7d5a17e9e9e4526ff37977fd))
|
||||
- 🚀 try to support remote install too ([3c66de2](https://github.com/melMass/comfy_mtb/commit/3c66de2500a89efd2d2e3af88fc58429af725789))
|
||||
- 💄 save gif issues ([7335003](https://github.com/melMass/comfy_mtb/commit/7335003346e83666c5dee631b8e6b15586d871e7))
|
||||
- 🚑️ always use latest for now ([fccf313](https://github.com/melMass/comfy_mtb/commit/fccf31348994ab6e344a1ab00a8f9998309f9319))
|
||||
- 🐛 install logic ([7e301e2](https://github.com/melMass/comfy_mtb/commit/7e301e2a067d41cba9b8ef357496dd1df94e4cdd))
|
||||
- 🎉 remove tests & add missing docs ([4e6b877](https://github.com/melMass/comfy_mtb/commit/4e6b87719989aa144946c5c9a43b9398c20bf11e))
|
||||
- ⚡️ update node_list ([c794d6a](https://github.com/melMass/comfy_mtb/commit/c794d6a071778220d654b526d2edfddcc79752fc))
|
||||
- 🚑️ set debug level from endpoint ([18402e3](https://github.com/melMass/comfy_mtb/commit/18402e3be1ab47e10109cfd2dff18863a1ee56f7))
|
||||
- 🐛 add base64 prefix to outputs ([0950f99](https://github.com/melMass/comfy_mtb/commit/0950f9914c9bbed7c89f3de33a967cb76f9d0bbb))
|
||||
- 🎨 refactor and add Gif preview on node ([c2e8379](https://github.com/melMass/comfy_mtb/commit/c2e83794faeb8da708c98908882e38b2a42827bd))
|
||||
- ✨ Various widgets issues ([27500ca](https://github.com/melMass/comfy_mtb/commit/27500ca432d686774b991045b7cffc58c0b67faf))
|
||||
- 🔥 deprecate some nodes and fix image list ([9aa934f](https://github.com/melMass/comfy_mtb/commit/9aa934f70ff6adf91efb26aa8e5cb21ec575196a))
|
||||
- 🐛 crop nodes ([67d3783](https://github.com/melMass/comfy_mtb/commit/67d3783ac9186da6bba4b7dc7e8dc3d5db5a1b0f))
|
||||
- 🐛 tensor2pil ([8a59508](https://github.com/melMass/comfy_mtb/commit/8a59508ff91d6b2d9ca287ef1c054ec5755337a4))
|
||||
- ⚡️ a few missing __doc__ ([ab09cca](https://github.com/melMass/comfy_mtb/commit/ab09ccadd905bebbf1b7b2d992e96e36fc60d68a))
|
||||
- ⚡️ from tensor2np always returning a list ([6168b3a](https://github.com/melMass/comfy_mtb/commit/6168b3a2ac38b5eebed3daf9e52df5742abf6813))
|
||||
- 🚑️ TF by default fills vram ([c225da5](https://github.com/melMass/comfy_mtb/commit/c225da5f298acb4cb2b39022382543c0c966d428))
|
||||
- ✨ leftovers ([da3e6f4](https://github.com/melMass/comfy_mtb/commit/da3e6f47c6073e73cf9d3a3cd23ba5ccbe1fedce))
|
||||
- ✨ handle non fork gdown in model dll ([95797e8](https://github.com/melMass/comfy_mtb/commit/95797e823e12e62ae8758753f60c34afbe19ec90))
|
||||
- ✨ properly add the submodules ([00510ed](https://github.com/melMass/comfy_mtb/commit/00510ed0b8583dd64518daa67d963582f4f029d3))
|
||||
- 📌 remove sad talker for now ([1622cbc](https://github.com/melMass/comfy_mtb/commit/1622cbcb9d51ddd0e1a8b4d87ba47b99327163eb))
|
||||
- 🎨 narrow requirements ([1a92ef7](https://github.com/melMass/comfy_mtb/commit/1a92ef734dd4271efc875856038f9e3b6b9ded6c))
|
||||
- 🚀 use the comfy util to handle graph interruption ([9752f3e](https://github.com/melMass/comfy_mtb/commit/9752f3e9dec9aa59cfa809aa14f0151594c03858))
|
||||
- 🔥 much faster (using GPU) on windows ([2f455aa](https://github.com/melMass/comfy_mtb/commit/2f455aaca55c0a044735c768b295d077b2f5b8d6))
|
||||
- 🐛 uint8 to uint16 ([be5a655](https://github.com/melMass/comfy_mtb/commit/be5a655cfaba1794f7b09c82d65de42e2b031720))
|
||||
- ✨ add missing requirements ([b779bc3](https://github.com/melMass/comfy_mtb/commit/b779bc39ac19f779aeb73c98b916671d1d16806f))
|
||||
- 📝 don't propagate base logs ([7fd99c2](https://github.com/melMass/comfy_mtb/commit/7fd99c25c4e50566def5c5166a9d9059b1febfa6))
|
||||
- 🐛 bg upscaler in gfpgan ([fee48ad](https://github.com/melMass/comfy_mtb/commit/fee48adff3d66960cb17836f3f4efbfd0c8740c4))
|
||||
- 📝 separate debug / info better ([e24863d](https://github.com/melMass/comfy_mtb/commit/e24863d1f9f63f367a2b392e6228ffa42927b71b))
|
||||
- 🔥 change log level of the base logger ([7538c2c](https://github.com/melMass/comfy_mtb/commit/7538c2c4bad8390a32226dc0a5a6ef978b00d201))
|
||||
- ✨ handle externs dynamicly ([6ef308a](https://github.com/melMass/comfy_mtb/commit/6ef308a87062c91e2d7249c05d96c6fb76e5a6c4))
|
||||
- 🐛 separate faceswap model load ([8e267c0](https://github.com/melMass/comfy_mtb/commit/8e267c0204ce5abe8e113fd401234d49f377646a))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📝 fold each comfy mode ([3c3c438](https://github.com/melMass/comfy_mtb/commit/3c3c4380bd1a3f0eed5216b835e076c26fce2f88))
|
||||
- 📝 add more description to examples ([46eab5c](https://github.com/melMass/comfy_mtb/commit/46eab5ca2f0e04d872d87c849b11551fd219bdb9))
|
||||
- 📝 add model notice ([cbe67ed](https://github.com/melMass/comfy_mtb/commit/cbe67edd4befb7260be01fa09af8448e5bcf5680))
|
||||
- 📝 add preview for examples ([b5176ca](https://github.com/melMass/comfy_mtb/commit/b5176ca0ee489ada52b6632f68b794b4f709d5ba))
|
||||
- 📝 add jp and cn (using deep translation) ([da559b9](https://github.com/melMass/comfy_mtb/commit/da559b9eaf135a49c0ab9bfa45573baf0c18dfb2))
|
||||
- 📝 update readme ([b0fb522](https://github.com/melMass/comfy_mtb/commit/b0fb5222cb19e4004533d3367863be5c9ce8e72b)) in [#15](https://github.com/melMass/comfy_mtb/pull/15)
|
||||
- 📝 update README.md ([f8dc768](https://github.com/melMass/comfy_mtb/commit/f8dc768635a2d21f6ff81b42c418724c432159bf))
|
||||
- 📝 updated instructions ([c3b9fd4](https://github.com/melMass/comfy_mtb/commit/c3b9fd4afedbb46748aef17b40e167a4cfad65f5))
|
||||
|
||||
### Features
|
||||
|
||||
- ✨ update install instructions ([7be37db](https://github.com/melMass/comfy_mtb/commit/7be37dbbfac45e8038f94ced8a2fa8ec2b06fb34))
|
||||
- 🚀 add install script ([dad3966](https://github.com/melMass/comfy_mtb/commit/dad3966ba219c1998e4fc7f6e641864fb0e7c3e8))
|
||||
- 🚧 add my CLIs ([44eaae5](https://github.com/melMass/comfy_mtb/commit/44eaae5c79f4dbec344053d945e7275be5c3c0a5))
|
||||
- ✨comfy_widget shared utils ([91bb95d](https://github.com/melMass/comfy_mtb/commit/91bb95da914468de533b040324700c7f9707e4fb))
|
||||
- 🚀 debug node ([b27b8ef](https://github.com/melMass/comfy_mtb/commit/b27b8ef91fe7335b1df3766547edaf4b9625ae4d))
|
||||
- ✨ add FitNumber node ([aa551eb](https://github.com/melMass/comfy_mtb/commit/aa551ebe57801c69010815119fe21e19a858780c))
|
||||
- 🔥 add API endpoints ([95afbdb](https://github.com/melMass/comfy_mtb/commit/95afbdbf76e66897e632252d876384ada9acf153))
|
||||
- ✨ categorize ([d2b3962](https://github.com/melMass/comfy_mtb/commit/d2b396236a10fe620ebebabd5a22c36159921913))
|
||||
- 🚀 add a few examples ([b9c1d3d](https://github.com/melMass/comfy_mtb/commit/b9c1d3df7a1460fe9ffa84f6f9ea0cfb5409de1a))
|
||||
- ✨ added a way to export the node list ([5f5297f](https://github.com/melMass/comfy_mtb/commit/5f5297f80debc77f3fda2f0d37b3acff8419140d))
|
||||
- ✨ WIP batch from history ([cde7293](https://github.com/melMass/comfy_mtb/commit/cde72938d5ffd09179f5974676e12d4599a8d6ff))
|
||||
- ✨ extract node names using ast ([38f6147](https://github.com/melMass/comfy_mtb/commit/38f61473bc23b4c5d4efc5048d54c059565a6fa0))
|
||||
- 🔥 add batch support for load image sequence ([3faadc4](https://github.com/melMass/comfy_mtb/commit/3faadc4b8a5049cb8c264b8a3d50565adec405f1))
|
||||
- 🎨 add support for image.size(0) == 0 ([629e2b5](https://github.com/melMass/comfy_mtb/commit/629e2b5f5fbebe4e79e8b7a4cff2de6017e79225))
|
||||
- ✨ image feed ([99eb5ae](https://github.com/melMass/comfy_mtb/commit/99eb5ae0c7413f6ab1f24cfc8337c9b1b2d9824c))
|
||||
- ✨ FILM interpolation nodes ([e04e77e](https://github.com/melMass/comfy_mtb/commit/e04e77eb097735ec1369dec51238cdcc5abe39b7))
|
||||
- ✨ add an headless option for model downloads ([217e8a1](https://github.com/melMass/comfy_mtb/commit/217e8a1546d06b97250d99612ac6bdb5ce89e155))
|
||||
- 🐛 support batch count > 1 for restore face ([8ef48a0](https://github.com/melMass/comfy_mtb/commit/8ef48a013a8d6b832b8c0c7dcabc1b78c27ff207))
|
||||
- 🚧 wrapper for GFPGAN bg upscaler ([88cdcc6](https://github.com/melMass/comfy_mtb/commit/88cdcc6a87dae452924e8915eccdadc69d7d136e))
|
||||
- ✨ add GFPGAN (FaceRestore) ([3a6e545](https://github.com/melMass/comfy_mtb/commit/3a6e5450502f3b1d7c505178fc9ba337cd95c39e))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- ✨ before categorize ([0cc54e5](https://github.com/melMass/comfy_mtb/commit/0cc54e58ec86c28354cae37e14f39e831c13ea02))
|
||||
- ✨ add more issue templates ([710a638](https://github.com/melMass/comfy_mtb/commit/710a638a8187ef08254478f684307dccdebcded2)) in [#25](https://github.com/melMass/comfy_mtb/pull/25)
|
||||
- ✨ add bug report template ([f927bc7](https://github.com/melMass/comfy_mtb/commit/f927bc7c9a82951e6df4763433732f20ea87e9cb))
|
||||
- 🍻 create FUNDING.yml ([f634fe0](https://github.com/melMass/comfy_mtb/commit/f634fe0e6b2db28138e4bd7932fbfc8606a0f033))
|
||||
- 🍻 add bmc to readme ([cd1b603](https://github.com/melMass/comfy_mtb/commit/cd1b603565464fe98a718e1fbaa8c7cd84057576))
|
||||
- 📝 extra files from another branch ([b78be8f](https://github.com/melMass/comfy_mtb/commit/b78be8fd3cd36666fd94a3ab08eca11cce526043))
|
||||
- 🚀 push leftovers ([4c41fe7](https://github.com/melMass/comfy_mtb/commit/4c41fe7af9f8e16d895eb06223349e1294dd4698))
|
||||
|
||||
### Refactor
|
||||
|
||||
- ♻️ removes a few nodes, moved other around ([4d8ddac](https://github.com/melMass/comfy_mtb/commit/4d8ddaca320ce483640d030618e70730b3453df2))
|
||||
- ♻️ remove test ([68c250e](https://github.com/melMass/comfy_mtb/commit/68c250e890dacae9f627b0d266ad6dcab0fa0c8b))
|
||||
- 🚧 remove color_widget ([e480d07](https://github.com/melMass/comfy_mtb/commit/e480d071171cffa789930620f1e7ccc76473bf93))
|
||||
|
||||
### Testing
|
||||
|
||||
- 🔧 pipe detection ([ee17d57](https://github.com/melMass/comfy_mtb/commit/ee17d57c3d6d71fda1a5acc2cf85f936c525bc87))
|
||||
|
||||
### Install
|
||||
|
||||
- 🚧 handle symlink errors ([d982b69](https://github.com/melMass/comfy_mtb/commit/d982b69a58c05ccead9c49370764beaa4549992a))
|
||||
|
||||
### Merge
|
||||
|
||||
- 🔀 pull request #22 from melMass/dev/next-release ([c34de0a](https://github.com/melMass/comfy_mtb/commit/c34de0ab351b2c95d7fa4fab4487155bee6bfa3a)) in [#22](https://github.com/melMass/comfy_mtb/pull/22)
|
||||
- 🎉 pull request #11 from dev/frame_interpolation ([1e28606](https://github.com/melMass/comfy_mtb/commit/1e28606427bcc8d895b87eaa6cd4147ab6d9a11f)) in [#11](https://github.com/melMass/comfy_mtb/pull/11)
|
||||
- 🎉 pull request #8 from dev/small-fixes ([7585624](https://github.com/melMass/comfy_mtb/commit/7585624de5895eb34c6a520d4dab18b47e64b6ca)) in [#8](https://github.com/melMass/comfy_mtb/pull/8)
|
||||
|
||||
## [0.0.1] - 2023-06-28
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🤦 add missing file ([e2c4561](https://github.com/melMass/comfy_mtb/commit/e2c456147c260b4e9d583662e3bb9d6d9a019a5e))
|
||||
- ✨ small edits ([bcf55ca](https://github.com/melMass/comfy_mtb/commit/bcf55ca9a3a07067be3319182501f7b635e5d2ba))
|
||||
- ⚡️ add support for batch in roop ([2dae020](https://github.com/melMass/comfy_mtb/commit/2dae02056a11ddfe1f84ee040818028177e404b5))
|
||||
- 🔥 various preparing for the first tag ([793784a](https://github.com/melMass/comfy_mtb/commit/793784a5fd08e8a70d670fc8edbc3bb5b6e13e67))
|
||||
- 🐛 various bugs ([afd0843](https://github.com/melMass/comfy_mtb/commit/afd08431458e3bbb14a25c84a87408113edf5db5))
|
||||
- ⚡️ add missing controls to QRCode ([7e86b0e](https://github.com/melMass/comfy_mtb/commit/7e86b0ed4d300021517f6c5cf28a45012497b5c5))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📝 add rembg screenshot ([9a2d523](https://github.com/melMass/comfy_mtb/commit/9a2d52325f87ecf6342ef4897da919006755b9db))
|
||||
- 📝 add a few screenshots ([e162336](https://github.com/melMass/comfy_mtb/commit/e162336cd366d39cd4b96f05b3c9c68eecec3dc4))
|
||||
- 📝 update readme ([7f3070d](https://github.com/melMass/comfy_mtb/commit/7f3070debbc3330da50ff845621ce299894cf862))
|
||||
|
||||
### Features
|
||||
|
||||
- 💄 faceswap node using roop ([966a14b](https://github.com/melMass/comfy_mtb/commit/966a14b40d88f4fccfb2eaa5ff9b222f0eedd7cb))
|
||||
- ✨ sync local changes ([647bf9e](https://github.com/melMass/comfy_mtb/commit/647bf9e94195c279a620c74c2253471b9c4b90f7))
|
||||
- ✨ bbox from alpha ([37abf8a](https://github.com/melMass/comfy_mtb/commit/37abf8aad12f4711c6d82c6be4be6fa3578e7af5))
|
||||
- ✨ a111 like style loader ([f59b68e](https://github.com/melMass/comfy_mtb/commit/f59b68e3ad92841a4d189d8dddf7b41e915c9b4e))
|
||||
- ✨ add a color type and widget ([9a2e986](https://github.com/melMass/comfy_mtb/commit/9a2e986327c34227a707beab6d9929b0a05e41e6))
|
||||
- ✨ add a few nodes ([811443b](https://github.com/melMass/comfy_mtb/commit/811443b92161815db1cdff81898e8834dcd6fbfa))
|
||||
- ✨ add SadTalker as a submodule ([3fb8716](https://github.com/melMass/comfy_mtb/commit/3fb871651b12bce62d8e911bd3884f417f80c937))
|
||||
- 🚨 push local changes ([6cac344](https://github.com/melMass/comfy_mtb/commit/6cac344f6fb15ebb902acee70ee71edc585ec4bc))
|
||||
- ⚡️ initial commit ([1ae3bbc](https://github.com/melMass/comfy_mtb/commit/1ae3bbc89ae6e0d2e8c61122485bd0df837e17c2))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- 🚀 add gh action ([572b4d5](https://github.com/melMass/comfy_mtb/commit/572b4d52bce1398660d4d7ca0c5c48c11e0128e3)) in [#4](https://github.com/melMass/comfy_mtb/pull/4)
|
||||
|
||||
[main]: https://github.com/melMass/comfy_mtb/compare/v0.1.4..main
|
||||
[0.1.4]: https://github.com/melMass/comfy_mtb/compare/v0.1.3..v0.1.4
|
||||
[0.1.3]: https://github.com/melMass/comfy_mtb/compare/v0.1.2..v0.1.3
|
||||
[0.1.2]: https://github.com/melMass/comfy_mtb/compare/v0.1.1..v0.1.2
|
||||
[0.1.1]: https://github.com/melMass/comfy_mtb/compare/v0.1.0..v0.1.1
|
||||
[0.1.0]: https://github.com/melMass/comfy_mtb/compare/v0.0.1..v0.1.0
|
||||
|
||||
@@ -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(
|
||||
|
||||
+83
@@ -0,0 +1,83 @@
|
||||
[changelog]
|
||||
header = """
|
||||
# Changelog\n
|
||||
This is an automated changelog based on the commits in this repository.
|
||||
|
||||
Check the notes in the [releases](https://github.com/melMass/comfy_mtb/releases) for more information.
|
||||
"""
|
||||
# https://keats.github.io/tera/docs/#introduction
|
||||
body = """
|
||||
{% if version -%}\
|
||||
## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }}
|
||||
{% else %}\
|
||||
## [Unreleased]
|
||||
{% endif -%}\
|
||||
|
||||
{% for group, commits in commits | group_by(attribute="group") %}
|
||||
### {{ group | upper_first }}
|
||||
{% for commit in commits %}
|
||||
- {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message | upper_first | trim }} ([{{ commit.id | truncate(length=7, end="") }}](<REPO>/commit/{{ commit.id }}))\
|
||||
{% if commit.github.username and commit.github.username != remote.github.owner %} by [@{{ commit.github.username }}](https://github.com/{{ commit.github.username }}){%- endif -%}
|
||||
{% if commit.github.pr_number %} in [#{{ commit.github.pr_number }}](<REPO>/pull/{{ commit.github.pr_number }}){%- endif -%}
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
{%- if github.contributors | filter(attribute="is_first_time", value=true) | length != 0 %}
|
||||
## New Contributors
|
||||
{%- endif -%}
|
||||
|
||||
{% for contributor in github.contributors | filter(attribute="is_first_time", value=true) %}
|
||||
* [@{{ contributor.username }}](https://github.com/{{ contributor.username }}) made their first contribution in [#{{ contributor.pr_number }}](<REPO>/pull/{{ contributor.pr_number }})\
|
||||
{%- endfor %}\n
|
||||
"""
|
||||
footer = """
|
||||
{% for release in releases -%}
|
||||
{% if release.version -%}
|
||||
{% if release.previous.version -%}
|
||||
[{{ release.version | trim_start_matches(pat="v") }}]: \
|
||||
<REPO>/compare/{{ release.previous.version }}..{{ release.version }}
|
||||
{% endif -%}
|
||||
{% else -%}
|
||||
[unreleased]: <REPO>/compare/{{ release.previous.version }}..HEAD
|
||||
{% endif -%}
|
||||
{% endfor %}
|
||||
"""
|
||||
trim = true
|
||||
postprocessors = [
|
||||
{ pattern = '<REPO>', replace = "https://github.com/melMass/comfy_mtb" }, # replace repository URL
|
||||
]
|
||||
|
||||
[git]
|
||||
# https://www.conventionalcommits.org
|
||||
conventional_commits = true
|
||||
filter_unconventional = true
|
||||
split_commits = false
|
||||
commit_preprocessors = [
|
||||
# { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](<REPO>/issues/${2}))" }, # replace issue numbers
|
||||
{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "" },
|
||||
]
|
||||
commit_parsers = [
|
||||
{ message = "^feat", group = "Features" },
|
||||
{ message = "^fix", group = "Bug Fixes" },
|
||||
{ message = "^doc", group = "Documentation" },
|
||||
{ message = "^perf", group = "Performance" },
|
||||
{ message = "^refactor", group = "Refactor" },
|
||||
{ message = "^style", group = "Styling" },
|
||||
{ message = "^test", group = "Testing" },
|
||||
{ message = "^chore\\(release\\): prepare for", skip = true },
|
||||
{ message = "^chore\\(deps\\)", skip = true },
|
||||
{ message = "^chore\\(pr\\)", skip = true },
|
||||
{ message = "^chore\\(pull\\)", skip = true },
|
||||
{ message = "^chore|ci", group = "Miscellaneous Tasks" },
|
||||
{ body = ".*security", group = "Security" },
|
||||
{ message = "^revert", group = "Revert" },
|
||||
]
|
||||
protect_breaking_commits = false
|
||||
filter_commits = false
|
||||
tag_pattern = "v[0-9].*"
|
||||
topo_order = false
|
||||
sort_commits = "newest"
|
||||
|
||||
[remote.github]
|
||||
owner = "melMass"
|
||||
repo = "comfy_mtb"
|
||||
+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 @@
|
||||
{"last_node_id":9,"last_link_id":0,"nodes":[{"id":9,"type":"Note Plus (mtb)","pos":[332, 139, 0, 0, 0, 0, 0, 0, 0, 0],"size":[573.4446126650389, 1292.5298919072263],"flags":{},"order":0,"mode":0,"inputs":[],"outputs":[],"title":"Note+ (mtb)","properties":{},"widgets_values":["# Note+ Demo\n\n# Images \nyou can resize them (see showdown syntax)\n\n\n\n# iFrame (embeds)\n<iframe src=\"https://www.youtube.com/embed/tgbNymZ7vqY\">\n</iframe>\n\n# Headings\n\n# h1 Heading:smile:\n\n## h2 Heading\n\n### h3 Heading\n\n#### h4 Heading\n\n##### h5 Heading\n\n###### h6 Heading\n\n# Tables\n\nColons can be used to align columns.\n\n| Tables|Are|Cool |\n| ------------- |:-----------:| ----:|\n| col 3 is| right-aligned | $1600 |\n| col 2 is| centered| $12 |\n| zebra stripes | are neat|$1 |\n\nEmphasis, aka italics, with _asterisks_ or _underscores_.\n\nStrong emphasis, aka bold, with **asterisks** or **underscores**.\n\nCombined emphasis with **asterisks and _underscores_**.\n\nStrikethrough uses two tildes. ~~Scratch this.~~\n\n**This is bold text**\n\n**This is bold text**\n\n_This is italic text_\n\n_This is italic text_\n\n~~Strikethrough~~\n\n1. First ordered list item\n2. Another item\n\n- Unordered sub-list.\n\n1. Actual numbers don't matter, just that it's a number\n1. Ordered sub-list\n1. And another item.\n1.\n\n- [x] Finish my changes\n- [] Push my commits to GitHub\n- [] Open a pull request\n- [x] mentions:@melmass, #refs, [links](), **formatting**, and <del>tags</del> supported\n- [x] list syntax required (any unordered or ordered list supported)\n- [x] this is a complete item\n- [] this is an incomplete item\n","markdown","*{\ncolor:whitesmoke;\n}\n\nh1{\ncolor:cyan;\n}\nh2{\ncolor:yellow;\n}\nh3{\ncolor:pink;\n}\n\nstrong{\ncolor:red;\n}"],"color":"#223","bgcolor":"#335","shape":1}],"links":[],"groups":[],"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"
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
"""MTB Nodes module."""
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@ from ..log import log
|
||||
|
||||
|
||||
class AnimationBuilder:
|
||||
"""Convenient way to manage basic animation maths at the core of many of my workflows"""
|
||||
"""Simple maths for animation."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -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"
|
||||
|
||||
+776
@@ -0,0 +1,776 @@
|
||||
from io import BytesIO
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
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),)
|
||||
|
||||
|
||||
class PlotBatchFloat:
|
||||
"""Plot floats"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 768}),
|
||||
"height": ("INT", {"default": 768}),
|
||||
"point_size": ("INT", {"default": 4}),
|
||||
"seed": ("INT", {"default": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("plot",)
|
||||
FUNCTION = "plot"
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def plot(self, width, height, point_size, seed, **kwargs):
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("Agg")
|
||||
import matplotlib.pyplot as plt
|
||||
|
||||
fig, ax = plt.subplots(figsize=(width / 100, height / 100), dpi=100)
|
||||
fig.set_edgecolor("black")
|
||||
fig.patch.set_facecolor("#2e2e2e")
|
||||
# Setting background color and grid
|
||||
ax.set_facecolor("#2e2e2e") # Dark gray background
|
||||
ax.grid(color="gray", linestyle="-", linewidth=0.5, alpha=0.5)
|
||||
|
||||
# Finding global min and max across all lists for scaling the plot
|
||||
global_min = min(min(values) for values in kwargs.values())
|
||||
global_max = max(max(values) for values in kwargs.values())
|
||||
|
||||
# Color cycle to ensure each plot has a distinct color
|
||||
colormap = plt.cm.get_cmap("viridis", len(kwargs))
|
||||
color_normalization_factor = (
|
||||
0.5 if len(kwargs) == 1 else (len(kwargs) - 1)
|
||||
)
|
||||
|
||||
# Plotting each list with a unique color
|
||||
for i, (label, values) in enumerate(kwargs.items()):
|
||||
color_value = i / color_normalization_factor
|
||||
ax.plot(values, label=label, color=colormap(color_value))
|
||||
|
||||
ax.set_ylim(global_min, global_max) # Scaling the y-axis
|
||||
ax.legend(
|
||||
title="Legend",
|
||||
title_fontsize="large",
|
||||
fontsize="medium",
|
||||
edgecolor="black",
|
||||
)
|
||||
|
||||
# Setting labels and title
|
||||
ax.set_xlabel("Time", fontsize="large", color="white")
|
||||
ax.set_ylabel("Value", fontsize="large", color="white")
|
||||
ax.set_title(
|
||||
"Plot of Values over Time", fontsize="x-large", color="white"
|
||||
)
|
||||
|
||||
# Adjusting tick colors to be visible on dark background
|
||||
ax.tick_params(colors="white")
|
||||
|
||||
# Changing color of the axes border
|
||||
for _, spine in ax.spines.items():
|
||||
spine.set_edgecolor("white")
|
||||
|
||||
# Rendering the plot into a NumPy array
|
||||
buf = BytesIO()
|
||||
plt.savefig(buf, format="png", bbox_inches="tight")
|
||||
buf.seek(0)
|
||||
image = Image.open(buf)
|
||||
plt.close(fig) # Closing the figure to free up memory
|
||||
|
||||
return (pil2tensor(image),)
|
||||
|
||||
def draw_point(self, image, point, color, point_size):
|
||||
x, y = point
|
||||
y = image.shape[0] - 1 - y # Invert Y-coordinate
|
||||
half_size = point_size // 2
|
||||
x_start, x_end = (
|
||||
max(0, x - half_size),
|
||||
min(image.shape[1], x + half_size + 1),
|
||||
)
|
||||
y_start, y_end = (
|
||||
max(0, y - half_size),
|
||||
min(image.shape[0], y + half_size + 1),
|
||||
)
|
||||
image[y_start:y_end, x_start:x_end] = color
|
||||
|
||||
def draw_line(self, image, start, end, color):
|
||||
x1, y1 = start
|
||||
x2, y2 = end
|
||||
|
||||
# Invert Y-coordinate
|
||||
y1 = image.shape[0] - 1 - y1
|
||||
y2 = image.shape[0] - 1 - y2
|
||||
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
is_steep = abs(dy) > abs(dx)
|
||||
if is_steep:
|
||||
x1, y1 = y1, x1
|
||||
x2, y2 = y2, x2
|
||||
swapped = False
|
||||
if x1 > x2:
|
||||
x1, x2 = x2, x1
|
||||
y1, y2 = y2, y1
|
||||
swapped = True
|
||||
dx = x2 - x1
|
||||
dy = y2 - y1
|
||||
error = int(dx / 2.0)
|
||||
y = y1
|
||||
ystep = None
|
||||
if y1 < y2:
|
||||
ystep = 1
|
||||
else:
|
||||
ystep = -1
|
||||
for x in range(x1, x2 + 1):
|
||||
coord = (y, x) if is_steep else (x, y)
|
||||
image[coord] = color
|
||||
error -= abs(dy)
|
||||
if error < 0:
|
||||
y += ystep
|
||||
error += dx
|
||||
if swapped:
|
||||
image[(x1, y1)] = color
|
||||
image[(x2, y2)] = color
|
||||
|
||||
|
||||
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,
|
||||
PlotBatchFloat,
|
||||
]
|
||||
+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,
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import json
|
||||
|
||||
|
||||
def deserialize_curve(curve):
|
||||
if isinstance(curve, str):
|
||||
curve = json.loads(curve)
|
||||
return curve
|
||||
|
||||
|
||||
def serialize_curve(curve):
|
||||
if not isinstance(curve, str):
|
||||
curve = json.dumps(curve)
|
||||
return curve
|
||||
|
||||
|
||||
class MTB_Curve:
|
||||
"""A basic FLOAT_CURVE input node."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"curve": ("FLOAT_CURVE",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("FLOAT_CURVE",)
|
||||
FUNCTION = "do_curve"
|
||||
|
||||
CATEGORY = "mtb/curve"
|
||||
|
||||
def do_curve(self, curve):
|
||||
return (curve,)
|
||||
|
||||
|
||||
__nodes__ = [MTB_Curve]
|
||||
+99
-37
@@ -1,64 +1,125 @@
|
||||
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(
|
||||
"List of List of Tensors: "
|
||||
f"{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:
|
||||
"""Experimental node to debug any Comfy values, support for more types and widgets is planned"""
|
||||
"""Experimental node to debug any Comfy values.
|
||||
|
||||
support for more types and widgets is planned.
|
||||
"""
|
||||
|
||||
@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)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
class SaveTensors:
|
||||
"""Save torch tensors (image, mask or latent) to disk, useful to debug things outside comfy"""
|
||||
"""Save torch tensors (image, mask or latent) to disk.
|
||||
|
||||
useful to debug things outside comfy.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
@@ -113,7 +174,8 @@ class SaveTensors:
|
||||
torch.save(latent, full_output_folder / latent_file)
|
||||
# pickle.dump(latent, open(full_output_folder/ latent_file, "wb"))
|
||||
|
||||
# np.save(full_output_folder/ latent_file, latent[""].cpu().numpy())
|
||||
# np.save(full_output_folder / latent_file,
|
||||
# latent[""].cpu().numpy())
|
||||
|
||||
return f"{filename_prefix}_{counter:05}"
|
||||
|
||||
|
||||
+136
-49
@@ -1,23 +1,49 @@
|
||||
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):
|
||||
"""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'.
|
||||
def color_to_normals(
|
||||
color_img, overlap, progress_callback, *, save_temp=False
|
||||
):
|
||||
"""Compute 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(
|
||||
"Converting color image to grayscale by taking "
|
||||
f"the mean over color channels: {img.shape}"
|
||||
)
|
||||
|
||||
# Split image in tiles
|
||||
log.debug("DeepBump Color → Normals : tilling")
|
||||
@@ -28,72 +54,105 @@ 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(
|
||||
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
|
||||
|
||||
|
||||
# - NORMALS to CURVATURE
|
||||
def conv_1d(array, kernel_1d):
|
||||
"""Performs row by row 1D convolutions of the given 2D image with the given 1D kernel."""
|
||||
"""Perform row by row 1D convolutions.
|
||||
|
||||
of the given 2D image with the given 1D kernel.
|
||||
"""
|
||||
# Input kernel length must be odd
|
||||
k_l = len(kernel_1d)
|
||||
|
||||
assert k_l % 2 != 0
|
||||
# Convolution is repeat-padded
|
||||
extended = np.pad(array, k_l // 2, mode="wrap")
|
||||
# Output has same size as input (padded, valid-mode convolution)
|
||||
output = np.empty(array.shape)
|
||||
for i in range(array.shape[0]):
|
||||
output[i] = np.convolve(extended[i + (k_l // 2)], kernel_1d, mode="valid")
|
||||
output[i] = np.convolve(
|
||||
extended[i + (k_l // 2)], kernel_1d, mode="valid"
|
||||
)
|
||||
|
||||
return output * -1
|
||||
|
||||
|
||||
def gaussian_kernel(length, sigma):
|
||||
"""Returns a 1D gaussian kernel of size 'length'."""
|
||||
|
||||
"""Return a 1D gaussian kernel of size 'length'."""
|
||||
space = np.linspace(-(length - 1) / 2, (length - 1) / 2, length)
|
||||
kernel = np.exp(-0.5 * np.square(space) / np.square(sigma))
|
||||
return kernel / np.sum(kernel)
|
||||
|
||||
|
||||
def normalize(np_array):
|
||||
"""Normalize all elements of the given numpy array to [0,1]"""
|
||||
|
||||
return (np_array - np.min(np_array)) / (np.max(np_array) - np.min(np_array))
|
||||
"""Normalize all elements of the given numpy array to [0,1]."""
|
||||
return (np_array - np.min(np_array)) / (
|
||||
np.max(np_array) - np.min(np_array)
|
||||
)
|
||||
|
||||
|
||||
def normals_to_curvature(normals_img, blur_radius, progress_callback):
|
||||
"""Computes a curvature map from the given normal map. 'normals_img' must be a numpy array
|
||||
in C,H,W format (with C as RGB). 'blur_radius' must be one of 'SMALLEST', 'SMALLER', 'SMALL',
|
||||
'MEDIUM', 'LARGE', 'LARGER', 'LARGEST'."""
|
||||
"""Compute a curvature map from the given normal map.
|
||||
|
||||
'normals_img' must be a numpy array in C,H,W format (with C as RGB).
|
||||
'blur_radius' must be one of:
|
||||
'SMALLEST', 'SMALLER', 'SMALL', 'MEDIUM', 'LARGE', 'LARGER', 'LARGEST'.
|
||||
"""
|
||||
# Convolutions on normal map red & green channels
|
||||
if progress_callback is not None:
|
||||
progress_callback(0, 4)
|
||||
@@ -118,8 +177,12 @@ def normals_to_curvature(normals_img, blur_radius, progress_callback):
|
||||
"LARGER": 1 / 8,
|
||||
"LARGEST": 1 / 4,
|
||||
}
|
||||
assert blur_radius in blur_factors
|
||||
blur_radius_px = int(np.mean(normals_img.shape[1:3]) * blur_factors[blur_radius])
|
||||
if blur_radius not in blur_factors:
|
||||
raise ValueError(f"{blur_radius} not found in {blur_factors}")
|
||||
|
||||
blur_radius_px = int(
|
||||
np.mean(normals_img.shape[1:3]) * blur_factors[blur_radius]
|
||||
)
|
||||
|
||||
# If blur radius too small, do not blur
|
||||
if blur_radius_px < 2:
|
||||
@@ -156,8 +219,9 @@ def normals_to_grad(normals_img):
|
||||
|
||||
def copy_flip(grad_x, grad_y):
|
||||
"""Concat 4 flipped copies of input gradients (makes them wrap).
|
||||
Output is twice bigger in both dimensions."""
|
||||
|
||||
Output is twice bigger in both dimensions.
|
||||
"""
|
||||
grad_x_top = np.hstack([grad_x, -np.flip(grad_x, axis=1)])
|
||||
grad_x_bottom = np.hstack([np.flip(grad_x, axis=0), -np.flip(grad_x)])
|
||||
new_grad_x = np.vstack([grad_x_top, grad_x_bottom])
|
||||
@@ -171,7 +235,6 @@ def copy_flip(grad_x, grad_y):
|
||||
|
||||
def frankot_chellappa(grad_x, grad_y, progress_callback=None):
|
||||
"""Frankot-Chellappa depth-from-gradient algorithm."""
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(0, 3)
|
||||
|
||||
@@ -211,8 +274,8 @@ def frankot_chellappa(grad_x, grad_y, progress_callback=None):
|
||||
def normals_to_height(normals_img, seamless, progress_callback):
|
||||
"""Computes a height map from the given normal map. 'normals_img' must be a numpy array
|
||||
in C,H,W format (with C as RGB). 'seamless' is a bool that should indicates if 'normals_img'
|
||||
is seamless."""
|
||||
|
||||
is seamless.
|
||||
"""
|
||||
# Flip height axis
|
||||
flip_img = np.flip(normals_img, axis=1)
|
||||
|
||||
@@ -226,7 +289,9 @@ def normals_to_height(normals_img, seamless, progress_callback):
|
||||
grad_x, grad_y = copy_flip(grad_x, grad_y)
|
||||
|
||||
# Compute height
|
||||
pred_img = frankot_chellappa(-grad_x, grad_y, progress_callback=progress_callback)
|
||||
pred_img = frankot_chellappa(
|
||||
-grad_x, grad_y, progress_callback=progress_callback
|
||||
)
|
||||
|
||||
# Cut to valid part if gradients were expanded
|
||||
if not seamless:
|
||||
@@ -247,7 +312,11 @@ class DeepBump:
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"mode": (
|
||||
["Color to Normals", "Normals to Curvature", "Normals to Height"],
|
||||
[
|
||||
"Color to Normals",
|
||||
"Normals to Curvature",
|
||||
"Normals to Height",
|
||||
],
|
||||
),
|
||||
"color_to_normals_overlap": (["SMALL", "MEDIUM", "LARGE"],),
|
||||
"normals_to_curvature_blur_radius": (
|
||||
@@ -261,7 +330,7 @@ class DeepBump:
|
||||
"LARGEST",
|
||||
],
|
||||
),
|
||||
"normals_to_height_seamless": ("BOOL", {"default": False}),
|
||||
"normals_to_height_seamless": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -272,31 +341,49 @@ class DeepBump:
|
||||
|
||||
def apply(
|
||||
self,
|
||||
*,
|
||||
image,
|
||||
mode="Color to Normals",
|
||||
color_to_normals_overlap="SMALL",
|
||||
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]
|
||||
|
||||
+72
-31
@@ -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,9 +94,13 @@ 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
|
||||
arch="clean"
|
||||
if basic
|
||||
else "RestoreFormer", # or original for v1.0 only
|
||||
channel_multiplier=2, # 1 for v1.0 only
|
||||
bg_upsampler=bg_upsampler,
|
||||
)
|
||||
@@ -98,7 +124,11 @@ class BGUpscaleWrapper:
|
||||
imgt = imgt.movedim(-1, -3).to(device)
|
||||
|
||||
steps = imgt.shape[0] * comfy.utils.get_tiled_scale_steps(
|
||||
imgt.shape[3], imgt.shape[2], tile_x=tile, tile_y=tile, overlap=overlap
|
||||
imgt.shape[3],
|
||||
imgt.shape[2],
|
||||
tile_x=tile,
|
||||
tile_y=tile,
|
||||
overlap=overlap,
|
||||
)
|
||||
|
||||
log.debug(f"Steps: {steps}")
|
||||
@@ -140,12 +170,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}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,10 +205,14 @@ class RestoreFace:
|
||||
log.warning(f"Weight value has no effect for now. (value: {weight})")
|
||||
|
||||
if save_tmp_steps:
|
||||
self.save_intermediate_images(cropped_faces, restored_faces, height, width)
|
||||
self.save_intermediate_images(
|
||||
cropped_faces, restored_faces, height, width
|
||||
)
|
||||
output = None
|
||||
if restored_img is not None:
|
||||
output = Image.fromarray(cv2.cvtColor(restored_img, cv2.COLOR_BGR2RGB))
|
||||
output = Image.fromarray(
|
||||
cv2.cvtColor(restored_img, cv2.COLOR_BGR2RGB)
|
||||
)
|
||||
# imwrite(restored_img, save_restore_path)
|
||||
|
||||
return pil2tensor(output)
|
||||
@@ -194,7 +228,12 @@ class RestoreFace:
|
||||
) -> Tuple[torch.Tensor]:
|
||||
out = [
|
||||
self.do_restore(
|
||||
image[i], model, aligned, only_center_face, weight, save_tmp_steps
|
||||
image[i],
|
||||
model,
|
||||
aligned,
|
||||
only_center_face,
|
||||
weight,
|
||||
save_tmp_steps,
|
||||
)
|
||||
for i in range(image.size(0))
|
||||
]
|
||||
@@ -216,22 +255,24 @@ class RestoreFace:
|
||||
|
||||
return os.path.join(full_output_folder, file)
|
||||
|
||||
def save_intermediate_images(self, cropped_faces, restored_faces, height, width):
|
||||
def save_intermediate_images(
|
||||
self, cropped_faces, restored_faces, height, width
|
||||
):
|
||||
for idx, (cropped_face, restored_face) in enumerate(
|
||||
zip(cropped_faces, restored_faces)
|
||||
):
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import torch
|
||||
|
||||
|
||||
class MTB_FilterZ:
|
||||
"""Filters an image based on a depth map"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"depth": ("IMAGE",),
|
||||
"to_black": ("BOOLEAN", {"default": True}),
|
||||
"threshold": (
|
||||
"FLOAT",
|
||||
{"default": 0.5, "step": 0.01, "min": 0.0, "max": 1.0},
|
||||
),
|
||||
"invert": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "filter"
|
||||
CATEGORY = "mtb/filters"
|
||||
|
||||
def filter(
|
||||
self,
|
||||
image: torch.Tensor,
|
||||
depth: torch.Tensor,
|
||||
to_black,
|
||||
threshold: float,
|
||||
invert,
|
||||
):
|
||||
# Normalize depth map to be in range [0, 1]
|
||||
depth_normalized = (depth - depth.min()) / (depth.max() - depth.min())
|
||||
|
||||
# Calculate the difference from the threshold
|
||||
diff_from_threshold = torch.abs(depth_normalized - threshold)
|
||||
|
||||
out_img = None
|
||||
|
||||
if to_black:
|
||||
if invert:
|
||||
soft_mask = diff_from_threshold >= threshold
|
||||
else:
|
||||
soft_mask = diff_from_threshold <= threshold
|
||||
|
||||
out_img = image.clone()
|
||||
out_img[soft_mask] = 0
|
||||
return (out_img,)
|
||||
else:
|
||||
alpha_channel = 1 - diff_from_threshold / threshold
|
||||
alpha_channel = torch.clamp(alpha_channel, 0, 1)
|
||||
|
||||
if invert:
|
||||
# Invert the alpha channel
|
||||
alpha_channel = 1 - alpha_channel
|
||||
|
||||
# Ensure alpha_channel has the correct shape
|
||||
# It should have the shape [batch_size, height, width, 1]
|
||||
alpha_channel = alpha_channel.unsqueeze(-1)
|
||||
|
||||
# Combine RGB channels with alpha channel
|
||||
out_img = torch.cat((image, alpha_channel), dim=-1)
|
||||
|
||||
return (out_img,)
|
||||
|
||||
|
||||
__nodes__ = [MTB_FilterZ]
|
||||
-166
@@ -1,166 +0,0 @@
|
||||
import qrcode
|
||||
from ..utils import pil2tensor
|
||||
from PIL import Image
|
||||
from ..log import log
|
||||
|
||||
# class MtbExamples:
|
||||
# """MTB Example Images"""
|
||||
|
||||
# def __init__(self):
|
||||
# pass
|
||||
|
||||
# @classmethod
|
||||
# @lru_cache(maxsize=1)
|
||||
# def get_root(cls):
|
||||
# return here / "examples" / "samples"
|
||||
|
||||
# @classmethod
|
||||
# def INPUT_TYPES(cls):
|
||||
# input_dir = cls.get_root()
|
||||
# files = [f.name for f in input_dir.iterdir() if f.is_file()]
|
||||
# return {
|
||||
# "required": {"image": (sorted(files),)},
|
||||
# }
|
||||
|
||||
# RETURN_TYPES = ("IMAGE", "MASK")
|
||||
# FUNCTION = "do_mtb_examples"
|
||||
# CATEGORY = "fun"
|
||||
|
||||
# def do_mtb_examples(self, image, index):
|
||||
# image_path = (self.get_root() / image).as_posix()
|
||||
|
||||
# i = Image.open(image_path)
|
||||
# i = ImageOps.exif_transpose(i)
|
||||
# image = i.convert("RGB")
|
||||
# image = np.array(image).astype(np.float32) / 255.0
|
||||
# image = torch.from_numpy(image)[None,]
|
||||
# if "A" in i.getbands():
|
||||
# mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0
|
||||
# mask = 1.0 - torch.from_numpy(mask)
|
||||
# else:
|
||||
# mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
|
||||
# return (image, mask)
|
||||
|
||||
# @classmethod
|
||||
# def IS_CHANGED(cls, image):
|
||||
# image_path = (cls.get_root() / image).as_posix()
|
||||
|
||||
# m = hashlib.sha256()
|
||||
# with open(image_path, "rb") as f:
|
||||
# m.update(f.read())
|
||||
# return m.digest().hex()
|
||||
|
||||
|
||||
class UnsplashImage:
|
||||
"""Unsplash Image given a keyword and a size"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": ("INT", {"default": 512, "max": 8096, "min": 0, "step": 1}),
|
||||
"height": ("INT", {"default": 512, "max": 8096, "min": 0, "step": 1}),
|
||||
"random_seed": ("INT", {"default": 0, "max": 1e5, "min": 0, "step": 1}),
|
||||
},
|
||||
"optional": {
|
||||
"keyword": ("STRING", {"default": "nature"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "do_unsplash_image"
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
def do_unsplash_image(self, width, height, random_seed, keyword=None):
|
||||
import requests
|
||||
import io
|
||||
|
||||
base_url = "https://source.unsplash.com/random/"
|
||||
|
||||
if width and height:
|
||||
base_url += f"/{width}x{height}"
|
||||
|
||||
if keyword:
|
||||
keyword = keyword.replace(" ", "%20")
|
||||
base_url += f"?{keyword}&{random_seed}"
|
||||
else:
|
||||
base_url += f"?&{random_seed}"
|
||||
try:
|
||||
log.debug(f"Getting unsplash image from {base_url}")
|
||||
response = requests.get(base_url)
|
||||
response.raise_for_status()
|
||||
|
||||
image = Image.open(io.BytesIO(response.content))
|
||||
return (
|
||||
pil2tensor(
|
||||
image,
|
||||
),
|
||||
)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print("Error retrieving image:", e)
|
||||
return (None,)
|
||||
|
||||
|
||||
class QrCode:
|
||||
"""Basic QR Code generator"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"url": ("STRING", {"default": "https://www.github.com"}),
|
||||
"width": (
|
||||
"INT",
|
||||
{"default": 256, "max": 8096, "min": 0, "step": 1},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{"default": 256, "max": 8096, "min": 0, "step": 1},
|
||||
),
|
||||
"error_correct": (("L", "M", "Q", "H"), {"default": "L"}),
|
||||
"box_size": ("INT", {"default": 10, "max": 8096, "min": 0, "step": 1}),
|
||||
"border": ("INT", {"default": 4, "max": 8096, "min": 0, "step": 1}),
|
||||
"invert": (("BOOL",), {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "do_qr"
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
def do_qr(self, url, width, height, error_correct, box_size, border, invert):
|
||||
if error_correct == "L" or error_correct not in ["M", "Q", "H"]:
|
||||
error_correct = qrcode.constants.ERROR_CORRECT_L
|
||||
elif error_correct == "M":
|
||||
error_correct = qrcode.constants.ERROR_CORRECT_M
|
||||
elif error_correct == "Q":
|
||||
error_correct = qrcode.constants.ERROR_CORRECT_Q
|
||||
else:
|
||||
error_correct = qrcode.constants.ERROR_CORRECT_H
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=error_correct,
|
||||
box_size=box_size,
|
||||
border=border,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
back_color = (255, 255, 255) if invert else (0, 0, 0)
|
||||
fill_color = (0, 0, 0) if invert else (255, 255, 255)
|
||||
|
||||
code = img = qr.make_image(back_color=back_color, fill_color=fill_color)
|
||||
|
||||
# that we now resize without filtering
|
||||
code = code.resize((width, height), Image.NEAREST)
|
||||
|
||||
return (pil2tensor(code),)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
QrCode,
|
||||
UnsplashImage
|
||||
# MtbExamples,
|
||||
]
|
||||
@@ -0,0 +1,364 @@
|
||||
import qrcode
|
||||
from PIL import Image
|
||||
|
||||
from ..log import log
|
||||
from ..utils import comfy_dir, font_path, pil2tensor
|
||||
|
||||
# class MtbExamples:
|
||||
# """MTB Example Images"""
|
||||
|
||||
# def __init__(self):
|
||||
# pass
|
||||
|
||||
# @classmethod
|
||||
# @lru_cache(maxsize=1)
|
||||
# def get_root(cls):
|
||||
# return here / "examples" / "samples"
|
||||
|
||||
# @classmethod
|
||||
# def INPUT_TYPES(cls):
|
||||
# input_dir = cls.get_root()
|
||||
# files = [f.name for f in input_dir.iterdir() if f.is_file()]
|
||||
# return {
|
||||
# "required": {"image": (sorted(files),)},
|
||||
# }
|
||||
|
||||
# RETURN_TYPES = ("IMAGE", "MASK")
|
||||
# FUNCTION = "do_mtb_examples"
|
||||
# CATEGORY = "fun"
|
||||
|
||||
# def do_mtb_examples(self, image, index):
|
||||
# image_path = (self.get_root() / image).as_posix()
|
||||
|
||||
# i = Image.open(image_path)
|
||||
# i = ImageOps.exif_transpose(i)
|
||||
# image = i.convert("RGB")
|
||||
# image = np.array(image).astype(np.float32) / 255.0
|
||||
# image = torch.from_numpy(image)[None,]
|
||||
# if "A" in i.getbands():
|
||||
# mask = np.array(i.getchannel("A")).astype(np.float32) / 255.0
|
||||
# mask = 1.0 - torch.from_numpy(mask)
|
||||
# else:
|
||||
# mask = torch.zeros((64, 64), dtype=torch.float32, device="cpu")
|
||||
# return (image, mask)
|
||||
|
||||
# @classmethod
|
||||
# def IS_CHANGED(cls, image):
|
||||
# image_path = (cls.get_root() / image).as_posix()
|
||||
|
||||
# m = hashlib.sha256()
|
||||
# with open(image_path, "rb") as f:
|
||||
# m.update(f.read())
|
||||
# return m.digest().hex()
|
||||
|
||||
|
||||
class UnsplashImage:
|
||||
"""Unsplash Image given a keyword and a size"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"width": (
|
||||
"INT",
|
||||
{"default": 512, "max": 8096, "min": 0, "step": 1},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{"default": 512, "max": 8096, "min": 0, "step": 1},
|
||||
),
|
||||
"random_seed": (
|
||||
"INT",
|
||||
{"default": 0, "max": 1e5, "min": 0, "step": 1},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"keyword": ("STRING", {"default": "nature"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "do_unsplash_image"
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
def do_unsplash_image(self, width, height, random_seed, keyword=None):
|
||||
import io
|
||||
|
||||
import requests
|
||||
|
||||
base_url = "https://source.unsplash.com/random/"
|
||||
|
||||
if width and height:
|
||||
base_url += f"/{width}x{height}"
|
||||
|
||||
if keyword:
|
||||
keyword = keyword.replace(" ", "%20")
|
||||
base_url += f"?{keyword}&{random_seed}"
|
||||
else:
|
||||
base_url += f"?&{random_seed}"
|
||||
try:
|
||||
log.debug(f"Getting unsplash image from {base_url}")
|
||||
response = requests.get(base_url)
|
||||
response.raise_for_status()
|
||||
|
||||
image = Image.open(io.BytesIO(response.content))
|
||||
return (
|
||||
pil2tensor(
|
||||
image,
|
||||
),
|
||||
)
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
print("Error retrieving image:", e)
|
||||
return (None,)
|
||||
|
||||
|
||||
class QrCode:
|
||||
"""Basic QR Code generator"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"url": ("STRING", {"default": "https://www.github.com"}),
|
||||
"width": (
|
||||
"INT",
|
||||
{"default": 256, "max": 8096, "min": 0, "step": 1},
|
||||
),
|
||||
"height": (
|
||||
"INT",
|
||||
{"default": 256, "max": 8096, "min": 0, "step": 1},
|
||||
),
|
||||
"error_correct": (("L", "M", "Q", "H"), {"default": "L"}),
|
||||
"box_size": (
|
||||
"INT",
|
||||
{"default": 10, "max": 8096, "min": 0, "step": 1},
|
||||
),
|
||||
"border": (
|
||||
"INT",
|
||||
{"default": 4, "max": 8096, "min": 0, "step": 1},
|
||||
),
|
||||
"invert": (("BOOLEAN",), {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "do_qr"
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
def do_qr(
|
||||
self, url, width, height, error_correct, box_size, border, invert
|
||||
):
|
||||
log.warning(
|
||||
"This node will soon be deprecated, there are much better alternatives like https://github.com/coreyryanhanson/comfy-qr"
|
||||
)
|
||||
if error_correct == "L" or error_correct not in ["M", "Q", "H"]:
|
||||
error_correct = qrcode.constants.ERROR_CORRECT_L
|
||||
elif error_correct == "M":
|
||||
error_correct = qrcode.constants.ERROR_CORRECT_M
|
||||
elif error_correct == "Q":
|
||||
error_correct = qrcode.constants.ERROR_CORRECT_Q
|
||||
else:
|
||||
error_correct = qrcode.constants.ERROR_CORRECT_H
|
||||
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=error_correct,
|
||||
box_size=box_size,
|
||||
border=border,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
back_color = (255, 255, 255) if invert else (0, 0, 0)
|
||||
fill_color = (0, 0, 0) if invert else (255, 255, 255)
|
||||
|
||||
code = img = qr.make_image(
|
||||
back_color=back_color, fill_color=fill_color
|
||||
)
|
||||
|
||||
# that we now resize without filtering
|
||||
code = code.resize((width, height), Image.NEAREST)
|
||||
|
||||
return (pil2tensor(code),)
|
||||
|
||||
|
||||
def bbox_dim(bbox):
|
||||
left, upper, right, lower = bbox
|
||||
width = right - left
|
||||
height = lower - upper
|
||||
return width, height
|
||||
|
||||
|
||||
# TODO: Auto install the base font to ComfyUI/fonts
|
||||
|
||||
|
||||
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 = [font_path]
|
||||
|
||||
for extension in font_extensions:
|
||||
try:
|
||||
if comfy_dir.exists():
|
||||
fonts.extend(comfy_dir.glob(f"fonts/**/{extension}"))
|
||||
else:
|
||||
log.warn(f"Directory {comfy_dir} does not exist.")
|
||||
except Exception as e:
|
||||
log.error(f"Error during font caching: {e}")
|
||||
|
||||
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:
|
||||
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": ("BOOLEAN", {"default": True}),
|
||||
"trim": ("BOOLEAN", {"default": True}),
|
||||
"line_height": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0, "step": 0.1},
|
||||
),
|
||||
"font_size": (
|
||||
"INT",
|
||||
{"default": 32, "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"}),
|
||||
"h_offset": (
|
||||
"INT",
|
||||
{"default": 0, "min": 0, "max": 8096, "step": 1},
|
||||
),
|
||||
"v_offset": (
|
||||
"INT",
|
||||
{"default": 0, "min": 0, "max": 8096, "step": 1},
|
||||
),
|
||||
"h_coverage": (
|
||||
"INT",
|
||||
{"default": 100, "min": 1, "max": 100, "step": 1},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "text_to_image"
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
def text_to_image(
|
||||
self,
|
||||
text: str,
|
||||
font,
|
||||
wrap,
|
||||
trim,
|
||||
line_height,
|
||||
font_size,
|
||||
width,
|
||||
height,
|
||||
color,
|
||||
background,
|
||||
h_align="left",
|
||||
v_align="top",
|
||||
h_offset=0,
|
||||
v_offset=0,
|
||||
h_coverage=100,
|
||||
):
|
||||
import textwrap
|
||||
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
font_path = self.fonts[font]
|
||||
|
||||
text = (
|
||||
text.encode("ascii", "ignore").decode().strip() if trim else text
|
||||
)
|
||||
# Handle word wrapping
|
||||
if wrap:
|
||||
wrap_width = (((width / 100) * h_coverage) / font_size) * 2
|
||||
lines = textwrap.wrap(text, width=wrap_width)
|
||||
else:
|
||||
lines = [text]
|
||||
font = ImageFont.truetype(font_path, size=font_size)
|
||||
log.debug(f"Lines: {lines}")
|
||||
img = Image.new("RGBA", (width, height), background)
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
line_height_px = line_height * font_size
|
||||
|
||||
# Vertical alignment
|
||||
if v_align == "top":
|
||||
y_text = v_offset
|
||||
elif v_align == "center":
|
||||
y_text = ((height - (line_height_px * len(lines))) // 2) + v_offset
|
||||
else: # bottom
|
||||
y_text = (height - (line_height_px * len(lines))) - v_offset
|
||||
|
||||
def get_width(line):
|
||||
if hasattr(font, "getsize"):
|
||||
return font.getsize(line)[0]
|
||||
else:
|
||||
return font.getlength(line)
|
||||
|
||||
# Draw each line of text
|
||||
for line in lines:
|
||||
line_width = get_width(line)
|
||||
# Horizontal alignment
|
||||
if h_align == "left":
|
||||
x_text = h_offset
|
||||
elif h_align == "center":
|
||||
x_text = ((width - line_width) // 2) + h_offset
|
||||
else: # right
|
||||
x_text = (width - line_width) - h_offset
|
||||
|
||||
draw.text((x_text, y_text), line, fill=color, font=font)
|
||||
y_text += line_height_px
|
||||
|
||||
return (pil2tensor(img),)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
QrCode,
|
||||
UnsplashImage,
|
||||
TextToImage,
|
||||
# MtbExamples,
|
||||
]
|
||||
+347
-15
@@ -1,8 +1,225 @@
|
||||
from typing import Optional
|
||||
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 MTB_ToDevice:
|
||||
"""Send a image or mask tensor to the given device."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
devices = ["cpu"]
|
||||
if torch.backends.mps.is_available():
|
||||
devices.append("mps")
|
||||
if torch.cuda.is_available():
|
||||
for i in range(torch.cuda.device_count()):
|
||||
devices.append(f"cuda{i}")
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"ignore_errors": ("BOOLEAN", {"default": False}),
|
||||
"device": (devices, {"default": "cpu"}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_NAMES = ("images", "masks")
|
||||
CATEGORY = "mtb/utils"
|
||||
FUNCTION = "to_device"
|
||||
|
||||
def to_device(
|
||||
self,
|
||||
*,
|
||||
ignore_errors=False,
|
||||
device="cuda",
|
||||
image: Optional[torch.Tensor] = None,
|
||||
mask: Optional[torch.Tensor] = None,
|
||||
):
|
||||
if not ignore_errors and image is None and mask is None:
|
||||
raise ValueError(
|
||||
"You must either provide an image or a mask,"
|
||||
" use ignore_error to passthrough"
|
||||
)
|
||||
if image is not None:
|
||||
image = image.to(device)
|
||||
if mask is not None:
|
||||
mask = mask.to(device)
|
||||
return (image, mask)
|
||||
|
||||
|
||||
# class MTB_ApplyTextTemplate:
|
||||
class MTB_ApplyTextTemplate:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"template": ("STRING", {"default": "", "multiline": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("string",)
|
||||
CATEGORY = "mtb/utils"
|
||||
FUNCTION = "execute"
|
||||
|
||||
def execute(self, *, template: str, **kwargs):
|
||||
res = f"{template}"
|
||||
for k, v in kwargs.items():
|
||||
res = res.replace(f"{{{k}}}", f"{v}")
|
||||
|
||||
return (res,)
|
||||
|
||||
|
||||
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:
|
||||
"""Basic string replacement"""
|
||||
"""Basic string replacement."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -30,6 +247,54 @@ 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 +303,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 +351,57 @@ 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,
|
||||
MTB_ToDevice,
|
||||
MTB_ApplyTextTemplate,
|
||||
]
|
||||
|
||||
+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]
|
||||
|
||||
+349
-140
@@ -1,21 +1,19 @@
|
||||
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 folder_paths
|
||||
import numpy as np
|
||||
import torch
|
||||
import torch.nn.functional as F
|
||||
from PIL import Image, ImageOps
|
||||
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 +21,20 @@ 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"""
|
||||
|
||||
@@ -104,7 +116,9 @@ class ColorCorrect:
|
||||
return pil2tensor(out)
|
||||
|
||||
@staticmethod
|
||||
def hsv_adjustment_tensor_not_working(image: torch.Tensor, hue, saturation, value):
|
||||
def hsv_adjustment_tensor_not_working(
|
||||
image: torch.Tensor, hue, saturation, value
|
||||
):
|
||||
"""Abandonning for now"""
|
||||
image = image.squeeze(0).permute(2, 0, 1)
|
||||
|
||||
@@ -181,7 +195,7 @@ class ColorCorrect:
|
||||
return (image,)
|
||||
|
||||
|
||||
class ImageCompare:
|
||||
class ImageCompare_:
|
||||
"""Compare two images and return a difference image"""
|
||||
|
||||
@classmethod
|
||||
@@ -217,7 +231,7 @@ class ImageCompare:
|
||||
import requests
|
||||
|
||||
|
||||
class LoadImageFromUrl:
|
||||
class LoadImageFromUrl_:
|
||||
"""Load an image from the given URL"""
|
||||
|
||||
@classmethod
|
||||
@@ -240,10 +254,11 @@ class LoadImageFromUrl:
|
||||
def load(self, url):
|
||||
# get the image from the url
|
||||
image = Image.open(requests.get(url, stream=True).raw)
|
||||
image = ImageOps.exif_transpose(image)
|
||||
return (pil2tensor(image),)
|
||||
|
||||
|
||||
class Blur:
|
||||
class Blur_:
|
||||
"""Blur an image using a Gaussian filter."""
|
||||
|
||||
@classmethod
|
||||
@@ -274,6 +289,83 @@ 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,26 +412,35 @@ 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),)
|
||||
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class ColoredImage:
|
||||
"""Constant color image of given size"""
|
||||
"""Constant color image of given size."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
pass
|
||||
@@ -351,7 +452,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 +465,91 @@ class ColoredImage:
|
||||
|
||||
FUNCTION = "render_img"
|
||||
|
||||
def render_img(self, color, width, height):
|
||||
image = Image.new("RGB", (width, height), color=color)
|
||||
def resize_and_crop(self, img, target_size):
|
||||
# Calculate scaling factors for both dimensions
|
||||
scale_x = target_size[0] / img.width
|
||||
scale_y = target_size[1] / img.height
|
||||
|
||||
image = pil2tensor(image)
|
||||
# Use the smaller scaling factor to maintain aspect ratio
|
||||
scale = max(scale_x, scale_y)
|
||||
|
||||
return (image,)
|
||||
# Resize the image based on calculated scale
|
||||
new_size = (int(img.width * scale), int(img.height * scale))
|
||||
img = img.resize(new_size, Image.LANCZOS)
|
||||
|
||||
# Calculate cropping coordinates
|
||||
left = (img.width - target_size[0]) / 2
|
||||
top = (img.height - target_size[1]) / 2
|
||||
right = (img.width + target_size[0]) / 2
|
||||
bottom = (img.height + target_size[1]) / 2
|
||||
|
||||
# Crop and return the image
|
||||
return img.crop((left, top, right, bottom))
|
||||
|
||||
def resize_and_crop_thumbnails(self, img, target_size):
|
||||
img.thumbnail(target_size, Image.LANCZOS)
|
||||
left = (img.width - target_size[0]) / 2
|
||||
top = (img.height - target_size[1]) / 2
|
||||
right = (img.width + target_size[0]) / 2
|
||||
bottom = (img.height + target_size[1]) / 2
|
||||
return img.crop((left, top, right, bottom))
|
||||
|
||||
def render_img(
|
||||
self,
|
||||
color,
|
||||
width,
|
||||
height,
|
||||
foreground_image: Optional[torch.Tensor] = None,
|
||||
foreground_mask: Optional[torch.Tensor] = None,
|
||||
):
|
||||
image = Image.new("RGBA", (width, height), color=color)
|
||||
output = []
|
||||
if foreground_image is not None:
|
||||
fg_images = tensor2pil(foreground_image)
|
||||
fg_masks = [None] * len(
|
||||
fg_images
|
||||
) # Default to None for each foreground image
|
||||
|
||||
if foreground_mask is not None:
|
||||
if foreground_image.size()[0] != foreground_mask.size()[0]:
|
||||
raise ValueError(
|
||||
"Foreground image and mask must have same batch size"
|
||||
)
|
||||
fg_masks = tensor2pil(foreground_mask.unsqueeze(-1))
|
||||
|
||||
for fg_image, fg_mask in zip(fg_images, fg_masks):
|
||||
# Resize and crop if dimensions mismatch
|
||||
if fg_image.size != image.size:
|
||||
fg_image = self.resize_and_crop(fg_image, image.size)
|
||||
if fg_mask:
|
||||
fg_mask = self.resize_and_crop(fg_mask, image.size)
|
||||
|
||||
if fg_mask:
|
||||
output.append(
|
||||
Image.composite(
|
||||
fg_image.convert("RGBA"),
|
||||
image,
|
||||
fg_mask,
|
||||
).convert("RGB")
|
||||
)
|
||||
else:
|
||||
if fg_image.mode != "RGBA":
|
||||
raise ValueError(
|
||||
"Foreground image must be in 'RGBA' mode "
|
||||
f"when no mask is provided, got {fg_image.mode}"
|
||||
)
|
||||
output.append(
|
||||
Image.alpha_composite(image, fg_image).convert("RGB")
|
||||
)
|
||||
|
||||
else:
|
||||
if foreground_mask is not None:
|
||||
log.warn("Mask ignored because no foreground image is given")
|
||||
output.append(image.convert("RGB"))
|
||||
|
||||
output = pil2tensor(output)
|
||||
|
||||
return (output,)
|
||||
|
||||
|
||||
class ImagePremultiply:
|
||||
@@ -377,25 +561,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 +603,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 +626,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 +634,58 @@ 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 +698,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"},
|
||||
}
|
||||
@@ -598,7 +750,10 @@ class SaveImageGrid:
|
||||
subfolder,
|
||||
filename_prefix,
|
||||
) = folder_paths.get_save_image_path(
|
||||
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
|
||||
filename_prefix,
|
||||
self.output_dir,
|
||||
images[0].shape[1],
|
||||
images[0].shape[0],
|
||||
)
|
||||
image_list = []
|
||||
batch_counter = counter
|
||||
@@ -628,22 +783,76 @@ class SaveImageGrid:
|
||||
file = f"{filename}_{counter:05}_.png"
|
||||
grid = self.create_image_grid(image_list)
|
||||
grid.save(
|
||||
os.path.join(full_output_folder, file), pnginfo=metadata, compress_level=4
|
||||
os.path.join(full_output_folder, file),
|
||||
pnginfo=metadata,
|
||||
compress_level=4,
|
||||
)
|
||||
|
||||
results = [{"filename": file, "subfolder": subfolder, "type": self.type}]
|
||||
results = [
|
||||
{"filename": file, "subfolder": subfolder, "type": self.type}
|
||||
]
|
||||
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,109 @@
|
||||
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 "
|
||||
f"{'vertically' if vertical else 'horizontally'}"
|
||||
)
|
||||
|
||||
normalized_tensors = [
|
||||
self.normalize_to_rgba(tensor) for tensor in tensors
|
||||
]
|
||||
|
||||
if vertical:
|
||||
width = normalized_tensors[0].shape[2]
|
||||
if any(tensor.shape[2] != width for tensor in normalized_tensors):
|
||||
raise ValueError(
|
||||
"All tensors must have the same width "
|
||||
"for vertical stacking."
|
||||
)
|
||||
dim = 1
|
||||
else:
|
||||
height = normalized_tensors[0].shape[1]
|
||||
if any(tensor.shape[1] != height for tensor in normalized_tensors):
|
||||
raise ValueError(
|
||||
"All tensors must have the same height "
|
||||
"for horizontal stacking."
|
||||
)
|
||||
dim = 2
|
||||
|
||||
stacked_tensor = torch.cat(normalized_tensors, dim=dim)
|
||||
|
||||
return (stacked_tensor,)
|
||||
|
||||
def normalize_to_rgba(self, tensor):
|
||||
"""Normalize tensor to have 4 channels (RGBA)."""
|
||||
_, _, _, channels = tensor.shape
|
||||
# already RGBA
|
||||
if channels == 4:
|
||||
return tensor
|
||||
# RGB to RGBA
|
||||
elif channels == 3:
|
||||
alpha_channel = torch.ones(
|
||||
tensor.shape[:-1] + (1,), device=tensor.device
|
||||
) # Add an alpha channel
|
||||
return torch.cat((tensor, alpha_channel), dim=-1)
|
||||
else:
|
||||
raise ValueError(
|
||||
"Tensor has an unsupported number of channels: "
|
||||
"expected 3 (RGB) or 4 (RGBA)."
|
||||
)
|
||||
|
||||
|
||||
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, "
|
||||
f"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]
|
||||
+298
-68
@@ -1,26 +1,143 @@
|
||||
from ..utils import tensor2np
|
||||
import uuid
|
||||
import folder_paths
|
||||
from ..log import log
|
||||
import comfy.model_management as model_management
|
||||
import json
|
||||
import subprocess
|
||||
import torch
|
||||
import 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",),
|
||||
# "frames": ("FRAMES",),
|
||||
"playlist": ("PLAYLIST",),
|
||||
},
|
||||
"required": {
|
||||
"fps": ("FLOAT", {"default": 24, "min": 1}),
|
||||
"prefix": ("STRING", {"default": "export"}),
|
||||
}
|
||||
"format": (
|
||||
["mov", "mp4", "mkv", "gif", "avi"],
|
||||
{"default": "mov"},
|
||||
),
|
||||
"codec": (
|
||||
["prores_ks", "libx264", "libx265", "gif"],
|
||||
{"default": "prores_ks"},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
@@ -30,26 +147,98 @@ 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])}")
|
||||
log.debug(f"Exporting {len(frames)} frames")
|
||||
|
||||
frames = [frame.astype(np.uint16) * 257 for frame in frames]
|
||||
if codec == "gif":
|
||||
out_path = (output_dir / file_id).as_posix()
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"png",
|
||||
"-r",
|
||||
str(fps),
|
||||
"-i",
|
||||
"-",
|
||||
"-vcodec",
|
||||
"gif",
|
||||
"-y",
|
||||
out_path,
|
||||
]
|
||||
process = subprocess.Popen(command, stdin=subprocess.PIPE)
|
||||
for frame in frames:
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
Image.fromarray(frame).save(process.stdin, "PNG")
|
||||
|
||||
process.stdin.close()
|
||||
process.wait()
|
||||
else:
|
||||
frames = [frame.astype(np.uint16) * 257 for frame in frames]
|
||||
|
||||
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 +251,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 +276,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 +317,11 @@ 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}),
|
||||
"resample_filter": (list(PIL_FILTER_MAP.keys()),),
|
||||
"use_ffmpeg": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
@@ -110,59 +329,70 @@ 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,
|
||||
use_ffmpeg=False,
|
||||
):
|
||||
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")
|
||||
if use_ffmpeg:
|
||||
# Use FFmpeg to create the GIF from PIL images
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"png",
|
||||
"-r",
|
||||
str(fps),
|
||||
"-i",
|
||||
"-",
|
||||
"-vcodec",
|
||||
"gif",
|
||||
"-y",
|
||||
out_path,
|
||||
]
|
||||
process = subprocess.Popen(command, stdin=subprocess.PIPE)
|
||||
for image in pil_images:
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
image.save(process.stdin, "PNG")
|
||||
process.stdin.close()
|
||||
process.wait()
|
||||
|
||||
# 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",
|
||||
out_path,
|
||||
else:
|
||||
pil_images[0].save(
|
||||
out_path,
|
||||
save_all=True,
|
||||
append_images=pil_images[1:],
|
||||
optimize=optimize,
|
||||
duration=int(1000 / fps),
|
||||
loop=0,
|
||||
)
|
||||
results = [
|
||||
{"filename": f"{ruuid}.gif", "subfolder": "", "type": "output"}
|
||||
]
|
||||
|
||||
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"})
|
||||
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]
|
||||
|
||||
+15
-10
@@ -1,13 +1,11 @@
|
||||
import os
|
||||
import re
|
||||
import torch
|
||||
import hashlib, json, os, re
|
||||
from pathlib import Path
|
||||
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import hashlib
|
||||
import torch
|
||||
from PIL import Image, ImageOps
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
import folder_paths
|
||||
from pathlib import Path
|
||||
import json
|
||||
|
||||
from ..log import log
|
||||
|
||||
@@ -173,7 +171,10 @@ class SaveImageSequence:
|
||||
"required": {
|
||||
"images": ("IMAGE",),
|
||||
"filename_prefix": ("STRING", {"default": "Sequence"}),
|
||||
"current_frame": ("INT", {"default": 0, "min": 0, "max": 9999999}),
|
||||
"current_frame": (
|
||||
"INT",
|
||||
{"default": 0, "min": 0, "max": 9999999},
|
||||
),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
@@ -220,10 +221,14 @@ class SaveImageSequence:
|
||||
resolved_path = Path(self.output_dir) / filename_prefix
|
||||
resolved_path.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
resolved_img = resolved_path / f"{filename_prefix}_{current_frame:05}.png"
|
||||
resolved_img = (
|
||||
resolved_path / f"{filename_prefix}_{current_frame:05}.png"
|
||||
)
|
||||
|
||||
output_image = images[0].cpu().numpy()
|
||||
img = Image.fromarray(np.clip(output_image * 255.0, 0, 255).astype(np.uint8))
|
||||
img = Image.fromarray(
|
||||
np.clip(output_image * 255.0, 0, 255).astype(np.uint8)
|
||||
)
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
|
||||
+114
@@ -0,0 +1,114 @@
|
||||
[tool.poetry]
|
||||
name = "comfy-mtb"
|
||||
version = "0.4.0"
|
||||
description = "Animation oriented nodes pack for ComfyUI."
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/melMass/comfy_mtb"
|
||||
authors = ["Mel Massadian"]
|
||||
packages = [{ include = "comfy-mtb" }]
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Intended Audience :: Developers",
|
||||
]
|
||||
|
||||
[tool.poetry.urls]
|
||||
"Bug Tracker" = "https://github.com/melMass/comfy_mtb/issues"
|
||||
"Changelog" = "https://github.com/melMass/comfy_mtb/releases"
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
black = { extras = ["jupyter"], version = "^23.7.0" }
|
||||
codespell = "^2.2.5"
|
||||
mypy = "^1.5.1"
|
||||
pre-commit = "^3.3.3"
|
||||
pytest = "^7.4.0"
|
||||
pytest-cov = "^4.1.0"
|
||||
pytest-random-order = "^1.1.0"
|
||||
ruff = "^0.0.285"
|
||||
|
||||
[tool.poetry.group.docs]
|
||||
optional = true
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
docutils = "0.17.1"
|
||||
jupyter-book = "^0.15.1"
|
||||
sphinx-autobuild = "^2021.3.14"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
log_level = "DEBUG"
|
||||
log_cli = true
|
||||
markers = [
|
||||
"wip: tests that aren't fully finished yet",
|
||||
"heavy: marks tests as heavy (deselect with '-m \"not heavy\"')",
|
||||
|
||||
]
|
||||
filterwarnings = ["ignore::UserWarning", 'ignore::DeprecationWarning']
|
||||
|
||||
[tool.isort]
|
||||
profile = "black"
|
||||
line_length = 88
|
||||
auto_identify_namespace_packages = false
|
||||
# NOTE:
|
||||
# pyright doesn't like implicit namespace + single line (related to https://github.com/microsoft/pyright/issues/2882?) but it's horible so I'll live with it
|
||||
force_single_line = false
|
||||
known_first_party = ["mtb"]
|
||||
extend_skip = ["archives"]
|
||||
combine_straight_imports = true
|
||||
|
||||
[tool.coverage.run]
|
||||
parallel = true
|
||||
source = ["docs", "tests", "comfy-mtb"]
|
||||
|
||||
[tool.coverage.report]
|
||||
fail_under = 90
|
||||
show_missing = true
|
||||
|
||||
[tool.coverage.html]
|
||||
show_contexts = true
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 79
|
||||
select = ["A", "B", "C", "D", "E", "F", "FBT", "I", "N", "S", "SIM", "UP", "W"]
|
||||
# NOTE:
|
||||
# D102 - undocumented-public-method (noisy)
|
||||
# D103 - undocumented-public-function (noisy)
|
||||
# D100 - undocumented-public-module (noisy)
|
||||
# N802 - invalid-function-name (forced by comfy's arch)
|
||||
ignore = ["D103", "D102", "D100", "N802"]
|
||||
# exclude auto generated file
|
||||
extend-exclude = ["./docs/conf.py"]
|
||||
|
||||
[tool.ruff.per-file-ignores]
|
||||
# imported but unused
|
||||
"__init__.py" = ["F401"]
|
||||
# use of assert detected
|
||||
"tests/*" = ["S101"]
|
||||
|
||||
[tool.ruff.pydocstyle]
|
||||
convention = "numpy"
|
||||
|
||||
[tool.mypy]
|
||||
pretty = true
|
||||
ignore_missing_imports = true
|
||||
# exclude auto generated file
|
||||
exclude = ["docs/conf.py"]
|
||||
|
||||
[tool.codespell]
|
||||
# exclude auto generated file
|
||||
skip = "./docs/conf.py,poetry.lock"
|
||||
check-filenames = true
|
||||
|
||||
[tool.poetry-version-plugin]
|
||||
source = "git-tag"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
@@ -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,10 @@
|
||||
qrcode[pil]
|
||||
onnxruntime-gpu
|
||||
requirements-parser
|
||||
# opencv-contrib
|
||||
rembg
|
||||
imageio_ffmpeg
|
||||
rich
|
||||
rich_argparse
|
||||
matplotlib
|
||||
pillow
|
||||
@@ -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:
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
$env.GITHUB_TOKEN = (gh auth token)
|
||||
git cliff --tag main | save -f CHANGELOG.md
|
||||
@@ -0,0 +1,38 @@
|
||||
# NOTE: this file must be used in all test_* files to setup the test env.
|
||||
|
||||
import importlib
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def setup_test():
|
||||
mod_path = Path(__file__).parent.parent
|
||||
|
||||
# add custom_nodes to path
|
||||
sys.path.append(mod_path.parent.as_posix())
|
||||
print(f"Appended: {mod_path.parent.as_posix()}")
|
||||
|
||||
# add comfy root to path
|
||||
sys.path.append(mod_path.parent.parent.as_posix())
|
||||
print(f"Appended: {mod_path.parent.parent.as_posix()}")
|
||||
|
||||
# import mtb
|
||||
module = importlib.import_module(mod_path.name)
|
||||
|
||||
# add the module to globals
|
||||
globals()[mod_path.name] = module
|
||||
|
||||
# set the logging level for third-party libraries
|
||||
logging.getLogger("xformers").setLevel(logging.WARNING)
|
||||
logging.getLogger("tensorflow").setLevel(logging.ERROR)
|
||||
logging.getLogger("h5py._conv").setLevel(logging.ERROR)
|
||||
logging.getLogger("numexpr.utils").setLevel(logging.ERROR)
|
||||
logging.getLogger("matplotlib").setLevel(logging.ERROR)
|
||||
logging.getLogger("torchaudio._extension").setLevel(logging.ERROR)
|
||||
logging.getLogger("torch.utils.tensorboard").setLevel(logging.ERROR)
|
||||
logging.getLogger("torchvision.transforms.functional_tensor").setLevel(
|
||||
logging.ERROR
|
||||
)
|
||||
logging.getLogger("basicsr.metrics.niqe").setLevel(logging.ERROR)
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import pytest
|
||||
from setup_test import setup_test
|
||||
|
||||
setup_test()
|
||||
|
||||
from comfy_mtb_utils.nodes.graph_utils import FitNumber
|
||||
from comfy_mtb_utils.utils import apply_easing
|
||||
|
||||
|
||||
# - apply_easing
|
||||
@pytest.mark.parametrize(
|
||||
"value, easing_type, expected",
|
||||
[
|
||||
(0.5, "Linear", 0.5),
|
||||
(0, "Linear", 0),
|
||||
(1, "Linear", 1),
|
||||
(0.5, "Sine In", 0.2928932188134524),
|
||||
],
|
||||
)
|
||||
def test_apply_easing(value: float, easing_type: str, expected: float):
|
||||
assert apply_easing(value, easing_type) == pytest.approx(
|
||||
expected
|
||||
), "Easing function did not return expected value"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"val, easing_type",
|
||||
[
|
||||
(0.5, "NonExistentEasing"),
|
||||
],
|
||||
)
|
||||
def test_apply_easing_error_handling(val, easing_type):
|
||||
with pytest.raises(ValueError):
|
||||
apply_easing(val, easing_type)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"val, easing_type, expected",
|
||||
[
|
||||
(0, "Linear", 0),
|
||||
(1, "Linear", 1),
|
||||
(0.5, "Linear", 0.5),
|
||||
],
|
||||
)
|
||||
def test_apply_easing_corner_cases(val, easing_type, expected):
|
||||
assert apply_easing(val, easing_type) == expected
|
||||
|
||||
|
||||
# - FitNumber
|
||||
@pytest.fixture
|
||||
def fit_number_instance():
|
||||
return FitNumber()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, clamp, source_min, source_max, target_min, target_max, easing, expected",
|
||||
[
|
||||
(0.5, False, 0, 1, 0, 1, "Linear", (0.5,)),
|
||||
],
|
||||
)
|
||||
def test_fit_number_set_range(
|
||||
fit_number_instance,
|
||||
value,
|
||||
clamp,
|
||||
source_min,
|
||||
source_max,
|
||||
target_min,
|
||||
target_max,
|
||||
easing,
|
||||
expected,
|
||||
):
|
||||
assert (
|
||||
fit_number_instance.set_range(
|
||||
value=value,
|
||||
clamp=clamp,
|
||||
source_min=source_min,
|
||||
source_max=source_max,
|
||||
target_min=target_min,
|
||||
target_max=target_max,
|
||||
easing=easing,
|
||||
)
|
||||
== expected
|
||||
), "Set range method did not return expected value"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, clamp, source_min, source_max, target_min, target_max, easing, expected",
|
||||
[
|
||||
(0.5, False, 0, 1, 0, 1, "Linear", (0.5,)),
|
||||
(0.4, False, 1.0, 0.4, 0.4, 0.7, "Linear", (0.7,)),
|
||||
# Add more test cases covering various scenarios, edge cases, and easing types
|
||||
],
|
||||
)
|
||||
def test_fit_number_inverted_ranges(
|
||||
fit_number_instance,
|
||||
value,
|
||||
clamp,
|
||||
source_min,
|
||||
source_max,
|
||||
target_min,
|
||||
target_max,
|
||||
easing,
|
||||
expected,
|
||||
):
|
||||
assert (
|
||||
fit_number_instance.set_range(
|
||||
value=value,
|
||||
clamp=clamp,
|
||||
source_min=source_min,
|
||||
source_max=source_max,
|
||||
target_min=target_min,
|
||||
target_max=target_max,
|
||||
easing=easing,
|
||||
)
|
||||
== expected
|
||||
), "Set range method did not return expected value"
|
||||
@@ -1,11 +1,136 @@
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import torch
|
||||
from pathlib import Path
|
||||
import contextlib
|
||||
import functools
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
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 +149,136 @@ 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
|
||||
font_path = here / "font.ttf"
|
||||
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)
|
||||
|
||||
# Add extern folder to path
|
||||
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 / "data" / "font.ttf"
|
||||
|
||||
# - 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"))
|
||||
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):
|
||||
@@ -59,19 +287,23 @@ def tensor2pil(image: torch.Tensor) -> List[Image.Image]:
|
||||
|
||||
return [
|
||||
Image.fromarray(
|
||||
np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)
|
||||
np.clip(255.0 * image.cpu().numpy().squeeze(), 0, 255).astype(
|
||||
np.uint8
|
||||
)
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
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,13 +311,473 @@ 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):
|
||||
out.extend(tensor2np(tensor[i]))
|
||||
return out
|
||||
|
||||
return [np.clip(255.0 * tensor.cpu().numpy().squeeze(), 0, 255).astype(np.uint8)]
|
||||
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
|
||||
|
||||
+201
-17
@@ -7,7 +7,68 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { app } from '/scripts/app.js'
|
||||
import { app } from '../../scripts/app.js'
|
||||
|
||||
// - crude uuid
|
||||
export function makeUUID() {
|
||||
let dt = new Date().getTime()
|
||||
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (dt + Math.random() * 16) % 16 | 0
|
||||
dt = Math.floor(dt / 16)
|
||||
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16)
|
||||
})
|
||||
return uuid
|
||||
}
|
||||
|
||||
//- local storage manager
|
||||
export class LocalStorageManager {
|
||||
constructor(namespace) {
|
||||
this.namespace = namespace
|
||||
}
|
||||
|
||||
_namespacedKey(key) {
|
||||
return `${this.namespace}:${key}`
|
||||
}
|
||||
|
||||
set(key, value) {
|
||||
const serializedValue = JSON.stringify(value)
|
||||
localStorage.setItem(this._namespacedKey(key), serializedValue)
|
||||
}
|
||||
|
||||
get(key, default_val = null) {
|
||||
const value = localStorage.getItem(this._namespacedKey(key))
|
||||
return value ? JSON.parse(value) : default_val
|
||||
}
|
||||
|
||||
remove(key) {
|
||||
localStorage.removeItem(this._namespacedKey(key))
|
||||
}
|
||||
|
||||
clear() {
|
||||
Object.keys(localStorage)
|
||||
.filter((k) => k.startsWith(this.namespace + ':'))
|
||||
.forEach((k) => localStorage.removeItem(k))
|
||||
}
|
||||
}
|
||||
|
||||
// - log utilities
|
||||
|
||||
function createLogger(emoji, color, consoleMethod = 'log') {
|
||||
return function (message, ...args) {
|
||||
if (window.MTB?.DEBUG) {
|
||||
console[consoleMethod](
|
||||
`%c${emoji} ${message}`,
|
||||
`color: ${color};`,
|
||||
...args,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const infoLogger = createLogger('ℹ️', 'yellow')
|
||||
export const warnLogger = createLogger('⚠️', 'orange', 'warn')
|
||||
export const errorLogger = createLogger('🔥', 'red', 'error')
|
||||
export const successLogger = createLogger('✅', 'green')
|
||||
|
||||
export const log = (...args) => {
|
||||
if (window.MTB?.DEBUG) {
|
||||
@@ -18,20 +79,44 @@ 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,
|
||||
node,
|
||||
widgetWidth,
|
||||
widgetY,
|
||||
height
|
||||
height,
|
||||
) {
|
||||
const margin = 10
|
||||
const elRect = ctx.canvas.getBoundingClientRect()
|
||||
const transform = new DOMMatrix()
|
||||
.scaleSelf(
|
||||
elRect.width / ctx.canvas.width,
|
||||
elRect.height / ctx.canvas.height
|
||||
elRect.height / ctx.canvas.height,
|
||||
)
|
||||
.multiplySelf(ctx.getTransform())
|
||||
.translateSelf(margin, margin + widgetY)
|
||||
@@ -49,7 +134,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 +145,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 +153,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,25 +199,46 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
export function calculateTotalChildrenHeight(parentElement) {
|
||||
let totalHeight = 0
|
||||
|
||||
for (const child of parentElement.children) {
|
||||
const style = window.getComputedStyle(child)
|
||||
|
||||
// Get height as an integer (without 'px')
|
||||
const height = parseInt(style.height, 10)
|
||||
|
||||
// Get vertical margin as integers
|
||||
const marginTop = parseInt(style.marginTop, 10)
|
||||
const marginBottom = parseInt(style.marginBottom, 10)
|
||||
|
||||
// Sum up height and vertical margins
|
||||
totalHeight += height + marginTop + marginBottom
|
||||
}
|
||||
|
||||
return totalHeight
|
||||
}
|
||||
/**
|
||||
* Appends a callback to the extra menu options of a given node type.
|
||||
* @param {*} nodeType
|
||||
@@ -279,11 +409,48 @@ function getBrightness(rgbObj) {
|
||||
(parseInt(rgbObj[0]) * 299 +
|
||||
parseInt(rgbObj[1]) * 587 +
|
||||
parseInt(rgbObj[2]) * 114) /
|
||||
1000
|
||||
1000,
|
||||
)
|
||||
}
|
||||
|
||||
//- 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
|
||||
|
||||
@@ -316,3 +483,20 @@ export function defineClass(className, classStyles) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Prefixes the node title with '[DEPRECATED]' and log the deprecation reason to the console.*/
|
||||
export const addDeprecation = (nodeType, reason) => {
|
||||
const title = nodeType.title
|
||||
nodeType.title = '[DEPRECATED] ' + title
|
||||
// console.log(nodeType)
|
||||
|
||||
const styles = {
|
||||
title: 'font-size:1.3em;font-weight:900;color:yellow; background: black',
|
||||
reason: 'font-size:1.2em',
|
||||
}
|
||||
console.log(
|
||||
`%c⚠️ ${title} is deprecated:%c ${reason}`,
|
||||
styles.title,
|
||||
styles.reason,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
|
||||
import { app } from '../../scripts/app.js'
|
||||
|
||||
function B0(t) { return (1 - t) ** 3 / 6; }
|
||||
function B1(t) { return (3 * t ** 3 - 6 * t ** 2 + 4) / 6; }
|
||||
function B2(t) { return (-3 * t ** 3 + 3 * t ** 2 + 3 * t + 1) / 6; }
|
||||
function B3(t) { return t ** 3 / 6; }
|
||||
|
||||
class CurveWidget {
|
||||
constructor(inputName, defaultValue) {
|
||||
this.name = inputName || "Curve";
|
||||
this._value = defaultValue || [{ x: 0, y: 0 }, { x: 1, y: 1 }];
|
||||
this.type = "FLOAT_CURVE";
|
||||
this.selectedPointIndex = null;
|
||||
this.resize
|
||||
}
|
||||
|
||||
drawBSpline(ctx, width, height, posY) {
|
||||
const n = this._value.length - 1;
|
||||
const numSegments = n - 2;
|
||||
const numPoints = this._value.length;
|
||||
if (numPoints < 4) {
|
||||
this.drawLinear(ctx, width, height, posY);
|
||||
} else {
|
||||
for (let j = 0; j <= numSegments; j++) {
|
||||
for (let t = 0; t <= 1; t += 0.01) {
|
||||
let pt = this.getBSplinePoint(j, t);
|
||||
let x = pt.x * width;
|
||||
let y = posY + height - pt.y * height;
|
||||
|
||||
if (t === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
drawLinear(ctx, width, height, posY) {
|
||||
for (let i = 0; i < this._value.length - 1; i++) {
|
||||
let p1 = this._value[i];
|
||||
let p2 = this._value[i + 1];
|
||||
ctx.moveTo(p1.x * width, posY + height - p1.y * height);
|
||||
ctx.lineTo(p2.x * width, posY + height - p2.y * height);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
getBSplinePoint(i, t) {
|
||||
// Control points for this segment
|
||||
const p0 = this._value[i];
|
||||
const p1 = this._value[i + 1];
|
||||
const p2 = this._value[i + 2];
|
||||
const p3 = this._value[i + 3];
|
||||
|
||||
const x = B0(t) * p0.x + B1(t) * p1.x + B2(t) * p2.x + B3(t) * p3.x;
|
||||
const y = B0(t) * p0.y + B1(t) * p1.y + B2(t) * p2.y + B3(t) * p3.y;
|
||||
|
||||
return { x, y };
|
||||
}
|
||||
|
||||
draw(ctx, node, width, posY, height) {
|
||||
const [cw, ch] = this.computeSize(width)
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.fillStyle = "#000";
|
||||
//ctx.fillRect(0, posY, cw, ch);
|
||||
ctx.strokeStyle = "#fff";
|
||||
ctx.lineWidth = 2;
|
||||
|
||||
// normalized coordinates -> canvas coordinates
|
||||
for (let i = 0; i < this._value.length - 1; i++) {
|
||||
let p1 = this._value[i];
|
||||
let p2 = this._value[i + 1];
|
||||
ctx.moveTo(p1.x * cw, posY + ch - p1.y * ch);
|
||||
ctx.lineTo(p2.x * cw, posY + ch - p2.y * ch);
|
||||
}
|
||||
ctx.stroke();
|
||||
// this.drawBSpline(ctx, width, height, posY);
|
||||
|
||||
// points
|
||||
this._value.forEach(point => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(point.x * cw, posY + ch - point.y * ch, 5, 0, 2 * Math.PI);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
mouse(event, pos, node) {
|
||||
// console.debug(event.type, pos, node)
|
||||
let x = pos[0] - node.pos[0]
|
||||
let y = pos[1] - node.pos[1]
|
||||
let width = node.size[0]
|
||||
const height = 300; // TODO: compute
|
||||
const posY = node.pos[1];
|
||||
|
||||
const localPos = { x: pos[0], y: pos[1] - LiteGraph.NODE_WIDGET_HEIGHT };
|
||||
|
||||
if (event.type === LiteGraph.pointerevents_method + "down") {
|
||||
console.debug("Checking if a point was clicked");
|
||||
const clickedPointIndex = this.detectPoint(localPos, width, height);
|
||||
if (clickedPointIndex !== null) {
|
||||
this.selectedPointIndex = clickedPointIndex;
|
||||
} else {
|
||||
this.addPoint(localPos, width, height);
|
||||
}
|
||||
return true;
|
||||
} else if (event.type === LiteGraph.pointerevents_method + "move" && this.selectedPointIndex !== null) {
|
||||
this.movePoint(this.selectedPointIndex, localPos, width, height);
|
||||
return true;
|
||||
} else if (event.type === LiteGraph.pointerevents_method + "up" && this.selectedPointIndex !== null) {
|
||||
this.selectedPointIndex = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
detectPoint(localPos, width, height) {
|
||||
const threshold = 20; // TODO: extract
|
||||
for (let i = 0; i < this._value.length; i++) {
|
||||
const p = this._value[i];
|
||||
const px = p.x * width;
|
||||
const py = height - p.y * height;
|
||||
if (Math.abs(localPos.x - px) < threshold && Math.abs(localPos.y - py) < threshold) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
addPoint(localPos, width, height) {
|
||||
// add a new point based on click position
|
||||
const normalizedPoint = { x: localPos.x / width, y: 1 - localPos.y / height };
|
||||
this._value.push(normalizedPoint);
|
||||
this._value.sort((a, b) => a.x - b.x);
|
||||
this.value = JSON.stringify(this._value);
|
||||
}
|
||||
|
||||
movePoint(index, localPos, width, height) {
|
||||
const point = this._value[index];
|
||||
point.x = Math.max(0, Math.min(1, localPos.x / width));
|
||||
point.y = Math.max(0, Math.min(1, 1 - localPos.y / height));
|
||||
|
||||
this._value[index] = point;
|
||||
this.value = JSON.stringify(this._value);
|
||||
}
|
||||
|
||||
computeSize(width) {
|
||||
return [width, 300];
|
||||
}
|
||||
|
||||
configure(data) {
|
||||
console.log(data)
|
||||
}
|
||||
|
||||
value() {
|
||||
console.debug('Returning value', this._value)
|
||||
return this._value
|
||||
}
|
||||
setValue(value) {
|
||||
console.debug('Setting value', value)
|
||||
this._value = value
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'mtb.curves',
|
||||
getCustomWidgets: function () {
|
||||
return {
|
||||
FLOAT_CURVE: (node, inputName, inputData, app) => {
|
||||
console.debug('Registering float curve widget');
|
||||
|
||||
return {
|
||||
widget: node.addCustomWidget(
|
||||
new CurveWidget(inputName, inputData[1]?.default)
|
||||
),
|
||||
minWidth: 150,
|
||||
minHeight: 30,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
}
|
||||
},
|
||||
|
||||
})
|
||||
+35
-11
@@ -7,23 +7,41 @@
|
||||
*
|
||||
*/
|
||||
|
||||
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,
|
||||
index,
|
||||
connected,
|
||||
link_info
|
||||
link_info,
|
||||
) {
|
||||
const r = onConnectionsChange
|
||||
? onConnectionsChange.apply(this, arguments)
|
||||
@@ -34,7 +52,7 @@ app.registerExtension({
|
||||
//- infer type
|
||||
if (link_info) {
|
||||
const fromNode = this.graph._nodes.find(
|
||||
(otherNode) => otherNode.id == link_info.origin_id
|
||||
(otherNode) => otherNode.id == link_info.origin_id,
|
||||
)
|
||||
const type = fromNode.outputs[link_info.origin_slot].type
|
||||
this.inputs[index].type = type
|
||||
@@ -45,6 +63,7 @@ app.registerExtension({
|
||||
this.inputs[index].type = '*'
|
||||
this.inputs[index].label = `anything_${index + 1}`
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
@@ -57,15 +76,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++
|
||||
@@ -74,22 +96,24 @@ app.registerExtension({
|
||||
if (message.b64_images) {
|
||||
for (const img of message.b64_images) {
|
||||
const w = this.addCustomWidget(
|
||||
MtbWidgets.DEBUG_IMG(`${prefix}_${widgetI}`, img)
|
||||
MtbWidgets.DEBUG_IMG(`${prefix}_${widgetI}`, img),
|
||||
)
|
||||
w.parent = this
|
||||
widgetI++
|
||||
}
|
||||
// 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?.()
|
||||
}
|
||||
}
|
||||
|
||||
Vendored
+3
File diff suppressed because one or more lines are too long
Vendored
+3
File diff suppressed because one or more lines are too long
+28
-9
@@ -9,9 +9,9 @@
|
||||
|
||||
// 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'
|
||||
import { LocalStorageManager } from "./comfy_shared.js"
|
||||
const styles = {
|
||||
lighbox: {
|
||||
position: 'fixed',
|
||||
@@ -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',
|
||||
},
|
||||
@@ -53,11 +53,31 @@ let currentImageIndex = 0
|
||||
const imageUrls = []
|
||||
|
||||
let image_menu = null
|
||||
let activated = true
|
||||
const storage = new LocalStorageManager('mtb');
|
||||
|
||||
let activated = storage.get("image_feed", true)
|
||||
|
||||
app.registerExtension({
|
||||
name: 'mtb.ImageFeed',
|
||||
setup: () => {
|
||||
app.ui.settings.addSetting({
|
||||
id: 'mtb.imageFeed.enabled',
|
||||
name: '[⚡mtb] Enable image feed',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
attrs: {
|
||||
style: {
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
},
|
||||
async onChange(value) {
|
||||
storage.set("image_feed", value)
|
||||
activated = value
|
||||
},
|
||||
})
|
||||
},
|
||||
init: async () => {
|
||||
if (!activated) { return }
|
||||
const pythongossFeed = app.extensions.find(
|
||||
(e) => e.name == 'pysssss.ImageFeed'
|
||||
)
|
||||
@@ -230,9 +250,8 @@ app.registerExtension({
|
||||
objectFit: 'cover',
|
||||
})
|
||||
|
||||
img.src = `/view?filename=${encodeURIComponent(src.filename)}&type=${
|
||||
src.type
|
||||
}&subfolder=${encodeURIComponent(src.subfolder)}`
|
||||
img.src = `/view?filename=${encodeURIComponent(src.filename)}&type=${src.type
|
||||
}&subfolder=${encodeURIComponent(src.subfolder)}`
|
||||
|
||||
imageUrls.push(img.src)
|
||||
|
||||
|
||||
+213
-216
@@ -7,13 +7,52 @@
|
||||
*
|
||||
*/
|
||||
|
||||
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'
|
||||
|
||||
// NOTE: new widget types registered by MTB Widgets
|
||||
const newTypes = [, /*'BOOL'*/ 'COLOR', 'BBOX']
|
||||
|
||||
const deprecated_nodes = {
|
||||
// 'Animation Builder':
|
||||
// 'Kept to avoid breaking older script but replaced by TimeEngine',
|
||||
}
|
||||
|
||||
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) => {
|
||||
@@ -73,7 +112,7 @@ export const MtbWidgets = {
|
||||
ctx.fillText(
|
||||
this.label || this.name,
|
||||
margin * 2 + 5,
|
||||
currentY + H * 0.7
|
||||
currentY + H * 0.7,
|
||||
)
|
||||
ctx.fillStyle = text_color
|
||||
ctx.textAlign = 'right'
|
||||
@@ -82,10 +121,10 @@ export const MtbWidgets = {
|
||||
Number(this.value).toFixed(
|
||||
this.options?.precision !== undefined
|
||||
? this.options.precision
|
||||
: 3
|
||||
: 3,
|
||||
),
|
||||
widget_width - margin * 2 - 20,
|
||||
currentY + H * 0.7
|
||||
currentY + H * 0.7,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -167,7 +206,7 @@ export const MtbWidgets = {
|
||||
this.value = Number(v)
|
||||
shared.inner_value_change(this, this.value, event)
|
||||
}.bind(w),
|
||||
event
|
||||
event,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -177,7 +216,7 @@ export const MtbWidgets = {
|
||||
function () {
|
||||
shared.inner_value_change(this, this.value, event)
|
||||
}.bind(this),
|
||||
20
|
||||
20,
|
||||
)
|
||||
|
||||
app.canvas.setDirty(true)
|
||||
@@ -204,116 +243,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 = {}
|
||||
@@ -335,7 +265,7 @@ export const MtbWidgets = {
|
||||
border,
|
||||
widgetY + border,
|
||||
widgetWidth - border * 2,
|
||||
height - border * 2
|
||||
height - border * 2,
|
||||
)
|
||||
const color = parseCss(this.value.default || this.value)
|
||||
if (!color) {
|
||||
@@ -425,46 +355,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 +378,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: 1em;
|
||||
font-family: monospace;
|
||||
`
|
||||
w.value = val
|
||||
document.body.appendChild(w.inputEl)
|
||||
|
||||
@@ -521,7 +425,7 @@ const mtb_widgets = {
|
||||
setup: () => {
|
||||
app.ui.settings.addSetting({
|
||||
id: 'mtb.Debug.enabled',
|
||||
name: '[mtb] Enable Debug (py and js)',
|
||||
name: '[⚡mtb] Enable Debug (py and js)',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
|
||||
@@ -562,7 +466,7 @@ const mtb_widgets = {
|
||||
|
||||
return {
|
||||
widget: node.addCustomWidget(
|
||||
MtbWidgets.BOOL(inputName, inputData[1]?.default || false)
|
||||
MtbWidgets.BOOL(inputName, inputData[1]?.default || false),
|
||||
),
|
||||
minWidth: 150,
|
||||
minHeight: 30,
|
||||
@@ -573,7 +477,7 @@ const mtb_widgets = {
|
||||
console.debug('Registering color')
|
||||
return {
|
||||
widget: node.addCustomWidget(
|
||||
MtbWidgets.COLOR(inputName, inputData[1]?.default || '#ff0000')
|
||||
MtbWidgets.COLOR(inputName, inputData[1]?.default || '#ff0000'),
|
||||
),
|
||||
minWidth: 150,
|
||||
minHeight: 30,
|
||||
@@ -621,12 +525,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 +569,14 @@ const mtb_widgets = {
|
||||
}
|
||||
}
|
||||
|
||||
if (!nodeData.name.endsWith('(mtb)')) {
|
||||
return
|
||||
}
|
||||
const deprecation = deprecated_nodes[nodeData.name.replace(' (mtb)', '')]
|
||||
|
||||
if (deprecation) {
|
||||
shared.addDeprecation(nodeType, deprecation)
|
||||
}
|
||||
//- Extending Python Nodes
|
||||
switch (nodeData.name) {
|
||||
case 'Psd Save (mtb)': {
|
||||
@@ -678,7 +585,7 @@ const mtb_widgets = {
|
||||
type,
|
||||
index,
|
||||
connected,
|
||||
link_info
|
||||
link_info,
|
||||
) {
|
||||
const r = onConnectionsChange
|
||||
? onConnectionsChange.apply(this, arguments)
|
||||
@@ -696,7 +603,7 @@ const mtb_widgets = {
|
||||
? onNodeCreated.apply(this, arguments)
|
||||
: undefined
|
||||
const internal_count = this.widgets.find(
|
||||
(w) => w.name === 'internal_count'
|
||||
(w) => w.name === 'internal_count',
|
||||
)
|
||||
shared.hideWidgetForGood(this, internal_count)
|
||||
internal_count.afterQueued = function () {
|
||||
@@ -736,45 +643,37 @@ const mtb_widgets = {
|
||||
imgURLs = imgURLs.concat(
|
||||
message.gif.map((params) => {
|
||||
return api.apiURL(
|
||||
'/view?' + new URLSearchParams(params).toString()
|
||||
'/view?' + new URLSearchParams(params).toString(),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
if (message.apng) {
|
||||
imgURLs = imgURLs.concat(
|
||||
message.apng.map((params) => {
|
||||
return api.apiURL(
|
||||
'/view?' + new URLSearchParams(params).toString()
|
||||
'/view?' + new URLSearchParams(params).toString(),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
let i = 0
|
||||
for (const img of imgURLs) {
|
||||
const w = this.addCustomWidget(
|
||||
MtbWidgets.DEBUG_IMG(`${prefix}_${i}`, img)
|
||||
MtbWidgets.DEBUG_IMG(`${prefix}_${i}`, img),
|
||||
)
|
||||
w.parent = this
|
||||
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
|
||||
@@ -789,12 +688,12 @@ const mtb_widgets = {
|
||||
this.changeMode(LiteGraph.ALWAYS)
|
||||
|
||||
const raw_iteration = this.widgets.find(
|
||||
(w) => w.name === 'raw_iteration'
|
||||
(w) => w.name === 'raw_iteration',
|
||||
)
|
||||
const raw_loop = this.widgets.find((w) => w.name === 'raw_loop')
|
||||
|
||||
const total_frames = this.widgets.find(
|
||||
(w) => w.name === 'total_frames'
|
||||
(w) => w.name === 'total_frames',
|
||||
)
|
||||
const loop_count = this.widgets.find((w) => w.name === 'loop_count')
|
||||
|
||||
@@ -804,12 +703,12 @@ const mtb_widgets = {
|
||||
raw_iteration._value = 0
|
||||
|
||||
const value_preview = this.addCustomWidget(
|
||||
MtbWidgets['DEBUG_STRING']('value_preview', 'Idle')
|
||||
MtbWidgets['DEBUG_STRING']('value_preview', 'Idle'),
|
||||
)
|
||||
value_preview.parent = this
|
||||
|
||||
const loop_preview = this.addCustomWidget(
|
||||
MtbWidgets['DEBUG_STRING']('loop_preview', 'Iteration: Idle')
|
||||
MtbWidgets['DEBUG_STRING']('loop_preview', 'Iteration: Idle'),
|
||||
)
|
||||
loop_preview.parent = this
|
||||
|
||||
@@ -827,7 +726,7 @@ const mtb_widgets = {
|
||||
'button',
|
||||
`Reset`,
|
||||
'reset',
|
||||
onReset
|
||||
onReset,
|
||||
)
|
||||
|
||||
const run_button = this.addWidget('button', `Queue`, 'queue', () => {
|
||||
@@ -837,17 +736,12 @@ const mtb_widgets = {
|
||||
`Started a queue of ${total_frames.value} frames (for ${
|
||||
loop_count.value
|
||||
} loop, so ${total_frames.value * loop_count.value})`,
|
||||
5000
|
||||
5000,
|
||||
)
|
||||
})
|
||||
|
||||
this.onRemoved = () => {
|
||||
for (const w of this.widgets) {
|
||||
if (w.canvas) {
|
||||
w.canvas.remove()
|
||||
}
|
||||
w.onRemoved?.()
|
||||
}
|
||||
shared.cleanupNode(this)
|
||||
app.canvas.setDirty(true)
|
||||
}
|
||||
|
||||
@@ -879,7 +773,7 @@ const mtb_widgets = {
|
||||
type,
|
||||
index,
|
||||
connected,
|
||||
link_info
|
||||
link_info,
|
||||
) {
|
||||
const r = onConnectionsChange
|
||||
? onConnectionsChange.apply(this, arguments)
|
||||
@@ -890,6 +784,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) {
|
||||
@@ -924,7 +852,7 @@ const mtb_widgets = {
|
||||
if (style && style.length >= 1) {
|
||||
if (style[0]) {
|
||||
window.MTB?.notify?.(
|
||||
`Extracted positive from ${this.widgets[0].value}`
|
||||
`Extracted positive from ${this.widgets[0].value}`,
|
||||
)
|
||||
const tn = LiteGraph.createNode('Text box')
|
||||
app.graph.add(tn)
|
||||
@@ -932,7 +860,7 @@ const mtb_widgets = {
|
||||
tn.widgets[0].value = style[0]
|
||||
} else {
|
||||
window.MTB?.notify?.(
|
||||
`No positive to extract for ${this.widgets[0].value}`
|
||||
`No positive to extract for ${this.widgets[0].value}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -945,7 +873,7 @@ const mtb_widgets = {
|
||||
if (style && style.length >= 2) {
|
||||
if (style[1]) {
|
||||
window.MTB?.notify?.(
|
||||
`Extracted negative from ${this.widgets[0].value}`
|
||||
`Extracted negative from ${this.widgets[0].value}`,
|
||||
)
|
||||
const tn = LiteGraph.createNode('Text box')
|
||||
app.graph.add(tn)
|
||||
@@ -953,7 +881,7 @@ const mtb_widgets = {
|
||||
tn.widgets[0].value = style[1]
|
||||
} else {
|
||||
window.MTB.notify(
|
||||
`No negative to extract for ${this.widgets[0].value}`
|
||||
`No negative to extract for ${this.widgets[0].value}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -965,6 +893,75 @@ const mtb_widgets = {
|
||||
|
||||
break
|
||||
}
|
||||
case 'Apply Text Template (mtb)': {
|
||||
shared.setupDynamicConnections(nodeType, 'var', '*')
|
||||
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)':
|
||||
case 'Plot Batch Float (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,697 @@
|
||||
import { app } from '../../scripts/app.js'
|
||||
import * as shared from './comfy_shared.js'
|
||||
import { infoLogger, successLogger, errorLogger } from './comfy_shared.js'
|
||||
|
||||
const DEFAULT_CSS = ''
|
||||
const DEFAULT_HTML = `<p style='color:red;font-family:monospace'>
|
||||
Note+
|
||||
</p>`
|
||||
const DEFAULT_MD = '## Note+'
|
||||
const DEFAULT_MODE = 'markdown'
|
||||
const DEFAULT_THEME = 'one_dark'
|
||||
|
||||
const CSS_RESET = `
|
||||
* {
|
||||
font-family: monospace;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
p, ul, ol, dl, blockquote {
|
||||
margin: 0.3em;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
|
||||
ul, ol {
|
||||
|
||||
padding-left: 1em;
|
||||
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
pointer-events: all;
|
||||
color: cyan;
|
||||
}
|
||||
|
||||
img {
|
||||
padding: 1em 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
iframe {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
border:none;
|
||||
pointer-events:all;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #ccc;
|
||||
padding-left: 1em;
|
||||
margin-left: 0;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
pre, code {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
border-bottom: 1px solid #000;
|
||||
margin: 1em 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border-left: 1px solid #000;
|
||||
border-right: 1px solid #000;
|
||||
padding: 8px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
border: 1px solid #000;
|
||||
|
||||
background-color: rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
`
|
||||
|
||||
const themes = [
|
||||
'ambiance',
|
||||
'chaos',
|
||||
'chrome',
|
||||
'cloud9_day',
|
||||
'cloud9_night',
|
||||
'cloud9_night_low_color',
|
||||
'cloud_editor',
|
||||
'cloud_editor_dark',
|
||||
'clouds',
|
||||
'clouds_midnight',
|
||||
'cobalt',
|
||||
'crimson_editor',
|
||||
'dawn',
|
||||
'dracula',
|
||||
'dreamweaver',
|
||||
'eclipse',
|
||||
'github',
|
||||
'github_dark',
|
||||
'gob',
|
||||
'gruvbox',
|
||||
'gruvbox_dark_hard',
|
||||
'gruvbox_light_hard',
|
||||
'idle_fingers',
|
||||
'iplastic',
|
||||
'katzenmilch',
|
||||
'kr_theme',
|
||||
'kuroir',
|
||||
'merbivore',
|
||||
'merbivore_soft',
|
||||
'mono_industrial',
|
||||
'monokai',
|
||||
'nord_dark',
|
||||
'one_dark',
|
||||
'pastel_on_dark',
|
||||
'solarized_dark',
|
||||
'solarized_light',
|
||||
'sqlserver',
|
||||
'terminal',
|
||||
'textmate',
|
||||
'tomorrow',
|
||||
'tomorrow_night',
|
||||
'tomorrow_night_blue',
|
||||
'tomorrow_night_bright',
|
||||
'tomorrow_night_eighties',
|
||||
'twilight',
|
||||
'vibrant_ink',
|
||||
'vscode',
|
||||
]
|
||||
class NotePlus extends LiteGraph.LGraphNode {
|
||||
title = 'Note+ (mtb)'
|
||||
category = 'mtb/utils'
|
||||
|
||||
// same values as the comfy note
|
||||
color = LGraphCanvas.node_colors.yellow.color
|
||||
bgcolor = LGraphCanvas.node_colors.yellow.bgcolor
|
||||
groupcolor = LGraphCanvas.node_colors.yellow.groupcolor
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.uuid = shared.makeUUID()
|
||||
|
||||
infoLogger('Constructing Note+ instance')
|
||||
// - litegraph settings
|
||||
this.collapsable = true
|
||||
this.isVirtualNode = true
|
||||
this.shape = LiteGraph.BOX_SHAPE
|
||||
this.serialize_widgets = true
|
||||
|
||||
// - default values, serialization is done through widgets
|
||||
this._raw_html = DEFAULT_MODE === 'html' ? DEFAULT_HTML : DEFAULT_MD
|
||||
|
||||
// - mardown converter
|
||||
this.markdownConverter = new showdown.Converter({
|
||||
tables: true,
|
||||
strikethrough: true,
|
||||
emoji: true,
|
||||
ghCodeBlocks: true,
|
||||
tasklists: true,
|
||||
ghMentions: true,
|
||||
smoothLivePreview: true,
|
||||
simplifiedAutoLink: true,
|
||||
parseImgDimensions: true,
|
||||
openLinksInNewWindow: true,
|
||||
})
|
||||
|
||||
// - state
|
||||
this.live = true
|
||||
this.calculated_height = 0
|
||||
|
||||
// - add widgets
|
||||
const inner = document.createElement('div')
|
||||
inner.style.margin = '0'
|
||||
inner.style.padding = '0'
|
||||
inner.style.pointerEvents = 'none'
|
||||
this.html_widget = this.addDOMWidget('HTML', 'html', inner, {
|
||||
setValue: (val) => {
|
||||
this._raw_html = val
|
||||
},
|
||||
getValue: () => this._raw_html,
|
||||
getMinHeight: () => this.calculated_height, // (the edit button),
|
||||
hideOnZoom: false,
|
||||
})
|
||||
|
||||
this.setupSerializationWidgets()
|
||||
this.setupDialog()
|
||||
this.loadAceEditor()
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {CanvasRenderingContext2D} ctx
|
||||
* @param {LGraphCanvas} graphcanvas
|
||||
* @returns
|
||||
*/
|
||||
|
||||
onDrawForeground(ctx, _graphcanvas) {
|
||||
if (this.flags.collapsed) return
|
||||
|
||||
// Define the size and position of the icon
|
||||
const iconSize = 14 // Size of the icon
|
||||
const iconMargin = 8 // Margin from the edges
|
||||
const x = this.size[0] - iconSize - iconMargin
|
||||
const y = iconMargin * 1.5
|
||||
|
||||
// Create a new Path2D object from SVG path data
|
||||
const pencilPath = new Path2D(
|
||||
'M21.28 6.4l-9.54 9.54c-.95.95-3.77 1.39-4.4.76-.63-.63-.2-3.45.75-4.4l9.55-9.55a2.58 2.58 0 1 1 3.64 3.65z',
|
||||
)
|
||||
const folderPath = new Path2D(
|
||||
'M11 4H6a4 4 0 0 0-4 4v10a4 4 0 0 0 4 4h11c2.21 0 3-1.8 3-4v-5',
|
||||
)
|
||||
|
||||
// Draw the paths
|
||||
ctx.save()
|
||||
ctx.translate(x, y) // Position the icon on the canvas
|
||||
ctx.scale(iconSize / 32, iconSize / 32) // Scale the icon to the desired size
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.3)'
|
||||
|
||||
ctx.lineCap = 'round'
|
||||
ctx.lineJoin = 'round'
|
||||
|
||||
ctx.lineWidth = 2.4
|
||||
ctx.stroke(pencilPath)
|
||||
ctx.stroke(folderPath)
|
||||
ctx.restore()
|
||||
}
|
||||
onMouseDown(_e, localPos, _graphcanvas) {
|
||||
// Check if the click is within the pencil icon bounds
|
||||
const iconSize = 14
|
||||
const iconMargin = 8
|
||||
const iconX = this.size[0] - iconSize - iconMargin
|
||||
const iconY = iconMargin * 1.5
|
||||
|
||||
if (
|
||||
localPos[0] > iconX &&
|
||||
localPos[0] < iconX + iconSize &&
|
||||
localPos[1] > iconY &&
|
||||
localPos[1] < iconY + iconSize
|
||||
) {
|
||||
// Pencil icon was clicked, open the editor
|
||||
this.openEditorDialog()
|
||||
return true // Return true to indicate the event was handled
|
||||
}
|
||||
|
||||
return false // Return false to let the event propagate
|
||||
}
|
||||
|
||||
setupSerializationWidgets() {
|
||||
infoLogger('Setup Serializing widgets')
|
||||
|
||||
this.edit_mode_widget = this.addWidget(
|
||||
'combo',
|
||||
'Mode',
|
||||
DEFAULT_MODE,
|
||||
(me) => successLogger('Updating edit_mode', me),
|
||||
{
|
||||
values: ['html', 'markdown', 'raw'],
|
||||
},
|
||||
)
|
||||
this.css_widget = this.addWidget('text', 'CSS', DEFAULT_CSS, (val) => {
|
||||
successLogger(`Updating css ${val}`)
|
||||
})
|
||||
this.theme_widget = this.addWidget(
|
||||
'text',
|
||||
'Theme',
|
||||
DEFAULT_THEME,
|
||||
(val) => {
|
||||
successLogger(`Setting theme ${val}`)
|
||||
},
|
||||
)
|
||||
|
||||
shared.hideWidgetForGood(this, this.edit_mode_widget)
|
||||
shared.hideWidgetForGood(this, this.css_widget)
|
||||
shared.hideWidgetForGood(this, this.theme_widget)
|
||||
}
|
||||
setupDialog() {
|
||||
infoLogger('Setup dialog')
|
||||
// this.addWidget('button', 'Edit', 'Edit', this.openEditorDialog.bind(this))
|
||||
|
||||
this.dialog = new app.ui.dialog.constructor()
|
||||
this.dialog.element.classList.add('comfy-settings')
|
||||
|
||||
const closeButton = this.dialog.element.querySelector('button')
|
||||
closeButton.textContent = 'CANCEL'
|
||||
const saveButton = document.createElement('button')
|
||||
saveButton.textContent = 'SAVE'
|
||||
saveButton.onclick = () => {
|
||||
this.closeEditorDialog(true)
|
||||
}
|
||||
closeButton.onclick = () => {
|
||||
this.closeEditorDialog(false)
|
||||
}
|
||||
closeButton.before(saveButton)
|
||||
}
|
||||
|
||||
teardownEditors() {
|
||||
this.css_editor.destroy()
|
||||
this.css_editor.container.remove()
|
||||
|
||||
this.html_editor.destroy()
|
||||
this.html_editor.container.remove()
|
||||
}
|
||||
|
||||
closeEditorDialog(accept) {
|
||||
infoLogger('Closing editor dialog', accept)
|
||||
if (accept) {
|
||||
this.updateHTML(this.html_editor.getValue())
|
||||
this.updateCSS(this.css_editor.getValue())
|
||||
}
|
||||
this.teardownEditors()
|
||||
this.dialog.close()
|
||||
}
|
||||
|
||||
openEditorDialog() {
|
||||
infoLogger(`Current edit mode ${this.edit_mode_widget.value}`)
|
||||
const container = document.createElement('div')
|
||||
|
||||
Object.assign(container.style, {
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
flexDirection: 'column',
|
||||
})
|
||||
|
||||
const editorsContainer = document.createElement('div')
|
||||
Object.assign(editorsContainer.style, {
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
flexDirection: 'row',
|
||||
})
|
||||
|
||||
container.append(editorsContainer)
|
||||
|
||||
this.dialog.show('')
|
||||
this.dialog.textElement.append(container)
|
||||
|
||||
const aceHTML = document.createElement('div')
|
||||
aceHTML.id = 'noteplus-html-editor'
|
||||
Object.assign(aceHTML.style, {
|
||||
width: '300px',
|
||||
height: '300px',
|
||||
// backgroundColor: 'rgb(30,30,30)',
|
||||
// color: 'whitesmoke',
|
||||
})
|
||||
|
||||
editorsContainer.append(aceHTML)
|
||||
|
||||
const aceCSS = document.createElement('div')
|
||||
aceCSS.id = 'noteplus-css-editor'
|
||||
Object.assign(aceCSS.style, {
|
||||
width: '300px',
|
||||
height: '300px',
|
||||
// backgroundColor: 'rgb(30,30,30)',
|
||||
// color: 'whitesmoke',
|
||||
})
|
||||
|
||||
editorsContainer.append(aceCSS)
|
||||
|
||||
const live_edit = document.createElement('input')
|
||||
live_edit.type = 'checkbox'
|
||||
live_edit.checked = this.live
|
||||
live_edit.onchange = () => {
|
||||
this.live = live_edit.checked
|
||||
}
|
||||
|
||||
//- "Dynamic" elements
|
||||
const firstButton = this.dialog.element.querySelector('button')
|
||||
const syncUI = () => {
|
||||
let convert_to_html =
|
||||
this.dialog.element.querySelector('#convert-to-html')
|
||||
if (this.edit_mode_widget.value === 'markdown') {
|
||||
if (convert_to_html == null) {
|
||||
convert_to_html = document.createElement('button')
|
||||
convert_to_html.textContent = 'Convert to HTML (NO UNDO!)'
|
||||
convert_to_html.id = 'convert-to-html'
|
||||
convert_to_html.onclick = () => {
|
||||
const select_mode = this.dialog.element.querySelector('#edit_mode')
|
||||
|
||||
const md = this.html_editor.getValue()
|
||||
this.edit_mode_widget.value = 'html'
|
||||
select_mode.value = 'html'
|
||||
const html = this.markdownConverter.makeHtml(md)
|
||||
this.html_widget.value = html
|
||||
this.html_editor.setValue(html)
|
||||
this.html_editor.session.setMode('ace/mode/html')
|
||||
this.updateHTML(this.html_widget.value)
|
||||
|
||||
convert_to_html.remove()
|
||||
}
|
||||
|
||||
firstButton.before(convert_to_html)
|
||||
}
|
||||
} else {
|
||||
if (convert_to_html != null) {
|
||||
convert_to_html.remove()
|
||||
convert_to_html = null
|
||||
}
|
||||
}
|
||||
select_mode.value = this.edit_mode_widget.value
|
||||
}
|
||||
//- combobox
|
||||
let theme_select = this.dialog.element.querySelector('#theme_select')
|
||||
if (!theme_select) {
|
||||
infoLogger('Creating combobox for select')
|
||||
theme_select = document.createElement('select')
|
||||
theme_select.name = 'theme'
|
||||
theme_select.id = 'theme_select'
|
||||
|
||||
const addOption = (label) => {
|
||||
const option = document.createElement('option')
|
||||
option.value = label
|
||||
option.textContent = label
|
||||
theme_select.append(option)
|
||||
}
|
||||
for (const t of themes) {
|
||||
addOption(t)
|
||||
}
|
||||
|
||||
theme_select.addEventListener('change', (event) => {
|
||||
const val = event.target.value
|
||||
this.setTheme(val)
|
||||
})
|
||||
|
||||
container.prepend(theme_select)
|
||||
}
|
||||
|
||||
theme_select.value = this.theme_widget.value
|
||||
|
||||
let select_mode = this.dialog.element.querySelector('#edit_mode')
|
||||
|
||||
if (!select_mode) {
|
||||
infoLogger('Creating combobox for select')
|
||||
select_mode = document.createElement('select')
|
||||
select_mode.name = 'mode'
|
||||
select_mode.id = 'edit_mode'
|
||||
|
||||
const addOption = (label) => {
|
||||
const option = document.createElement('option')
|
||||
option.value = label
|
||||
option.textContent = label
|
||||
select_mode.append(option)
|
||||
}
|
||||
addOption('markdown')
|
||||
addOption('html')
|
||||
|
||||
select_mode.addEventListener('change', (event) => {
|
||||
const val = event.target.value
|
||||
this.edit_mode_widget.value = val
|
||||
if (this.html_editor) {
|
||||
this.html_editor.session.setMode(`ace/mode/${val}`)
|
||||
this.updateHTML(this.html_editor.getValue())
|
||||
|
||||
syncUI()
|
||||
}
|
||||
})
|
||||
|
||||
container.append(select_mode)
|
||||
}
|
||||
select_mode.value = this.edit_mode_widget.value
|
||||
|
||||
syncUI()
|
||||
|
||||
const live_edit_label = document.createElement('label')
|
||||
live_edit_label.textContent = 'Live Edit'
|
||||
|
||||
// add a tooltip
|
||||
live_edit_label.title =
|
||||
'When this is on, the editor will update the note+ whenever you change the text.'
|
||||
|
||||
live_edit_label.append(live_edit)
|
||||
|
||||
// select_mode.before(live_edit_label)
|
||||
container.append(live_edit_label)
|
||||
|
||||
this.setupEditors()
|
||||
}
|
||||
loadAceEditor() {
|
||||
shared
|
||||
.loadScript(
|
||||
'https://cdn.jsdelivr.net/npm/ace-builds@1.16.0/src-min-noconflict/ace.min.js',
|
||||
)
|
||||
.catch((e) => {
|
||||
errorLogger(e)
|
||||
})
|
||||
}
|
||||
onCreate() {
|
||||
errorLogger('NotePlus onCreate')
|
||||
}
|
||||
configure(info) {
|
||||
super.configure(info)
|
||||
infoLogger('Restoring serialized values', info)
|
||||
// - update view from serialzed data
|
||||
this.html_widget.element.id = `note-plus-${this.uuid}`
|
||||
this.setMode(this.edit_mode_widget.value)
|
||||
this.setTheme(this.theme_widget.value)
|
||||
this.updateHTML(this.html_widget.value)
|
||||
this.updateCSS(this.css_widget.value)
|
||||
this.setSize(info.size)
|
||||
}
|
||||
onNodeCreated() {
|
||||
infoLogger('Node created', this.uuid)
|
||||
this.html_widget.element.id = `note-plus-${this.uuid}`
|
||||
this.setMode(this.edit_mode_widget.value)
|
||||
this.setTheme(this.theme_widget.value)
|
||||
this.updateHTML(this.html_widget.value) // widget is populated here since we called super
|
||||
this.updateCSS(this.css_widget.value)
|
||||
}
|
||||
onRemoved() {
|
||||
infoLogger('Node removed', this.uuid)
|
||||
}
|
||||
getExtraMenuOptions() {
|
||||
const options = []
|
||||
// {
|
||||
// content: string;
|
||||
// callback?: ContextMenuEventListener;
|
||||
// /** Used as innerHTML for extra child element */
|
||||
// title?: string;
|
||||
// disabled?: boolean;
|
||||
// has_submenu?: boolean;
|
||||
// submenu?: {
|
||||
// options: ContextMenuItem[];
|
||||
// } & IContextMenuOptions;
|
||||
// className?: string;
|
||||
// }
|
||||
options.push({
|
||||
content: `Set to ${
|
||||
this.edit_mode_widget.value === 'html' ? 'markdown' : 'html'
|
||||
}`,
|
||||
callback: () => {
|
||||
this.edit_mode_widget.value =
|
||||
this.edit_mode_widget.value === 'html' ? 'markdown' : 'html'
|
||||
this.updateHTML(this.html_widget.value)
|
||||
},
|
||||
})
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
_setupEditor(editor) {
|
||||
this.setTheme(this.theme_widget.value)
|
||||
|
||||
editor.setShowPrintMargin(false)
|
||||
editor.session.setUseWrapMode(true)
|
||||
editor.renderer.setShowGutter(false)
|
||||
editor.session.setTabSize(4)
|
||||
editor.session.setUseSoftTabs(true)
|
||||
editor.setFontSize(14)
|
||||
editor.setReadOnly(false)
|
||||
editor.setHighlightActiveLine(false)
|
||||
editor.setShowFoldWidgets(true)
|
||||
return editor
|
||||
}
|
||||
|
||||
setTheme(theme) {
|
||||
this.theme_widget.value = theme
|
||||
if (this.html_editor) {
|
||||
this.html_editor.setTheme(`ace/theme/${theme}`)
|
||||
}
|
||||
if (this.css_editor) {
|
||||
this.css_editor.setTheme(`ace/theme/${theme}`)
|
||||
}
|
||||
}
|
||||
|
||||
setMode(mode) {
|
||||
this.edit_mode_widget.value = mode
|
||||
if (this.html_editor) {
|
||||
this.html_editor.session.setMode(`ace/mode/${mode}`)
|
||||
}
|
||||
|
||||
this.updateHTML(this.html_widget.value)
|
||||
}
|
||||
setupEditors() {
|
||||
infoLogger('NotePlus setupEditor')
|
||||
|
||||
this.html_editor = ace.edit('noteplus-html-editor')
|
||||
|
||||
this.css_editor = ace.edit('noteplus-css-editor')
|
||||
this.css_editor.session.setMode('ace/mode/css')
|
||||
|
||||
this.setMode(DEFAULT_MODE)
|
||||
|
||||
this._setupEditor(this.html_editor)
|
||||
this._setupEditor(this.css_editor)
|
||||
|
||||
this.css_editor.session.on('change', (_delta) => {
|
||||
// delta.start, delta.end, delta.lines, delta.action
|
||||
if (this.live) {
|
||||
this.updateCSS(this.css_editor.getValue())
|
||||
}
|
||||
})
|
||||
|
||||
this.html_editor.session.on('change', (_delta) => {
|
||||
// delta.start, delta.end, delta.lines, delta.action
|
||||
if (this.live) {
|
||||
this.updateHTML(this.html_editor.getValue())
|
||||
}
|
||||
})
|
||||
|
||||
this.html_editor.setValue(this.html_widget.value)
|
||||
this.css_editor.setValue(this.css_widget.value)
|
||||
}
|
||||
|
||||
scopeCss(css, scopeId) {
|
||||
return css
|
||||
.split('}')
|
||||
.map((rule) => {
|
||||
if (rule.trim() === '') {
|
||||
return ''
|
||||
}
|
||||
const scopedRule = rule
|
||||
.split('{')
|
||||
.map((segment, index) => {
|
||||
if (index === 0) {
|
||||
return `#${scopeId} ${segment.trim()}`
|
||||
}
|
||||
return `{${segment.trim()}`
|
||||
})
|
||||
.join(' ')
|
||||
return `${scopedRule}}`
|
||||
})
|
||||
.join('\n')
|
||||
}
|
||||
getCssDom() {
|
||||
const styleTagId = `note-plus-stylesheet-${this.uuid}`
|
||||
|
||||
let styleTag = document.head.querySelector(`#${styleTagId}`)
|
||||
|
||||
if (!styleTag) {
|
||||
styleTag = document.createElement('style')
|
||||
styleTag.type = 'text/css'
|
||||
styleTag.id = styleTagId
|
||||
document.head.appendChild(styleTag)
|
||||
infoLogger(`Creating note-plus-stylesheet-${this.uuid}`, styleTag)
|
||||
}
|
||||
|
||||
return styleTag
|
||||
}
|
||||
calculateHeight() {
|
||||
this.calculated_height = shared.calculateTotalChildrenHeight(
|
||||
this.html_widget.element,
|
||||
)
|
||||
this.setDirtyCanvas(true, true)
|
||||
}
|
||||
updateCSS(css) {
|
||||
infoLogger('NotePlus updateCSS')
|
||||
// this.html_widget.element.style = css
|
||||
const scopedCss = this.scopeCss(
|
||||
`${CSS_RESET}\n${css}`,
|
||||
`note-plus-${this.uuid}`,
|
||||
)
|
||||
|
||||
const cssDom = this.getCssDom()
|
||||
cssDom.innerHTML = scopedCss
|
||||
|
||||
this.css_widget.value = css
|
||||
this.calculateHeight()
|
||||
infoLogger('NotePlus updateCSS', this.calculated_height)
|
||||
// this.setSize(this.computeSize())
|
||||
}
|
||||
|
||||
updateHTML(val) {
|
||||
const cleanHTML = DOMPurify.sanitize(val, { ADD_TAGS: ['iframe'] })
|
||||
this.html_widget.value = cleanHTML
|
||||
|
||||
// update our widget preview
|
||||
if (this.edit_mode_widget.value === 'html') {
|
||||
this.html_widget.element.innerHTML = cleanHTML
|
||||
} else if (this.edit_mode_widget.value === 'markdown') {
|
||||
this.html_widget.element.innerHTML =
|
||||
this.markdownConverter.makeHtml(cleanHTML)
|
||||
}
|
||||
this.calculateHeight()
|
||||
// this.setSize(this.computeSize())
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'mtb.noteplus',
|
||||
|
||||
registerCustomNodes() {
|
||||
LiteGraph.registerNodeType('Note Plus (mtb)', NotePlus)
|
||||
|
||||
NotePlus.title_mode = LiteGraph.NO_TITLE
|
||||
},
|
||||
})
|
||||
+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