Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d576190f07 | ||
|
|
247ad12ec3 | ||
|
|
881573e2f2 | ||
|
|
6f3a5b5c71 | ||
|
|
fa017fbb81 |
@@ -5,20 +5,14 @@ on:
|
||||
tags:
|
||||
- '*'
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
publish-node:
|
||||
name: Publish Custom Node to registry
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ github.repository_owner == 'melMass' }}
|
||||
steps:
|
||||
- name: ♻️ Check out code
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
submodules: true
|
||||
- name: 📦 Publish Custom Node
|
||||
uses: Comfy-Org/publish-node-action@v1
|
||||
uses: Comfy-Org/publish-node-action@main
|
||||
with:
|
||||
personal_access_token: ${{ secrets.COMFY_REGISTRY_TOKEN }}
|
||||
|
||||
@@ -6,6 +6,3 @@ node_modules/
|
||||
compose.yaml
|
||||
comfy_mtb.wsb
|
||||
Dockerfile
|
||||
|
||||
# I store the gh-pages worktrees (src & build) there
|
||||
.worktrees
|
||||
|
||||
+2
-238
@@ -3,193 +3,10 @@
|
||||
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] - 2025-04-16
|
||||
## [main] - 2024-03-07
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🐛 note+ breaking wfs ([af42134](https://github.com/melMass/comfy_mtb/commit/af421340286b234e4c0cfcd4143a9d8726ebf3d1))
|
||||
- 🐛 ColorCorrect clamp issue ([a85e57b](https://github.com/melMass/comfy_mtb/commit/a85e57b18c7d3c765131873ffff523244ca9be73))
|
||||
- 🐛 Whisper chunks processing ([8bf3545](https://github.com/melMass/comfy_mtb/commit/8bf3545fec5b2a180607d40394b025a1e09c14b6))
|
||||
- 🐛 stackImages move to device ([d87e52e](https://github.com/melMass/comfy_mtb/commit/d87e52ea2c112fd95f257dcd6a54a5db77a34fc3))
|
||||
- 🐛 bbox upscale from center ([55261bd](https://github.com/melMass/comfy_mtb/commit/55261bda7c33d088b62c5483e4483201e5a9ce77))
|
||||
- 🐛 add MASK support for PickFromBatch ([0d264b9](https://github.com/melMass/comfy_mtb/commit/0d264b90a78d5a6719fb3ce71f4e9a642db4c950))
|
||||
- 🐛 use addDOMWidget for Debug node ([46af602](https://github.com/melMass/comfy_mtb/commit/46af6027d6c87d0c29b8bb0fd1cc1dbdae993629))
|
||||
- 🐛 use "modern" notation in toDevice ([b7ca8ed](https://github.com/melMass/comfy_mtb/commit/b7ca8ed1c6e117b71afd7696f55dcc3dbd5bad08))
|
||||
- 🐛 handle missing submodules ([d61da30](https://github.com/melMass/comfy_mtb/commit/d61da304099ff5e4528e4beb1ecc2eb83cabaaa1))
|
||||
- 🐛 add warnings about what each IO mode can do ([6608c0b](https://github.com/melMass/comfy_mtb/commit/6608c0b6d1cf8f7a9901214096f8c78bfe17056f))
|
||||
- 🐛 soft deprecate compression h264 ([a757e1c](https://github.com/melMass/comfy_mtb/commit/a757e1c98b2abbd2221a15b77e89d772e02d1d82))
|
||||
- 🐛 limit packages allowed to be installed from API ([d6e004c](https://github.com/melMass/comfy_mtb/commit/d6e004cce2c32f8e48b868e66b89f82da4887dc3))
|
||||
- 🐛 ensure default settings (io sidebar) ([ed17fa2](https://github.com/melMass/comfy_mtb/commit/ed17fa2ef4688aadf305a6d51b32c13a0efd22d6))
|
||||
- 🐛 spawn colour picker at pointer location ([e5482ae](https://github.com/melMass/comfy_mtb/commit/e5482aee5e3de07e8f055b3edc0fccc0e0f75c14)) by [@webfiltered](https://github.com/webfiltered) in [#223](https://github.com/melMass/comfy_mtb/pull/223)
|
||||
- 🐛 i/o sidebar for custom paths ([62469a4](https://github.com/melMass/comfy_mtb/commit/62469a4dd96e32509171aad74fcae8d2bb0ec593))
|
||||
|
||||
### Features
|
||||
|
||||
- ⚡ add BatchFromFolder ([9618513](https://github.com/melMass/comfy_mtb/commit/96185132b83c182032e9f6e822561eb5699af517))
|
||||
- ⚡ add use_normalized to TransformBatch2D ([d4a31bf](https://github.com/melMass/comfy_mtb/commit/d4a31bf19c2863df8dfc4cb9a3cd6683304949e4))
|
||||
- [**breaking**] ⚡ add support for masks in BatchFLoatMath ([fc7ba08](https://github.com/melMass/comfy_mtb/commit/fc7ba084f6ed7880e88e28eb448ab0bd7d796824))
|
||||
- ✨ add use_normalized to TransformImage ([4516aa9](https://github.com/melMass/comfy_mtb/commit/4516aa9cb4fcb12c946999d6dcc1501cc09011a3))
|
||||
- ✨ add regex support for String Replace ([78946b0](https://github.com/melMass/comfy_mtb/commit/78946b0fa3c3cf5dfcee8c7c4c0921b722d09d1e)) by [@poetryiii](https://github.com/poetryiii) in [#233](https://github.com/melMass/comfy_mtb/pull/233)
|
||||
- ✨ update diarization to 3.1 ([c30408f](https://github.com/melMass/comfy_mtb/commit/c30408f96d4df9c7d35545654401162090a74305)) by [@numz](https://github.com/numz)
|
||||
- ✨ add "workflow" query to /mtb/view endpoint ([eb7cf89](https://github.com/melMass/comfy_mtb/commit/eb7cf89f173b2342b04e7b61dca3d12cfaf65bdb))
|
||||
- ✨ add stretch_x and stretch_y to TransformImage ([22fce6f](https://github.com/melMass/comfy_mtb/commit/22fce6fdda135cbb1f1aad42c86aae166cba81b5))
|
||||
- ✨ add AudioDuration node ([f471497](https://github.com/melMass/comfy_mtb/commit/f47149746ac1e418cda2007c38aafbb03946ce22))
|
||||
- ✨ basic whisper nodes ([83cfc5c](https://github.com/melMass/comfy_mtb/commit/83cfc5c723d1a572af67ad14b52be4f8371a3c5f))
|
||||
- ✨ add BboxForDimensions ([9405784](https://github.com/melMass/comfy_mtb/commit/940578476438eaa6a42e0056f1b7b319ee585334))
|
||||
- ✨ improve the debug node ([cf7a9c4](https://github.com/melMass/comfy_mtb/commit/cf7a9c41e81e8dd461ab9dfa3c05bb8e2cdf2a67))
|
||||
- ✨ add BatchImageToSublist and counterpart ([00173fa](https://github.com/melMass/comfy_mtb/commit/00173fa3fbca4c5b1ff3016cc5139705ce61ec20))
|
||||
- ✨ add TensorOps ([a8cf465](https://github.com/melMass/comfy_mtb/commit/a8cf4650ff5cbd4975ef954b5829c772ee53250c))
|
||||
- ✨ live update outputs grid ([7f7a62f](https://github.com/melMass/comfy_mtb/commit/7f7a62f832c865a13b9181daee79d3cfc21581e2)) by [@christian-byrne](https://github.com/christian-byrne) in [#229](https://github.com/melMass/comfy_mtb/pull/229)
|
||||
- ✨ add SaveImage passthrough ([0eeb707](https://github.com/melMass/comfy_mtb/commit/0eeb707f34f51142def8e0ef7d351ee5028cb5e0))
|
||||
- ✨ add filtering to TransformImage ([bae26a0](https://github.com/melMass/comfy_mtb/commit/bae26a07fb02dd518c621eba28986a51c5d086bc))
|
||||
- ✨ add support for video in I/O sidebar ([c92d99a](https://github.com/melMass/comfy_mtb/commit/c92d99a8a37a64cfc285296f21452c4927a22774))
|
||||
- ✨ add an extra static input to Stack Images ([3f6d082](https://github.com/melMass/comfy_mtb/commit/3f6d08294096918d50101a19083f9134305cc8c9)) in [#222](https://github.com/melMass/comfy_mtb/pull/222)
|
||||
- ✨ add support for subdirs (i/o sidebar) ([52bd76e](https://github.com/melMass/comfy_mtb/commit/52bd76e19c8bd7e72986900e5dbfade0457ef7e0))
|
||||
- ✨ add Batch Sequence Nodes ([827c64c](https://github.com/melMass/comfy_mtb/commit/827c64c43d52ebfb8acd2e5c4491c4b66e6b8f40))
|
||||
- ✨ add support for more formats (I/O sidebar) ([8c629be](https://github.com/melMass/comfy_mtb/commit/8c629bee186b5ac991058018a788e4a836eef630))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- 🧹 bump version ([d093d76](https://github.com/melMass/comfy_mtb/commit/d093d76efd87474a3ca82858147255038060ab17))
|
||||
- 🧹 small adjustments ([01107c4](https://github.com/melMass/comfy_mtb/commit/01107c45f8539ff7c579e08e2a9075d93781b9a2))
|
||||
- 🤖 update publish action workflow with permissions and version constraints ([0e48aaa](https://github.com/melMass/comfy_mtb/commit/0e48aaa3e4f1e440a5d7ab42df56b728ced03aca)) by [@robinjhuang](https://github.com/robinjhuang) in [#237](https://github.com/melMass/comfy_mtb/pull/237)
|
||||
- 🧹 basic standalone detection ([3a25526](https://github.com/melMass/comfy_mtb/commit/3a25526e818a1af8f886d2ad5c27101c4a0caa8b))
|
||||
- 🧹 rename type ([edcb3da](https://github.com/melMass/comfy_mtb/commit/edcb3da08bff66f9adcef8dcd37c3925e64d0135))
|
||||
- 🧹 update env file ([fc908ba](https://github.com/melMass/comfy_mtb/commit/fc908ba0a528523b7c1e37e34fb32f430746de0d))
|
||||
- 🧹 dev ([9a94371](https://github.com/melMass/comfy_mtb/commit/9a943714aada107bfd236e00fa1063872db7a834))
|
||||
- 🧹 apply formatting ([58ae89f](https://github.com/melMass/comfy_mtb/commit/58ae89f8e0f0f8b42825722a6aebc04da39847b1))
|
||||
|
||||
### Refactor
|
||||
|
||||
- 📦 add model autodownload ([147edcf](https://github.com/melMass/comfy_mtb/commit/147edcfcbc09dd27a0c787f9da568fb850c3308a))
|
||||
|
||||
### Wip
|
||||
|
||||
- 🚧 loop drawing ([ead4b34](https://github.com/melMass/comfy_mtb/commit/ead4b34e6dd03ea4ed309b246ef31c995325aa08))
|
||||
|
||||
## New Contributors
|
||||
* [@poetryiii](https://github.com/poetryiii) made their first contribution in [#233](https://github.com/melMass/comfy_mtb/pull/233)
|
||||
* [@numz](https://github.com/numz) made their first contribution in [#](https://github.com/melMass/comfy_mtb/pull/)
|
||||
* [@webfiltered](https://github.com/webfiltered) made their first contribution in [#223](https://github.com/melMass/comfy_mtb/pull/223)
|
||||
## [0.2.0] - 2024-12-08
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🐛 remove mtb sidebar ([b0d52f7](https://github.com/melMass/comfy_mtb/commit/b0d52f73051368df6de2d1e10ad28ca56df72803))
|
||||
- 🐛 always enable the I/O sidebar ([ec1cb1a](https://github.com/melMass/comfy_mtb/commit/ec1cb1ac17d14670aa756dfb1ae7542397b12559))
|
||||
- 🐛 ui shifts on animation builder ([ecbb220](https://github.com/melMass/comfy_mtb/commit/ecbb220de6a05f2e506ec43f2b786be983166157))
|
||||
- 🐛 category for settings ([b6fa571](https://github.com/melMass/comfy_mtb/commit/b6fa571fd2096ace60d03cab42dba9ca37d0cb27)) in [#211](https://github.com/melMass/comfy_mtb/pull/211)
|
||||
- 🐛 new UI issues ([f272526](https://github.com/melMass/comfy_mtb/commit/f272526bfc5da95e95d42cb4c613a0b9585b2577))
|
||||
- 🐛 disable old BOOL widget (legacy) ([8596b81](https://github.com/melMass/comfy_mtb/commit/8596b8184edb484c907475a77ac1dc9e4a5c92af))
|
||||
- 🐛 pass ONNX providers explicitely ([43092e4](https://github.com/melMass/comfy_mtb/commit/43092e44a4ea17f90fcfb12372da634fe4b79557))
|
||||
- 🐛 typo in mtb_widgets error catch ([80b5a0c](https://github.com/melMass/comfy_mtb/commit/80b5a0ca7459763e7662421bccd8636976eefddd)) by [@christian-byrne](https://github.com/christian-byrne) in [#197](https://github.com/melMass/comfy_mtb/pull/197)
|
||||
- 🐛 doc widget sidebar offset in the new ui ([81b3bc1](https://github.com/melMass/comfy_mtb/commit/81b3bc1651f06ad2fa7938f810d3f406f5e7c41c))
|
||||
- 🐛 don't fallback to eval ([997d2fb](https://github.com/melMass/comfy_mtb/commit/997d2fb13af6aadf36873ea2ea3317e56f405aef))
|
||||
- 🐛 rework main utils ([c99b081](https://github.com/melMass/comfy_mtb/commit/c99b0812ab4a4183ef9298fb8a7c954bc7c858b2))
|
||||
- 🐛 MaskToImage ([821a0ef](https://github.com/melMass/comfy_mtb/commit/821a0ef42735a0a97ab82be22a4fdc67c9cfc80e))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📚 update wiki ([e17c6e2](https://github.com/melMass/comfy_mtb/commit/e17c6e29f5111bf5085b1fe6f764cfd1aae709f2))
|
||||
- 📚 remove link ([5bc125d](https://github.com/melMass/comfy_mtb/commit/5bc125d2f08470c8900dfd89deca721835848917))
|
||||
- 📚 clean readme ([333f646](https://github.com/melMass/comfy_mtb/commit/333f646ab1959d2c944fb046275cc93a545d557c))
|
||||
|
||||
### Features
|
||||
|
||||
- ✨ add h264 compression node ([e32d1e0](https://github.com/melMass/comfy_mtb/commit/e32d1e02df5e3a9351f829513f7ee3ffb2934be4))
|
||||
- ✨ add postshot nodes ([27e03fa](https://github.com/melMass/comfy_mtb/commit/27e03fa23efffda461c6975b15fe3964de476cb3))
|
||||
- ✨ improve the I/O sidebar ([cd9e614](https://github.com/melMass/comfy_mtb/commit/cd9e614b1a385d6b06eacfaad62def1d69f09808)) in [#193](https://github.com/melMass/comfy_mtb/pull/193)
|
||||
- ✨ add UpscaleBBoxBy ([74af5c6](https://github.com/melMass/comfy_mtb/commit/74af5c6499ef5dd73ce66c4c21b8c3507d69b037))
|
||||
- ✨ simplified sidebar and backend ([22f7c30](https://github.com/melMass/comfy_mtb/commit/22f7c3037345a866c9ff0b06f6689748021cee63))
|
||||
- ✨ add Interpolate Condition ([0133fb9](https://github.com/melMass/comfy_mtb/commit/0133fb93bc944d0dd7593b89b36e5b2676d9397a))
|
||||
- ✨ dump of wip things... ([cf7d305](https://github.com/melMass/comfy_mtb/commit/cf7d30507e7e449c4489e6a1ca159d3d0486bc55))
|
||||
- ✨ use the new parser for documentations ([4e593bb](https://github.com/melMass/comfy_mtb/commit/4e593bb30be561e39f1790e3514f60bb39e5a261))
|
||||
- ✨ add @mtb/markdown-parser bundles ([097ca33](https://github.com/melMass/comfy_mtb/commit/097ca33b8e7b27148e183e91712dc34d98d1a69b))
|
||||
- ✨ add VitMatte nodes ([896a025](https://github.com/melMass/comfy_mtb/commit/896a025006f9c7809c5e0776393a28f908be8950))
|
||||
- ✨ add ColorCorrectGPU ([9651a70](https://github.com/melMass/comfy_mtb/commit/9651a7034120589b059329b21688708e42772453))
|
||||
- ✨ add Swap FG/BG colors to MaskToImage ([57683c3](https://github.com/melMass/comfy_mtb/commit/57683c3c7d299a117a26526d52de4c26f2ec0f69))
|
||||
- ✨ add Extract coordinates ([f99f92e](https://github.com/melMass/comfy_mtb/commit/f99f92e8f7b2d6fac56f7f40049715910e15cfee))
|
||||
- ✨ add AudioCut ([5681b46](https://github.com/melMass/comfy_mtb/commit/5681b464adce395086712b61159b2694150b8027))
|
||||
- ✨ add AudioStack ([8d0fcee](https://github.com/melMass/comfy_mtb/commit/8d0fcee2f3decc1cbbf3b850332e6b2a022e1377))
|
||||
- ✨ add AudioSequence node ([1078fc6](https://github.com/melMass/comfy_mtb/commit/1078fc6f0fb225b52536f25ec6a9fa0456a90595))
|
||||
- ✨ add Split Bbox node ([9007a70](https://github.com/melMass/comfy_mtb/commit/9007a70aa0d6b2ead0f68f7aff8ae8e3c4f3624f))
|
||||
- ✨ update lerp example ([1a0ebd5](https://github.com/melMass/comfy_mtb/commit/1a0ebd5173687784f279a9c2184c89fb3be01dc5))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- 🧹 bump minor ([50cb6f5](https://github.com/melMass/comfy_mtb/commit/50cb6f5ed6e5d9fecb9733ef3f7852b8500005e9))
|
||||
- 🧹 add worktree to gitignores ([9ccf572](https://github.com/melMass/comfy_mtb/commit/9ccf572a158caeab9bff53853e8f6fb85b76776d))
|
||||
- 🧹 remove dupe code ([e099d58](https://github.com/melMass/comfy_mtb/commit/e099d581a7627c3a66d2e3e6df3a701b0e5f31b7))
|
||||
- 🧹 update externs ([784fb01](https://github.com/melMass/comfy_mtb/commit/784fb0145b7421e2730b52237ce6a8b63b189191))
|
||||
- 🧹 add pathlibed inputs to utils ([a825504](https://github.com/melMass/comfy_mtb/commit/a825504bdd67e3461be8118119e0becc35f8af40))
|
||||
- 🧹 disable Constant ([22190cd](https://github.com/melMass/comfy_mtb/commit/22190cd25ee590595f8f19e75a9a6c539699622b))
|
||||
- 🧹 new ui is default, flag for old ui ([a976adb](https://github.com/melMass/comfy_mtb/commit/a976adbb39a13b4cd76f224ebba40c604900c862))
|
||||
- 🧹 add methods to shared ([f8829fc](https://github.com/melMass/comfy_mtb/commit/f8829fcb373e0f9bc4f0ad36c939f372349943bf))
|
||||
- 🧹 add an old_ui flag to my launcher ([dbdf276](https://github.com/melMass/comfy_mtb/commit/dbdf27664cd207dbbc69b8d635adcd59ed8d269a))
|
||||
- 🧹 move qrcode to his own file ([7d5569e](https://github.com/melMass/comfy_mtb/commit/7d5569e5c1e0f0b6ccb505a02f74640139d6aaf9))
|
||||
|
||||
## [0.1.6] - 2024-07-03
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🐛 menu callback issue ([d64fac4](https://github.com/melMass/comfy_mtb/commit/d64fac4b74e0590acde5e3b8edd4a2f715448cf5))
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📚 Update requirements file in INSTALL.md ([f25f6bd](https://github.com/melMass/comfy_mtb/commit/f25f6bdcd13d50f9d383065321320b0ce6a03214)) by [@elthariel](https://github.com/elthariel) in [#186](https://github.com/melMass/comfy_mtb/pull/186)
|
||||
|
||||
### Features
|
||||
|
||||
- ✨ add alpha channel support for faceswap/restore ([d6343e1](https://github.com/melMass/comfy_mtb/commit/d6343e1860f46947e93758f8bba03857c9326b38))
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- 🧹 better classname extraction ([d687497](https://github.com/melMass/comfy_mtb/commit/d687497d8041ab5d77bd31909592def6e4d0e7f6))
|
||||
- 🤖 limit release only to tags ([4eebdd8](https://github.com/melMass/comfy_mtb/commit/4eebdd8b8bff73c3db4f0248da8dac7d67cb310b))
|
||||
- 🧹 runner ([fb34671](https://github.com/melMass/comfy_mtb/commit/fb34671ee6fe80b965fe576c279ed1ff77a358f2))
|
||||
- 🤖 only publish on tag ([f1b4846](https://github.com/melMass/comfy_mtb/commit/f1b484617a917d38d9b3658d8920aa7dec672a79))
|
||||
- 🧹 small fixes ([4507842](https://github.com/melMass/comfy_mtb/commit/4507842a706141977a6a68945c36e977c358d91a))
|
||||
|
||||
## New Contributors
|
||||
* [@elthariel](https://github.com/elthariel) made their first contribution in [#186](https://github.com/melMass/comfy_mtb/pull/186)
|
||||
## [0.1.5] - 2024-06-21
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- 🐛 keep the last model match instead of first ([1edc2cd](https://github.com/melMass/comfy_mtb/commit/1edc2cd10de81297e7a895009d358813e79b70ba))
|
||||
- 🐛 properly initialize the curve value ([35622e3](https://github.com/melMass/comfy_mtb/commit/35622e3a5e58103a8f5b150556b85e97e31555e1))
|
||||
- 🐛 ImageCompare improvements ([acc2d68](https://github.com/melMass/comfy_mtb/commit/acc2d687d596bf82c2075f9a24003eacf18adfe7)) by [@christian-byrne](https://github.com/christian-byrne) in [#176](https://github.com/melMass/comfy_mtb/pull/176)
|
||||
- 🐛 repetitive warning ([780c52f](https://github.com/melMass/comfy_mtb/commit/780c52f03aca3079a1b695510341486720004bec)) by [@vxkj1211](https://github.com/vxkj1211) in [#177](https://github.com/melMass/comfy_mtb/pull/177)
|
||||
- 🐛 add back was conversion node ([349a852](https://github.com/melMass/comfy_mtb/commit/349a8524c6f7fcab4a124cacb60bfbef1463cf1b))
|
||||
- 🐛 drag lag on documentation resize handle ([15330ea](https://github.com/melMass/comfy_mtb/commit/15330eab655f66214d3c25fd237679f090175c32))
|
||||
- 🐛 kwarg typo ([1571782](https://github.com/melMass/comfy_mtb/commit/1571782d012b83bce32a065e700f9a587db234d2))
|
||||
- 🐛 seed of PlotBatchFloat ([5b40302](https://github.com/melMass/comfy_mtb/commit/5b4030288d43c79859c9706a12aa0f8b7dea190f))
|
||||
- 🐛 forceInput for FLOAT <-> FLOATS converters ([5a0ef0d](https://github.com/melMass/comfy_mtb/commit/5a0ef0dadd01fd5937ed0715d829d6a456f96318))
|
||||
- 🐛 FLOAT always need options to be set ([967e72f](https://github.com/melMass/comfy_mtb/commit/967e72fc66780685f8192cb8fe13ba66b9326f63))
|
||||
- 🐛 remove doc if opened on node delete ([bee3f47](https://github.com/melMass/comfy_mtb/commit/bee3f47a14ddb92b3760098666bf75dc7d37f1e4))
|
||||
- 🐛 for documentation on HiDPI ([b11346a](https://github.com/melMass/comfy_mtb/commit/b11346aba88d9f1dac3b6b42c691979cc0978b6f))
|
||||
- 🐛 never remove input 0 of dynamic inputs ([30982fa](https://github.com/melMass/comfy_mtb/commit/30982fa48829c3fc2a6745ce5a07537a3d94b2f9))
|
||||
- 🐛 use the same fix as dynamicInputs for debug ([92b7990](https://github.com/melMass/comfy_mtb/commit/92b79906cd2ee1b4ca3ff25378d7786b5a47cb75))
|
||||
- 🐛 missing numberInput ([76f365b](https://github.com/melMass/comfy_mtb/commit/76f365b5eee165c76f3da7d2e3950786685bc08b))
|
||||
- 🐛 better curve ([da67e76](https://github.com/melMass/comfy_mtb/commit/da67e766c2f700dd9e2f51a5bafe07c612904f5d))
|
||||
- 🐛 prepend MTB_ to all classes ([b1d74ad](https://github.com/melMass/comfy_mtb/commit/b1d74adb15166e3e5eb9cf92d6148e4644bed346))
|
||||
- 🐛 dynamic connections ([652ac3f](https://github.com/melMass/comfy_mtb/commit/652ac3f3b971582b02115177fd6f7a9d3d7295df))
|
||||
- 🐛 remaining issue before merge ([100067a](https://github.com/melMass/comfy_mtb/commit/100067a645194366426f29b085bf25d0623f4fac))
|
||||
- 🐛 debug issues ([7807449](https://github.com/melMass/comfy_mtb/commit/7807449e6dcc01cfdb7f0eb818569184c8b41af2))
|
||||
- 🐛 errors when insightface's folder missing ([e838c04](https://github.com/melMass/comfy_mtb/commit/e838c04758402250fd3464d6cd6a6f872e8cef29))
|
||||
- 🐛 typo ([e40ad7a](https://github.com/melMass/comfy_mtb/commit/e40ad7a574f961ebe1f338b97214da5cbadcc529))
|
||||
- 🐛 better defaults (cont) ([1da483a](https://github.com/melMass/comfy_mtb/commit/1da483a8baa6a893f1adb05ef79b90c4412c3834))
|
||||
- 🐛 better defaults for Autopan ([5eff38b](https://github.com/melMass/comfy_mtb/commit/5eff38b387d22206d39c08e435806f9d03992feb))
|
||||
- 🐛 dynamic inputs ([9ab20a0](https://github.com/melMass/comfy_mtb/commit/9ab20a0ab50b1656ded9a84c13769fd2d547f2d2))
|
||||
- 🐛 bundle ace editor ([7c35582](https://github.com/melMass/comfy_mtb/commit/7c3558273bebc0754c802720e705232f220a0da4))
|
||||
- 🐛 image to mask ([f16d576](https://github.com/melMass/comfy_mtb/commit/f16d576f6f0e83fc2fafd2d1f29b2edeb00d3197))
|
||||
- 🐛 prepend MTB to classnames ([e56508c](https://github.com/melMass/comfy_mtb/commit/e56508c2078155f053e7f11d538a048df6a5b18b))
|
||||
- 🐛 allow smaller values in BatchTransform ([9a4b27d](https://github.com/melMass/comfy_mtb/commit/9a4b27d2e05e8ebe31f58a21db94bd3a54ed23d9))
|
||||
- 🐛 add category for virtual note+ ([eeac8c0](https://github.com/melMass/comfy_mtb/commit/eeac8c002ad1f9e461418fb66b9338e969259e58))
|
||||
- 🐛 make image feed of by default ([df0a98b](https://github.com/melMass/comfy_mtb/commit/df0a98b94a4a9388811bc8786e820ec892919c1a))
|
||||
- 🐛 support batch masks (colored image node) ([2465ffb](https://github.com/melMass/comfy_mtb/commit/2465ffb0d3b052fb78559394dbb550bba59b97a3))
|
||||
- 🐛 support pillow < 10 ([48f91b7](https://github.com/melMass/comfy_mtb/commit/48f91b74e2c7ef6d31c094eafa5332784a275a8b))
|
||||
- 🐛 image rotation bug ([54ff658](https://github.com/melMass/comfy_mtb/commit/54ff6583ded0ed4054f8e5d7fadf0b2350259dce)) by [@hongminpark](https://github.com/hongminpark) in [#154](https://github.com/melMass/comfy_mtb/pull/154)
|
||||
- 🐛 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))
|
||||
@@ -230,13 +47,6 @@ Check the notes in the [releases](https://github.com/melMass/comfy_mtb/releases)
|
||||
|
||||
### Documentation
|
||||
|
||||
- 📚 update the wiki ([fa3199b](https://github.com/melMass/comfy_mtb/commit/fa3199be2b87bf3cb7484a0fee32a8ac099adc65))
|
||||
- 📚 update wiki submodule ([49cea8d](https://github.com/melMass/comfy_mtb/commit/49cea8d94508b27781506e3b5509c65e1d84e80f))
|
||||
- 📚 add the wiki as a submodule ([5998924](https://github.com/melMass/comfy_mtb/commit/59989249260a9c579ec851c50534b58f3f02cd61))
|
||||
- 📚 missing doc ([c9836a8](https://github.com/melMass/comfy_mtb/commit/c9836a87f6823db1d53e56997417f3cbe8cc4727))
|
||||
- 📚 use flat icon ([991af4f](https://github.com/melMass/comfy_mtb/commit/991af4f45ff8c660b2c45466bb219186699170ed))
|
||||
- 📚 add banodoco channel link ([9ce34b4](https://github.com/melMass/comfy_mtb/commit/9ce34b47fd99b18db7997ccce44e6063f00b6801))
|
||||
- 📚 udpate changelog ([8221c49](https://github.com/melMass/comfy_mtb/commit/8221c49942bd87c14d5063066315a449a1fee86e))
|
||||
- 📝 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))
|
||||
@@ -250,28 +60,6 @@ Check the notes in the [releases](https://github.com/melMass/comfy_mtb/releases)
|
||||
|
||||
### Features
|
||||
|
||||
- ✨ add ModelPruner (wip) ([43d65ae](https://github.com/melMass/comfy_mtb/commit/43d65ae68c97e077117b17b7c9d1936583f965eb))
|
||||
- ✨ Use dynamic contrast in Color Correct ([6abac2e](https://github.com/melMass/comfy_mtb/commit/6abac2e4706a3d937420213e01468bae10cc2017)) by [@christian-byrne](https://github.com/christian-byrne) in [#180](https://github.com/melMass/comfy_mtb/pull/180)
|
||||
- ✨ StackImages add support for batch mismatch ([5060c56](https://github.com/melMass/comfy_mtb/commit/5060c561353e43624ec164cb73fce7d1d422f765))
|
||||
- ✨ add BatchFloatMath ([f9d2ebf](https://github.com/melMass/comfy_mtb/commit/f9d2ebf91d09fc214fecf7501a5490b33c30aca2))
|
||||
- ✨ add FLOATS to INTS ([1b7ae27](https://github.com/melMass/comfy_mtb/commit/1b7ae27cc1907bfba3c5166ec2c61547babd2e0a))
|
||||
- ✨ debug dict ([63ee25d](https://github.com/melMass/comfy_mtb/commit/63ee25d001d4c94aa95dc8b39008f5d943f2ab45))
|
||||
- ✨ add Swap BG/FG color menu item ([1caf7c1](https://github.com/melMass/comfy_mtb/commit/1caf7c18c372651b2be7227eb77e2251d963693d))
|
||||
- ✨ BatchFloatFit the batch version of FitNumber ([ab58c36](https://github.com/melMass/comfy_mtb/commit/ab58c362124f0f4b3178534ca78cb924fb881534))
|
||||
- ✨ add FloatToFloats (the counterpart) ([78a86da](https://github.com/melMass/comfy_mtb/commit/78a86daaf71dab5be34b90b13491460854718485))
|
||||
- ✨ add some FLOATS batch nodes ([2159395](https://github.com/melMass/comfy_mtb/commit/2159395389429c5f7012e660b41fad48d376b39f))
|
||||
- ✨ poc of the doc widget idea ([fac7529](https://github.com/melMass/comfy_mtb/commit/fac7529d1f7b6fc4b3b2e7f6022ebb23ec71169d))
|
||||
- ✨ add the backend node for Constant ([dff5b22](https://github.com/melMass/comfy_mtb/commit/dff5b2201d73c1a91d4b5864e3b974e68846a011))
|
||||
- ✨ add Constant node ([cbb5dd2](https://github.com/melMass/comfy_mtb/commit/cbb5dd2cf810d5648a64eae370dba610336b99d5))
|
||||
- ✨ add FloatsToFloat ([6ebecfd](https://github.com/melMass/comfy_mtb/commit/6ebecfd8cf1dc3779384e565a65baa9dceb43660))
|
||||
- ✨ add AutoPanEquilateral ([3513937](https://github.com/melMass/comfy_mtb/commit/35139371e84d715423015e05d1b4a6c1d88b0eb5))
|
||||
- ✨ add MatchDimensions ([5db3ebe](https://github.com/melMass/comfy_mtb/commit/5db3ebedb9d38470c82544e45970775193add05c))
|
||||
- ✨ add equilateral example ([8d65556](https://github.com/melMass/comfy_mtb/commit/8d65556c37f33d1c496504db92574805916dd613))
|
||||
- ✨ enhance tiling tools ([ba73fc6](https://github.com/melMass/comfy_mtb/commit/ba73fc6af7039a4629a73cdc36a8c8736dc27c9d))
|
||||
- ✨ add FLOATS support to blur ([92c810c](https://github.com/melMass/comfy_mtb/commit/92c810c5036f7a2b3f84a3fde8c81e6a2b046b07))
|
||||
- ✨ add "tube" to Batch Shape ([f658fc3](https://github.com/melMass/comfy_mtb/commit/f658fc31e040141209384d98dfe84b766fe4ae11))
|
||||
- ✨ note+ editor themes ([133da70](https://github.com/melMass/comfy_mtb/commit/133da705c94af2dfb3d2f38c0d9c2723c72cacf7))
|
||||
- ✨ add ffmpeg gif export ([1b29aad](https://github.com/melMass/comfy_mtb/commit/1b29aad360116e631b7b4d34e98a5a631f134977)) by [@huanggou666](https://github.com/huanggou666) in [#159](https://github.com/melMass/comfy_mtb/pull/159)
|
||||
- ✨ 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))
|
||||
@@ -294,19 +82,6 @@ Check the notes in the [releases](https://github.com/melMass/comfy_mtb/releases)
|
||||
|
||||
### Miscellaneous Tasks
|
||||
|
||||
- 🧹 add fields for the registry ([bb5682a](https://github.com/melMass/comfy_mtb/commit/bb5682aa6da923859db33830c2e46f24b19199a1))
|
||||
- 🧹 add pre-commit ([59612fd](https://github.com/melMass/comfy_mtb/commit/59612fd8110a888f0081433242a2b5a5f7e46da6))
|
||||
- 🧹 migrate from poetry to setuptools ([dfd17f6](https://github.com/melMass/comfy_mtb/commit/dfd17f6d783e784df7dab38d185c747b4c04d1d0))
|
||||
- 🧹 remove logs ([1070edd](https://github.com/melMass/comfy_mtb/commit/1070edd0245fb235183d5f38cd1bebf6e0405f97))
|
||||
- 🧹 add more pyproject meta ([644371e](https://github.com/melMass/comfy_mtb/commit/644371e5b5a2b8260fc5c6f699465b0bc1c81d57))
|
||||
- 🤖 move at the proper location ([f3d468c](https://github.com/melMass/comfy_mtb/commit/f3d468cfc238f13905a13a7b2225e3711129c64d))
|
||||
- 🤖 add CI to publish to ComfyUI Registry ([6cd448b](https://github.com/melMass/comfy_mtb/commit/6cd448b026956cdf3f1b81e93724b295316fbf09)) by [@haohaocreates](https://github.com/haohaocreates) in [#182](https://github.com/melMass/comfy_mtb/pull/182)
|
||||
- 🧹 add ComfyUI registry to pyproject.toml ([5951c90](https://github.com/melMass/comfy_mtb/commit/5951c90b10f9b77b2b617e83efe0112f43c8daef)) by [@haohaocreates](https://github.com/haohaocreates) in [#181](https://github.com/melMass/comfy_mtb/pull/181)
|
||||
- 🧹 update types ([96a0da9](https://github.com/melMass/comfy_mtb/commit/96a0da9dbd051d1fcf8b332c54ed2d307d8ae0dd))
|
||||
- 🧹 use a gettattr fallback ([a344cdc](https://github.com/melMass/comfy_mtb/commit/a344cdcba9823ca1fb0762795068039b1e1cf0ab))
|
||||
- 🧹 cleanup js ([64cc4e9](https://github.com/melMass/comfy_mtb/commit/64cc4e9649853023d645245bea1e1ceb11073f01))
|
||||
- 🧹 add savedatabundle js part ([edd7c3f](https://github.com/melMass/comfy_mtb/commit/edd7c3f5d075b640e9cdb067ebfe51c42ff61791))
|
||||
- 🧹 wip dynamic multitype ([71bfdd6](https://github.com/melMass/comfy_mtb/commit/71bfdd61d731ce15f9bd0bb19d65b5af208d5dcf))
|
||||
- 🧹 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))
|
||||
@@ -329,17 +104,9 @@ Check the notes in the [releases](https://github.com/melMass/comfy_mtb/releases)
|
||||
|
||||
### Wip
|
||||
|
||||
- 🚧 curve widget logic fixed ([e312b02](https://github.com/melMass/comfy_mtb/commit/e312b02ad2f8334e87654a20b0114837df229371))
|
||||
- 🚧 dump3 ([eedbb4b](https://github.com/melMass/comfy_mtb/commit/eedbb4bc6581bef85c746307fe9d53360ea45bcf))
|
||||
- 🚧 dump ([fa23975](https://github.com/melMass/comfy_mtb/commit/fa2397585fff4f54bcf17f0b0e0083c427b34fa8))
|
||||
- 🚧 dump ([0d0fb8e](https://github.com/melMass/comfy_mtb/commit/0d0fb8e13a5da54a44a96a04607f7a349f8fdb03))
|
||||
- 🚧 add text template node ([af2175a](https://github.com/melMass/comfy_mtb/commit/af2175a1fc0c2fb29ef3493f242fe45ec6fcabac))
|
||||
|
||||
## New Contributors
|
||||
* [@haohaocreates](https://github.com/haohaocreates) made their first contribution in [#182](https://github.com/melMass/comfy_mtb/pull/182)
|
||||
* [@vxkj1211](https://github.com/vxkj1211) made their first contribution in [#177](https://github.com/melMass/comfy_mtb/pull/177)
|
||||
* [@huanggou666](https://github.com/huanggou666) made their first contribution in [#159](https://github.com/melMass/comfy_mtb/pull/159)
|
||||
* [@hongminpark](https://github.com/hongminpark) made their first contribution in [#154](https://github.com/melMass/comfy_mtb/pull/154)
|
||||
* [@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)
|
||||
@@ -626,10 +393,7 @@ Check the notes in the [releases](https://github.com/melMass/comfy_mtb/releases)
|
||||
|
||||
- 🚀 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.2.0..main
|
||||
[0.2.0]: https://github.com/melMass/comfy_mtb/compare/v0.1.6..v0.2.0
|
||||
[0.1.6]: https://github.com/melMass/comfy_mtb/compare/v0.1.5..v0.1.6
|
||||
[0.1.5]: https://github.com/melMass/comfy_mtb/compare/v0.1.4..v0.1.5
|
||||
[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
|
||||
|
||||
+58
-239
@@ -7,12 +7,10 @@
|
||||
#
|
||||
###
|
||||
|
||||
__version__ = "0.3.0"
|
||||
__version__ = "0.1.6"
|
||||
|
||||
import os
|
||||
|
||||
from aiohttp.web_request import Request
|
||||
|
||||
# TODO: don't override this if the user has that setup already
|
||||
if not os.environ.get("TF_FORCE_GPU_ALLOW_GROWTH"):
|
||||
os.environ["TF_FORCE_GPU_ALLOW_GROWTH"] = "true"
|
||||
@@ -31,32 +29,26 @@ from importlib import reload
|
||||
from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
from server import PromptServer
|
||||
|
||||
IN_COMFY = False
|
||||
|
||||
try:
|
||||
from server import PromptServer
|
||||
|
||||
IN_COMFY = True
|
||||
except ModuleNotFoundError:
|
||||
IN_COMFY = False
|
||||
|
||||
import nodes
|
||||
|
||||
from .endpoint import endlog
|
||||
from .install import get_node_dependencies
|
||||
from .log import blue_text, cyan_text, get_label, get_summary, log
|
||||
from .utils import comfy_dir, here
|
||||
|
||||
NODE_CLASS_MAPPINGS: dict[str, type] = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {}
|
||||
NODE_CLASS_MAPPINGS_DEBUG: dict[str, str | None] = {}
|
||||
NODE_CLASS_MAPPINGS = {}
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||||
NODE_CLASS_MAPPINGS_DEBUG = {}
|
||||
WEB_DIRECTORY = "./web"
|
||||
|
||||
|
||||
def extract_nodes_from_source(filename: Path):
|
||||
source_code = ""
|
||||
|
||||
source_code = filename.read_text(encoding="utf-8")
|
||||
nodes: list[str] = []
|
||||
|
||||
nodes = []
|
||||
|
||||
try:
|
||||
parsed = ast.parse(source_code)
|
||||
@@ -65,15 +57,14 @@ def extract_nodes_from_source(filename: Path):
|
||||
target = node.targets[0]
|
||||
if isinstance(target, ast.Name) and target.id == "__nodes__":
|
||||
value = ast.get_source_segment(source_code, node.value)
|
||||
if value:
|
||||
node_value = ast.parse(value).body[0].value
|
||||
if isinstance(node_value, ast.List | ast.Tuple):
|
||||
nodes.extend(
|
||||
str(element.id)
|
||||
for element in node_value.elts
|
||||
if isinstance(element, ast.Name)
|
||||
)
|
||||
break
|
||||
node_value = ast.parse(value).body[0].value
|
||||
if isinstance(node_value, (ast.List, ast.Tuple)):
|
||||
nodes.extend(
|
||||
element.id
|
||||
for element in node_value.elts
|
||||
if isinstance(element, ast.Name)
|
||||
)
|
||||
break
|
||||
except SyntaxError:
|
||||
log.error("Failed to parse")
|
||||
return nodes
|
||||
@@ -81,8 +72,8 @@ def extract_nodes_from_source(filename: Path):
|
||||
|
||||
def load_nodes():
|
||||
errors: list[str] = []
|
||||
nodes: list[type] = []
|
||||
nodes_failed: list[str] = []
|
||||
nodes = []
|
||||
nodes_failed = []
|
||||
|
||||
for filename in (here / "nodes").iterdir():
|
||||
if filename.suffix == ".py":
|
||||
@@ -133,8 +124,7 @@ def uninstall_old_web_extensions():
|
||||
shutil.rmtree(web_mtb)
|
||||
except Exception as e:
|
||||
log.warning(
|
||||
f"""Failed to remove web mtb directory: {e}
|
||||
Please manually remove it from disk ({web_mtb}) and restart the server."""
|
||||
f"Failed to remove web mtb directory: {e}\nPlease manually remove it from disk ({web_mtb}) and restart the server."
|
||||
)
|
||||
|
||||
|
||||
@@ -151,7 +141,7 @@ def wiki_to_classname(s: str):
|
||||
|
||||
def classname_to_wiki(s: str):
|
||||
classname = s.replace("MTB_", "")
|
||||
parts: list[str] = []
|
||||
parts = []
|
||||
start = 0
|
||||
for i in range(1, len(classname)):
|
||||
if classname[i].isupper():
|
||||
@@ -171,6 +161,8 @@ if wiki.exists() and wiki.is_dir():
|
||||
|
||||
|
||||
# - REGISTER NODES
|
||||
|
||||
|
||||
MTB_EXPORT = os.environ.get("MTB_EXPORT")
|
||||
|
||||
nodes, failed = load_nodes()
|
||||
@@ -187,7 +179,7 @@ for node_class in nodes:
|
||||
node_class.DESCRIPTION = node_class.__doc__
|
||||
if MTB_EXPORT:
|
||||
wiki_name = classname_to_wiki(class_name)
|
||||
_ = (wiki / "nodes" / (wiki_name + ".md")).write_text(
|
||||
(wiki / "nodes" / (wiki_name + ".md")).write_text(
|
||||
node_class.__doc__, encoding="utf-8"
|
||||
)
|
||||
|
||||
@@ -200,15 +192,12 @@ for node_class in nodes:
|
||||
NODE_CLASS_MAPPINGS[node_label] = node_class
|
||||
NODE_DISPLAY_NAME_MAPPINGS[class_name] = node_label
|
||||
NODE_CLASS_MAPPINGS_DEBUG[node_label] = node_class.__doc__
|
||||
|
||||
# TODO: I removed this, I find it more convenient to write without spaces
|
||||
# but it breaks every of my workflows
|
||||
# TODO (cont): and until I find a way to automate the conversion
|
||||
# I'll leave it like this
|
||||
# TODO: I removed this, I find it more convenient to write without spaces, but it breaks every of my workflows
|
||||
# TODO (cont): and until I find a way to automate the conversion, I'll leave it like this
|
||||
|
||||
if os.environ.get("MTB_EXPORT"):
|
||||
with open(here / "node_list.json", "w") as f:
|
||||
_ = f.write(
|
||||
f.write(
|
||||
json.dumps(
|
||||
{
|
||||
k: NODE_CLASS_MAPPINGS_DEBUG[k]
|
||||
@@ -226,31 +215,29 @@ log.debug(
|
||||
)
|
||||
)
|
||||
|
||||
log.info(f"loaded {cyan_text(str(len(nodes)))} nodes successfuly")
|
||||
|
||||
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."
|
||||
)
|
||||
log.debug(failed)
|
||||
|
||||
|
||||
# - ENDPOINT
|
||||
|
||||
|
||||
if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
img_cache = None
|
||||
prompt_cache = None
|
||||
|
||||
with contextlib.suppress(ImportError):
|
||||
from cachetools import TTLCache
|
||||
|
||||
img_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
|
||||
prompt_cache = TTLCache(maxsize=100, ttl=5) # 1 min TTL
|
||||
|
||||
node_dependency_mapping = get_node_dependencies()
|
||||
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()
|
||||
@@ -319,10 +306,10 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
}
|
||||
)
|
||||
|
||||
@PromptServer.instance.routes.post("/mtb/server-info")
|
||||
async def set_server_info(request: Request):
|
||||
json_data: dict[str, bool] = await request.json()
|
||||
enabled = json_data.get("debug")
|
||||
@PromptServer.instance.routes.post("/mtb/debug")
|
||||
async def set_debug(request):
|
||||
json_data = await request.json()
|
||||
enabled = json_data.get("enabled")
|
||||
if enabled:
|
||||
os.environ["MTB_DEBUG"] = "true"
|
||||
log.setLevel(logging.DEBUG)
|
||||
@@ -330,7 +317,7 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
|
||||
elif "MTB_DEBUG" in os.environ:
|
||||
# del os.environ["MTB_DEBUG"]
|
||||
_ = os.environ.pop("MTB_DEBUG")
|
||||
os.environ.pop("MTB_DEBUG")
|
||||
log.setLevel(logging.INFO)
|
||||
|
||||
return web.json_response(
|
||||
@@ -338,17 +325,17 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
)
|
||||
|
||||
@PromptServer.instance.routes.get("/mtb")
|
||||
async def get_home(request: Request):
|
||||
async def get_home(request):
|
||||
from . import endpoint
|
||||
|
||||
_ = reload(endpoint)
|
||||
reload(endpoint)
|
||||
# Check if the request prefers HTML content
|
||||
if "text/html" in request.headers.get("Accept", ""):
|
||||
# # Return an HTML page
|
||||
html_response = """
|
||||
<div class="flex-container menu">
|
||||
<a href="/mtb/manage">manage</a>
|
||||
<a href="/mtb/server-info">Server Info</a>
|
||||
<a href="/mtb/debug">debug</a>
|
||||
<a href="/mtb/status">status</a>
|
||||
</div>
|
||||
"""
|
||||
@@ -360,196 +347,28 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
# Return JSON for other requests
|
||||
return web.json_response({"message": "Welcome to MTB!"})
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from io import BytesIO
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
|
||||
def get_cached_image(file_path: str, preview_params=None, channel=None):
|
||||
cache_key = (file_path, preview_params, channel)
|
||||
if img_cache and (cache_key in img_cache):
|
||||
return img_cache[cache_key]
|
||||
|
||||
with Image.open(file_path) as img:
|
||||
info = img.info
|
||||
if preview_params:
|
||||
img = process_preview(img, preview_params)
|
||||
if channel:
|
||||
img = process_channel(img, channel)
|
||||
if prompt_cache:
|
||||
prompt_cache[cache_key] = info
|
||||
if img_cache:
|
||||
img_cache[cache_key] = img.getvalue()
|
||||
return img_cache[cache_key]
|
||||
|
||||
return img.getvalue()
|
||||
|
||||
def process_preview(img: Image.Image, preview_params):
|
||||
image_format, quality, width = preview_params
|
||||
quality = int(quality)
|
||||
|
||||
if width:
|
||||
width = int(width)
|
||||
img.thumbnail((width, int(width * img.height / img.width)))
|
||||
|
||||
buffer = BytesIO()
|
||||
img.save(
|
||||
buffer, format=image_format, quality=quality, metadata=img.info
|
||||
)
|
||||
buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
def process_channel(img: Image.Image, channel: str):
|
||||
if channel == "rgb":
|
||||
if img.mode == "RGBA":
|
||||
r, g, b, _ = img.split()
|
||||
img = Image.merge("RGB", (r, g, b))
|
||||
else:
|
||||
img = img.convert("RGB")
|
||||
elif channel == "a":
|
||||
if img.mode == "RGBA":
|
||||
_, _, _, a = img.split()
|
||||
else:
|
||||
a = Image.new("L", img.size, 255)
|
||||
img = Image.new("RGBA", img.size)
|
||||
img.putalpha(a)
|
||||
|
||||
buffer = BytesIO()
|
||||
img.save(buffer, format="PNG")
|
||||
_ = buffer.seek(0)
|
||||
return buffer
|
||||
|
||||
async def get_image_response(
|
||||
file, filename: str, preview_info=None, channel=None
|
||||
):
|
||||
img = await asyncio.to_thread(
|
||||
get_cached_image, file, preview_info, channel
|
||||
)
|
||||
return web.Response(
|
||||
body=img,
|
||||
content_type="image/webp" if preview_info else "image/png",
|
||||
headers={"Content-Disposition": f'filename="{filename}"'},
|
||||
)
|
||||
|
||||
# TODO: Embed the metadatas somehow so we can drag and drop
|
||||
# to load workflows in the sidebar
|
||||
@PromptServer.instance.routes.get("/mtb/view")
|
||||
async def view_image(request: Request):
|
||||
import folder_paths
|
||||
|
||||
filename = request.rel_url.query.get("filename")
|
||||
if not filename:
|
||||
return web.Response(status=404)
|
||||
|
||||
filename, output_dir = folder_paths.annotated_filepath(filename)
|
||||
if filename[0] == "/" or ".." in filename:
|
||||
return web.Response(status=400)
|
||||
|
||||
if output_dir is None:
|
||||
rtype = request.rel_url.query.get("type", "output")
|
||||
output_dir = folder_paths.get_directory_by_type(rtype)
|
||||
|
||||
if output_dir is None:
|
||||
return web.Response(status=400)
|
||||
|
||||
if "subfolder" in request.rel_url.query:
|
||||
full_output_dir = os.path.join(
|
||||
output_dir, request.rel_url.query["subfolder"]
|
||||
)
|
||||
if (
|
||||
os.path.commonpath(
|
||||
(os.path.abspath(full_output_dir), output_dir)
|
||||
)
|
||||
!= output_dir
|
||||
):
|
||||
return web.Response(status=403)
|
||||
output_dir = full_output_dir
|
||||
|
||||
filename = os.path.basename(filename)
|
||||
file = os.path.join(output_dir, filename)
|
||||
|
||||
if not os.path.isfile(file):
|
||||
return web.Response(status=404)
|
||||
|
||||
ret_workflow = request.rel_url.query.get("workflow")
|
||||
|
||||
if ret_workflow:
|
||||
image = Image.open(file)
|
||||
prompt = image.info.get("prompt", "")
|
||||
workflow = image.info.get("workflow", "")
|
||||
|
||||
if workflow:
|
||||
workflow = json.loads(workflow)
|
||||
|
||||
if prompt:
|
||||
prompt = json.loads(prompt)
|
||||
|
||||
return web.json_response(
|
||||
{
|
||||
"prompt": prompt,
|
||||
"workflow": workflow,
|
||||
}
|
||||
)
|
||||
|
||||
preview_info = None
|
||||
if "preview" in request.rel_url.query:
|
||||
preview_params = request.rel_url.query["preview"].split(";")
|
||||
image_format = (
|
||||
preview_params[0]
|
||||
if preview_params[0] in ["webp", "jpeg"]
|
||||
else "webp"
|
||||
)
|
||||
quality = (
|
||||
int(preview_params[1])
|
||||
if len(preview_params) > 1 and preview_params[1].isdigit()
|
||||
else 90
|
||||
)
|
||||
width = request.rel_url.query.get("width")
|
||||
preview_info = (image_format, quality, width)
|
||||
|
||||
channel = request.rel_url.query.get("channel")
|
||||
|
||||
return await get_image_response(file, filename, preview_info, channel)
|
||||
|
||||
@PromptServer.instance.routes.get("/mtb/server-info")
|
||||
async def get_debug(request: Request):
|
||||
@PromptServer.instance.routes.get("/mtb/debug")
|
||||
async def get_debug(request):
|
||||
from . import endpoint
|
||||
|
||||
_ = reload(endpoint)
|
||||
isdebug = "MTB_DEBUG" in os.environ
|
||||
exposed = "MTB_EXPOSE" in os.environ
|
||||
|
||||
def render_property(name: str, val: str):
|
||||
return f"""<strong>{name}:</strong>
|
||||
<p>
|
||||
{val}
|
||||
</p>"""
|
||||
|
||||
reload(endpoint)
|
||||
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
|
||||
html_response = ""
|
||||
|
||||
html_response += render_property(
|
||||
"Debug", "Enabled" if isdebug else "Disabled"
|
||||
)
|
||||
|
||||
html_response += render_property("Exposed", str(exposed))
|
||||
|
||||
html_response = f"""
|
||||
<h1>MTB Debug Status: {'Enabled' if enabled else 'Disabled'}</h1>
|
||||
"""
|
||||
return web.Response(
|
||||
text=endpoint.render_base_template(
|
||||
"Server Info", html_response
|
||||
),
|
||||
text=endpoint.render_base_template("Debug", html_response),
|
||||
content_type="text/html",
|
||||
)
|
||||
|
||||
# Return JSON for other requests
|
||||
return web.json_response({"exposed": exposed, "debug": isdebug})
|
||||
return web.json_response({"enabled": enabled})
|
||||
|
||||
@PromptServer.instance.routes.get("/mtb/actions")
|
||||
async def no_route(request: Request):
|
||||
async def no_route(request):
|
||||
from . import endpoint
|
||||
|
||||
if "text/html" in request.headers.get("Accept", ""):
|
||||
@@ -563,7 +382,7 @@ if IN_COMFY and hasattr(PromptServer, "instance"):
|
||||
return web.json_response({"message": "actions has no get for now"})
|
||||
|
||||
@PromptServer.instance.routes.post("/mtb/actions")
|
||||
async def do_action(request: Request):
|
||||
async def do_action(request):
|
||||
from . import endpoint
|
||||
|
||||
reload(endpoint)
|
||||
|
||||
+21
-153
@@ -1,20 +1,10 @@
|
||||
import csv
|
||||
import secrets
|
||||
import sys
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal
|
||||
|
||||
import folder_paths
|
||||
from aiohttp import web
|
||||
|
||||
from .install import get_node_dependencies
|
||||
from .log import mklog
|
||||
from .utils import (
|
||||
SortMode,
|
||||
backup_file,
|
||||
build_glob_patterns,
|
||||
glob_multiple,
|
||||
import_install,
|
||||
reqs_map,
|
||||
run_command,
|
||||
@@ -24,25 +14,18 @@ from .utils import (
|
||||
endlog = mklog("mtb endpoint")
|
||||
|
||||
# - ACTIONS
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import_install("requirements")
|
||||
|
||||
|
||||
def ACTIONS_installDependency(dependency_names: list[str] | None = None):
|
||||
def ACTIONS_installDependency(dependency_names=None):
|
||||
if dependency_names is None:
|
||||
# return web.Response(text="No dependency name provided", status=400)
|
||||
return {"error": "No dependency name provided"}
|
||||
|
||||
endlog.debug(f"Received Install Dependency request for {dependency_names}")
|
||||
# reqs = []
|
||||
resolved_names = [reqs_map.get(name, name) for name in dependency_names]
|
||||
allowed_deps = list(
|
||||
{d for dep in get_node_dependencies().values() for d in dep}
|
||||
)
|
||||
for dep in dependency_names:
|
||||
if dep not in allowed_deps:
|
||||
return {
|
||||
"error": f"Unknown dependency: {dep}, you can only use this endpoint to install {allowed_deps}"
|
||||
}
|
||||
try:
|
||||
run_command(
|
||||
[Path(sys.executable), "-m", "pip", "install"] + resolved_names
|
||||
@@ -67,106 +50,6 @@ def ACTIONS_installDependency(dependency_names: list[str] | None = None):
|
||||
# break
|
||||
|
||||
|
||||
def ACTIONS_getUserImageFolders():
|
||||
input_dir = Path(folder_paths.get_input_directory())
|
||||
output_dir = Path(folder_paths.get_output_directory())
|
||||
|
||||
input_subdirs = [x.name for x in input_dir.iterdir() if x.is_dir()]
|
||||
output_subdirs = [x.name for x in output_dir.iterdir() if x.is_dir()]
|
||||
|
||||
return {"input": input_subdirs, "output": output_subdirs}
|
||||
|
||||
|
||||
def ACTIONS_getUserVideos(
|
||||
size=256, count=200, offset=0, sort: str | None = None
|
||||
):
|
||||
count = count or 1000
|
||||
video_extensions = ["webm", "mp4", "mkv", "mov"]
|
||||
entries = {}
|
||||
patterns = build_glob_patterns(video_extensions)
|
||||
input_dir = Path(folder_paths.get_input_directory())
|
||||
entries = glob_multiple(input_dir, patterns)
|
||||
|
||||
sort_mode = SortMode.from_str(sort)
|
||||
|
||||
if sort_mode:
|
||||
sort_key = {
|
||||
SortMode.MODIFIED: lambda x: x.stat().st_mtime,
|
||||
SortMode.MODIFIED_REVERSE: lambda x: x.stat().st_mtime,
|
||||
SortMode.NAME: lambda x: x.name,
|
||||
SortMode.NAME_REVERSE: lambda x: x.name,
|
||||
}.get(sort_mode)
|
||||
if sort_key:
|
||||
reverse = sort_mode in (SortMode.MODIFIED, SortMode.NAME_REVERSE)
|
||||
entries = sorted(entries, key=sort_key, reverse=reverse)
|
||||
|
||||
videos = {
|
||||
video.name: (
|
||||
f"/view?force_rate=0&frame_load_cap=0&skip_first_frames=0&select_every_nth=1&filename={urllib.parse.quote_plus(video.name)}&type=input&format=video&force_size={size}x?"
|
||||
)
|
||||
for i, video in enumerate(entries)
|
||||
if offset <= i < offset + count
|
||||
}
|
||||
return videos
|
||||
|
||||
|
||||
def ACTIONS_getUserImages(
|
||||
mode: Literal["input", "output"],
|
||||
count=1000,
|
||||
offset=0,
|
||||
sort: str | None = None,
|
||||
include_subfolders: bool = False,
|
||||
subfolder=None,
|
||||
):
|
||||
# enabled = "MTB_EXPOSE" in os.environ
|
||||
# if not enabled:
|
||||
# return {"error": "Session not authorized to getInputs"}
|
||||
|
||||
imgs = {}
|
||||
count = count or 1000
|
||||
|
||||
input_dir = Path(folder_paths.get_input_directory())
|
||||
output_dir = Path(folder_paths.get_output_directory())
|
||||
|
||||
entry_dir = input_dir if mode == "input" else output_dir
|
||||
if subfolder:
|
||||
entry_dir = entry_dir / subfolder
|
||||
|
||||
if not entry_dir.exists():
|
||||
return {
|
||||
"error": f"Subfolder {entry_dir.name} doesn't exists in {entry_dir.parent.as_posix()}"
|
||||
}
|
||||
supported = ["png", "jpg", "jpeg", "webp", "gif"]
|
||||
|
||||
entries = {}
|
||||
patterns = build_glob_patterns(supported, recursive=include_subfolders)
|
||||
entries = glob_multiple(entry_dir, patterns)
|
||||
|
||||
sort_mode = SortMode.from_str(sort)
|
||||
|
||||
if sort_mode:
|
||||
sort_key = {
|
||||
SortMode.MODIFIED: lambda x: x.stat().st_mtime,
|
||||
SortMode.MODIFIED_REVERSE: lambda x: x.stat().st_mtime,
|
||||
SortMode.NAME: lambda x: x.name,
|
||||
SortMode.NAME_REVERSE: lambda x: x.name,
|
||||
}.get(sort_mode)
|
||||
if sort_key:
|
||||
reverse = sort_mode in (SortMode.MODIFIED, SortMode.NAME_REVERSE)
|
||||
entries = sorted(entries, key=sort_key, reverse=reverse)
|
||||
|
||||
imgs = {
|
||||
img.name: (
|
||||
f"/mtb/view?filename={img.name}&width=512&type={mode}&subfolder={subfolder or ''}"
|
||||
f"{img.parent.relative_to(entry_dir) if include_subfolders else ''}"
|
||||
f"&preview=&rand={secrets.randbelow(424242)}"
|
||||
)
|
||||
for i, img in enumerate(entries)
|
||||
if offset <= i < offset + count
|
||||
}
|
||||
return imgs
|
||||
|
||||
|
||||
def ACTIONS_getStyles(style_name=None):
|
||||
from .nodes.conditions import MTB_StylesLoader
|
||||
|
||||
@@ -214,7 +97,7 @@ def ACTIONS_saveStyle(data):
|
||||
csv_writer.writerow(row)
|
||||
|
||||
|
||||
async def do_action(request: web.Request) -> web.Response:
|
||||
async def do_action(request) -> web.Response:
|
||||
endlog.debug("Init action request")
|
||||
request_data = await request.json()
|
||||
name = request_data.get("name")
|
||||
@@ -226,12 +109,7 @@ async def do_action(request: web.Request) -> web.Response:
|
||||
method = globals().get(method_name)
|
||||
|
||||
if callable(method):
|
||||
result = None
|
||||
if args:
|
||||
result = method(*args) if isinstance(args, list) else method(args)
|
||||
else:
|
||||
result = method()
|
||||
|
||||
result = method(args) if args else method()
|
||||
endlog.debug(f"Action result: {result}")
|
||||
return web.json_response({"result": result})
|
||||
|
||||
@@ -252,13 +130,10 @@ async def do_action(request: web.Request) -> web.Response:
|
||||
# - HTML UTILS
|
||||
|
||||
|
||||
def dependencies_button(name: str, dependencies: list[str]) -> str:
|
||||
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>
|
||||
<button class="dependency-button" onclick="window.mtb_action('installDependency',[{deps}])">Install {name} deps</button>
|
||||
"""
|
||||
|
||||
|
||||
@@ -278,7 +153,7 @@ def csv_editor():
|
||||
html_out = """
|
||||
<div id="style-editor">
|
||||
<h1>Style Editor</h1>
|
||||
|
||||
|
||||
"""
|
||||
for current, styles in style_files.items():
|
||||
current_out = f"<h3>{current}</h3>"
|
||||
@@ -340,14 +215,11 @@ def render_tab_view(**kwargs):
|
||||
"""
|
||||
|
||||
|
||||
def add_foldable_region(title: str, content: str):
|
||||
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}')"
|
||||
>
|
||||
<div class='foldable-title' onclick="toggleFoldable('{title}', '{symbol_id}')">
|
||||
<span id='{symbol_id}' class='foldable-symbol'>▷</span>
|
||||
{title}
|
||||
</div>
|
||||
@@ -359,9 +231,7 @@ def add_foldable_region(title: str, content: str):
|
||||
"""
|
||||
|
||||
|
||||
def add_split_pane(
|
||||
left_content: str, right_content: str, *, vertical: bool = True
|
||||
):
|
||||
def add_split_pane(left_content, right_content, vertical=True):
|
||||
orientation = "vertical" if vertical else "horizontal"
|
||||
return f"""
|
||||
<div class="split-pane {orientation}">
|
||||
@@ -380,7 +250,7 @@ def add_split_pane(
|
||||
"""
|
||||
|
||||
|
||||
def add_dropdown(title: str, options: list[str]):
|
||||
def add_dropdown(title, options):
|
||||
option_str = "\n".join(
|
||||
[f"<option value='{opt}'>{opt}</option>" for opt in options]
|
||||
)
|
||||
@@ -392,18 +262,18 @@ def add_dropdown(title: str, options: list[str]):
|
||||
"""
|
||||
|
||||
|
||||
def render_table(table_dict: dict[str, Any], sort=True, title=None):
|
||||
table_list = sorted(
|
||||
def render_table(table_dict, sort=True, title=None):
|
||||
table_dict = sorted(
|
||||
table_dict.items(), key=lambda item: item[0]
|
||||
) # Sort the dictionary by keys
|
||||
|
||||
table_rows = ""
|
||||
for name, item in table_list:
|
||||
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'])}"
|
||||
f"{dependencies_button(name,item['dependencies'])}"
|
||||
)
|
||||
|
||||
table_rows += "</td></tr>"
|
||||
@@ -429,12 +299,12 @@ def render_table(table_dict: dict[str, Any], sort=True, title=None):
|
||||
<tbody>
|
||||
{table_rows}
|
||||
</tbody>
|
||||
</table>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
|
||||
|
||||
def render_base_template(title: str, content: str):
|
||||
def render_base_template(title, content):
|
||||
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>
|
||||
@@ -470,9 +340,7 @@ def render_base_template(title: str, content: str):
|
||||
<header>
|
||||
<a href="/">Back to Comfy</a>
|
||||
<div class="mtb_logo">
|
||||
<img
|
||||
src="https://repository-images.githubusercontent.com/649047066/a3eef9a7-20dd-4ef9-b839-884502d4e873"
|
||||
alt="Comfy MTB Logo" height="70" width="128">
|
||||
<img src="https://repository-images.githubusercontent.com/649047066/a3eef9a7-20dd-4ef9-b839-884502d4e873" alt="Comfy MTB Logo" height="70" width="128">
|
||||
<span class="title">Comfy MTB</span></div>
|
||||
<a style="width:128px;text-align:center" href="https://www.github.com/melmass/comfy_mtb">
|
||||
{github_icon_svg}
|
||||
@@ -487,6 +355,6 @@ def render_base_template(title: str, content: str):
|
||||
<!-- Shared footer content here -->
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
|
||||
</html>
|
||||
"""
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
# NOTE: This file is only use for development you can ignore it
|
||||
|
||||
use private/log.nu
|
||||
|
||||
def get_root [--clean] {
|
||||
if $clean {
|
||||
$env.COMFY_CLEAN_ROOT
|
||||
@@ -23,57 +21,13 @@ export def "comfy dev-web" [] {
|
||||
npm run dev
|
||||
}
|
||||
|
||||
export def "daily run" [] {
|
||||
let res = (comfy update --rebase)
|
||||
comfy update --clean
|
||||
comfy update_extensions
|
||||
|
||||
daily commit $res.from $res.to
|
||||
}
|
||||
|
||||
def short-date [] {
|
||||
format date "%Y-%m-%d"
|
||||
}
|
||||
|
||||
# was daily run today?
|
||||
export def "daily was-run" [] {
|
||||
|
||||
let daily = ($env.COMFY_MTB | path join daily.nuon)
|
||||
|
||||
if ($daily | path exists) {
|
||||
let last = (open $daily | sort-by date | get date | last | short-date)
|
||||
let today = (date now | short-date)
|
||||
return ($last == $today)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export def "daily commit" [from:string, to:string] {
|
||||
let daily = ($env.COMFY_MTB | path join daily.nuon)
|
||||
let commit = [{date: (date now) from:$from to:$to}]
|
||||
|
||||
let dailies = (if ($daily | path exists) {
|
||||
open $daily | append $commit
|
||||
} else {
|
||||
$commit
|
||||
})
|
||||
|
||||
$dailies | save -f $daily
|
||||
log success "Commited daily check"
|
||||
}
|
||||
|
||||
# start the comfy server
|
||||
export def "comfy start" [--clean,--old-ui, --listen, --skip-daily(-s)] {
|
||||
if (not (daily was-run)) and not $skip_daily {
|
||||
log info "Running daily checks"
|
||||
daily run
|
||||
}
|
||||
export def "comfy start" [--clean,--old-ui, --listen] {
|
||||
|
||||
let root = get_root --clean=($clean)
|
||||
cd $root
|
||||
|
||||
log info "Running Server"
|
||||
|
||||
MTB_DEBUG=true python main.py --port 3000 ...(if $old_ui { ["--front-end-version", "Comfy-Org/ComfyUI_legacy_frontend@latest"]} else {[ --front-end-version Comfy-Org/ComfyUI_frontend@latest]}) --preview-method auto ...(if $listen {["--listen"]} else {[]})
|
||||
MTB_DEBUG=true python main.py --port 3000 ...(if $old_ui { ["--front-end-version", "Comfy-Org/ComfyUI_legacy_frontend@latest"]} else {[]}) --preview-method auto ...(if $listen {["--listen"]} else {[]})
|
||||
}
|
||||
|
||||
# update comfy itself and merge master in current branch
|
||||
@@ -81,93 +35,69 @@ export def "comfy update" [
|
||||
--clean # ??
|
||||
--rebase # Rebase instead of merge
|
||||
] {
|
||||
let root = get_root --clean=$clean
|
||||
let root = get_root --clean=($clean)
|
||||
let models = $"($root)/models"
|
||||
let inputs = $"($root)/input"
|
||||
cd $root
|
||||
let branch_name = (git rev-parse --abbrev-ref HEAD | str trim)
|
||||
print $"(ansi yellow_italic)Backing up and removing models symlinks(ansi reset)"
|
||||
|
||||
let models = $"($root)/models"
|
||||
let inputs = $"($root)/input"
|
||||
|
||||
cd $root
|
||||
|
||||
let branch_name = (git rev-parse --abbrev-ref HEAD | str trim)
|
||||
let current_commit = (git rev-parse HEAD | str trim)
|
||||
|
||||
log info "Backing up and removing models symlinks"
|
||||
|
||||
# preparing root for pull
|
||||
if not $clean {
|
||||
git checkout pyproject.toml
|
||||
cd $models
|
||||
# find and store all symlinks
|
||||
let links = (ls -la |
|
||||
where not ($it.target | is-empty) |
|
||||
select name target |
|
||||
sort-by name)
|
||||
if not $clean {
|
||||
cd $models
|
||||
# find all symlinks
|
||||
let links = (ls -la |
|
||||
where not ($it.target | is-empty) |
|
||||
select name target |
|
||||
sort-by name)
|
||||
|
||||
|
||||
if not ($links | is-empty) {
|
||||
$links | save -f links.nuon
|
||||
# remove them
|
||||
open links.nuon | each {|p| rm $p.name }
|
||||
if not ($links | is-empty) {
|
||||
$links | save -f links.nuon
|
||||
# remove them
|
||||
open links.nuon | each {|p| rm $p.name }
|
||||
}
|
||||
} else {
|
||||
rm $models
|
||||
rm $inputs
|
||||
}
|
||||
} else {
|
||||
# just remove symlinks
|
||||
rm $models
|
||||
rm $inputs
|
||||
}
|
||||
|
||||
cd $root
|
||||
cd $root
|
||||
|
||||
log info $"Checking out to master"
|
||||
git checkout master
|
||||
print $"(ansi yellow_italic)Checking out to master(ansi reset)"
|
||||
git checkout master
|
||||
|
||||
log info "Fetching and pulling remote updates"
|
||||
if ($clean) {
|
||||
# from the local base repo master
|
||||
git fetch local master # $branch_name # master
|
||||
git pull local master # $branch_name # master
|
||||
} else {
|
||||
print $"(ansi yellow_italic)Fetching and pulling remote updates(ansi reset)"
|
||||
git fetch
|
||||
git pull
|
||||
}
|
||||
|
||||
let new_commit = (git rev-parse HEAD | str trim)
|
||||
print $"(ansi yellow_italic)Back to our branch \(($branch_name)\)(ansi reset)"
|
||||
git checkout -
|
||||
|
||||
log info $"Back to our branch \(($branch_name)\)"
|
||||
git checkout -
|
||||
|
||||
if $current_commit == $new_commit {
|
||||
log warn "No changes upstream"
|
||||
} else {
|
||||
if $rebase {
|
||||
log info "Rebasing changes"
|
||||
git rebase master
|
||||
print $"(ansi yellow_italic)Rebasing changes(ansi reset)"
|
||||
git rebase master
|
||||
|
||||
} else {
|
||||
log info "Merging changes"
|
||||
git merge master
|
||||
print $"(ansi yellow_italic)Merging changes(ansi reset)"
|
||||
git merge master
|
||||
}
|
||||
}
|
||||
|
||||
log info "Linking back the models"
|
||||
print $"(ansi yellow_italic)Linking back the models(ansi reset)"
|
||||
|
||||
if not $clean {
|
||||
rm pyproject.toml
|
||||
cp pyproject-mel.toml pyproject.toml
|
||||
cd $models
|
||||
if not $clean {
|
||||
cd $models
|
||||
# resymlink them
|
||||
open links.nuon | each {|p| link -a $p.target $p.name }
|
||||
} else {
|
||||
let master = (get_root)
|
||||
link ($master | path join models) $models
|
||||
link ($master | path join input) $inputs
|
||||
}
|
||||
|
||||
# resymlink them
|
||||
open links.nuon | each {|p| link -a $p.target $p.name }
|
||||
} else {
|
||||
let master = (get_root)
|
||||
link ($master | path join models) $models
|
||||
link ($master | path join input) $inputs
|
||||
}
|
||||
let commit_count = (git rev-list --count $branch_name $"^origin/($branch_name)")
|
||||
|
||||
let commit_count = (git rev-list --count $branch_name $"^origin/($branch_name)")
|
||||
|
||||
log success $"Update successful \(($commit_count) new commits\)"
|
||||
|
||||
return {from:$current_commit to:$new_commit}
|
||||
print $"(ansi green_bold)Update successful \(($commit_count) new commits\)(ansi reset)"
|
||||
|
||||
|
||||
}
|
||||
@@ -182,11 +112,11 @@ export def "comfy toggle_extensions" [--clean] {
|
||||
return
|
||||
}
|
||||
|
||||
log info "Choices" $choices
|
||||
print $choices
|
||||
|
||||
let filtered = $choices | wrap name | upsert enabled {|p| not ($p.name | str ends-with ".disabled")}
|
||||
|
||||
log info "Filtered" $filtered
|
||||
print $filtered
|
||||
$filtered | each {|f|
|
||||
let new_name = ($f.name | str replace ".disabled" "")
|
||||
|
||||
@@ -195,7 +125,7 @@ export def "comfy toggle_extensions" [--clean] {
|
||||
} else {
|
||||
$new_name
|
||||
}
|
||||
log info $"Moving ($f.name) to ($new_name)"
|
||||
print $"Moving ($f.name) to ($new_name)"
|
||||
mv $f.name $new_name
|
||||
}
|
||||
}
|
||||
@@ -205,7 +135,7 @@ export def "comfy update_extensions" [--clean] {
|
||||
let root = get_root --clean=($clean)
|
||||
cd $root
|
||||
cd custom_nodes
|
||||
git multipull . -s -q
|
||||
git multipull .
|
||||
}
|
||||
|
||||
def --env path-add [pth] {
|
||||
@@ -215,9 +145,8 @@ def --env path-add [pth] {
|
||||
|
||||
|
||||
export-env {
|
||||
$env.PYTHONUTF8 = 1
|
||||
$env.COMFY_MTB = ("." | path expand)
|
||||
# $env.CUDA_ROOT = 'C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.1\'
|
||||
$env.CUDA_ROOT = 'C:\Program Files\NVIDIA GPU Computing Toolkit\CUDA\v12.1\'
|
||||
|
||||
$env.CUDA_HOME = $env.CUDA_ROOT
|
||||
|
||||
@@ -225,12 +154,6 @@ export-env {
|
||||
$env.COMFY_CLEAN_ROOT = ($env.COMFY_ROOT | path dirname | path join ComfyClean)
|
||||
|
||||
path-add 'C:/Portable/TensorRT-8.6.0.12/lib'
|
||||
|
||||
if $nu.os-info.family == 'windows' {
|
||||
path-add 'G:\BIN\TensorRT-10.7.0.23\lib'
|
||||
path-add 'G:\BIN\cudnn-windows-x86_64-9.6.0.74_cuda12-archive\bin'
|
||||
}
|
||||
|
||||
path-add ($env.CUDA_ROOT | path join bin)
|
||||
overlay use ../../.venv/Scripts/activate.nu
|
||||
}
|
||||
|
||||
+29
-65
@@ -43,28 +43,10 @@ pip_map = {
|
||||
"tb-nightly": "tensorboard",
|
||||
"protobuf": "google.protobuf",
|
||||
"qrcode[pil]": "qrcode",
|
||||
"requirements-parser": "requirements",
|
||||
"requirements-parser": "requirements"
|
||||
# Add more mappings as needed
|
||||
}
|
||||
|
||||
|
||||
def get_node_dependencies():
|
||||
restore_deps = ["basicsr"]
|
||||
onnx_deps = ["onnxruntime"]
|
||||
swap_deps = ["insightface"] + onnx_deps
|
||||
quant_deps = ["bitsandbytes"]
|
||||
io_deps = ["av"]
|
||||
return {
|
||||
"QrCode": ["qrcode"],
|
||||
"DeepBump": onnx_deps,
|
||||
"FaceSwap": swap_deps,
|
||||
"LoadFaceSwapModel": swap_deps,
|
||||
"LoadFaceAnalysisModel": restore_deps,
|
||||
"Quantize": quant_deps,
|
||||
"SaveGif": io_deps,
|
||||
}
|
||||
|
||||
|
||||
# endregion
|
||||
|
||||
# region ansi
|
||||
@@ -142,12 +124,12 @@ def print_formatted(text, *formats, color=None, background=None, **kwargs):
|
||||
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)
|
||||
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(
|
||||
" " * len(encoded_header)
|
||||
@@ -181,9 +163,7 @@ def run_command(cmd, ignored_lines_start=None):
|
||||
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(f"Command failed with return code: {e.returncode}", file=sys.stderr)
|
||||
print(e.stderr.strip(), file=sys.stderr)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
@@ -258,7 +238,7 @@ def suppress_std():
|
||||
def get_local_version():
|
||||
init_file = os.path.join(os.path.dirname(__file__), "__init__.py")
|
||||
if os.path.isfile(init_file):
|
||||
with open(init_file) as f:
|
||||
with open(init_file, "r") as f:
|
||||
tree = ast.parse(f.read())
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Assign):
|
||||
@@ -276,16 +256,13 @@ def download_file(url, file_name):
|
||||
with requests.get(url, stream=True) as response:
|
||||
response.raise_for_status()
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
with (
|
||||
open(file_name, "wb") as file,
|
||||
tqdm(
|
||||
desc=file_name.stem,
|
||||
total=total_size,
|
||||
unit="B",
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
) as progress_bar,
|
||||
):
|
||||
with open(file_name, "wb") as file, tqdm(
|
||||
desc=file_name.stem,
|
||||
total=total_size,
|
||||
unit="B",
|
||||
unit_scale=True,
|
||||
unit_divisor=1024,
|
||||
) as progress_bar:
|
||||
for chunk in response.iter_content(chunk_size=8192):
|
||||
file.write(chunk)
|
||||
progress_bar.update(len(chunk))
|
||||
@@ -325,9 +302,7 @@ def import_or_install(requirement, dry=False):
|
||||
pip_install_name = pip_name + pip_spec
|
||||
|
||||
if not installed:
|
||||
print_formatted(
|
||||
f"Installing package {pip_name}...", "italic", color="yellow"
|
||||
)
|
||||
print_formatted(f"Installing package {pip_name}...", "italic", color="yellow")
|
||||
if dry:
|
||||
print_formatted(
|
||||
f"Dry-run: Package {pip_install_name} would be installed (import name: '{import_name}').",
|
||||
@@ -335,9 +310,7 @@ def import_or_install(requirement, dry=False):
|
||||
)
|
||||
else:
|
||||
try:
|
||||
run_command(
|
||||
[executable, "-m", "pip", "install", pip_install_name]
|
||||
)
|
||||
run_command([executable, "-m", "pip", "install", pip_install_name])
|
||||
print_formatted(
|
||||
f"Package {pip_install_name} installed successfully using pip package name (import name: '{import_name}')",
|
||||
"bold",
|
||||
@@ -353,9 +326,13 @@ def import_or_install(requirement, dry=False):
|
||||
|
||||
def get_github_assets(tag=None):
|
||||
if tag:
|
||||
tag_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/tags/{tag}"
|
||||
tag_url = (
|
||||
f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/tags/{tag}"
|
||||
)
|
||||
else:
|
||||
tag_url = f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
||||
tag_url = (
|
||||
f"https://api.github.com/repos/{repo_owner}/{repo_name}/releases/latest"
|
||||
)
|
||||
response = requests.get(tag_url)
|
||||
if response.status_code == 404:
|
||||
# print_formatted(
|
||||
@@ -384,9 +361,7 @@ except ImportError:
|
||||
def main():
|
||||
if len(sys.argv) == 1:
|
||||
print_formatted(
|
||||
"mtb doesn't need an install script anymore.",
|
||||
"italic",
|
||||
color="yellow",
|
||||
"mtb doesn't need an install script anymore.", "italic", color="yellow"
|
||||
)
|
||||
return
|
||||
if all(arg not in ("-p", "--path") for arg in sys.argv):
|
||||
@@ -409,7 +384,7 @@ def main():
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print_formatted(f"Detected environment: {apply_color(mode, 'cyan')}")
|
||||
print_formatted(f"Detected environment: {apply_color(mode,'cyan')}")
|
||||
|
||||
if args.path:
|
||||
clone_dir = Path(args.path)
|
||||
@@ -422,12 +397,8 @@ def main():
|
||||
else:
|
||||
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]
|
||||
)
|
||||
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..."
|
||||
@@ -438,14 +409,7 @@ def main():
|
||||
|
||||
print_formatted("Checking environment...", "italic", color="yellow")
|
||||
missing_deps = []
|
||||
install_cmd = [
|
||||
executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"-r",
|
||||
"requirements.txt",
|
||||
]
|
||||
install_cmd = [executable, "-m", "pip", "install", "-r", "requirements.txt"]
|
||||
run_command(install_cmd)
|
||||
|
||||
print_formatted(
|
||||
|
||||
+15
-702
@@ -1,42 +1,17 @@
|
||||
from typing import Any, TypedDict
|
||||
from typing import TypedDict
|
||||
|
||||
import torch
|
||||
import torchaudio
|
||||
from comfy.model_management import get_torch_device
|
||||
from huggingface_hub import snapshot_download
|
||||
from transformers import (
|
||||
WhisperForConditionalGeneration,
|
||||
WhisperProcessor,
|
||||
)
|
||||
|
||||
# from transformers import (
|
||||
# AutoFeatureExtractor,
|
||||
# WhisperForConditionalGeneration,
|
||||
# WhisperModel,
|
||||
# WhisperProcessor,
|
||||
# )
|
||||
from ..log import log
|
||||
from ..utils import get_model_path
|
||||
|
||||
WHISPER_SAMPLE_RATE = 16000
|
||||
|
||||
|
||||
class AudioTensor(TypedDict):
|
||||
class AudioDict(TypedDict):
|
||||
"""Comfy's representation of AUDIO data."""
|
||||
|
||||
sample_rate: int
|
||||
waveform: torch.Tensor
|
||||
|
||||
|
||||
class WhisperData(TypedDict):
|
||||
"""Whisper transcription data with timestamps and speaker info."""
|
||||
|
||||
text: str
|
||||
chunks: list[dict[str, Any]]
|
||||
language: str
|
||||
|
||||
|
||||
AudioData = AudioTensor | list[AudioTensor]
|
||||
AudioData = AudioDict | list[AudioDict]
|
||||
|
||||
|
||||
class MtbAudio:
|
||||
@@ -53,14 +28,10 @@ class MtbAudio:
|
||||
return audios["waveform"].shape[1] == 2
|
||||
|
||||
@staticmethod
|
||||
def resample(audio: AudioTensor, common_sample_rate: int) -> AudioTensor:
|
||||
current_rate = audio["sample_rate"]
|
||||
if current_rate != common_sample_rate:
|
||||
log.debug(
|
||||
f"Resampling audio from {current_rate} to {common_sample_rate}"
|
||||
)
|
||||
def resample(audio: AudioDict, common_sample_rate: int) -> AudioDict:
|
||||
if audio["sample_rate"] != common_sample_rate:
|
||||
resampler = torchaudio.transforms.Resample(
|
||||
orig_freq=current_rate, new_freq=common_sample_rate
|
||||
orig_freq=audio["sample_rate"], new_freq=common_sample_rate
|
||||
)
|
||||
return {
|
||||
"sample_rate": common_sample_rate,
|
||||
@@ -70,7 +41,7 @@ class MtbAudio:
|
||||
return audio
|
||||
|
||||
@staticmethod
|
||||
def to_stereo(audio: AudioTensor) -> AudioTensor:
|
||||
def to_stereo(audio: AudioDict) -> AudioDict:
|
||||
if audio["waveform"].shape[1] == 1:
|
||||
return {
|
||||
"sample_rate": audio["sample_rate"],
|
||||
@@ -83,8 +54,8 @@ class MtbAudio:
|
||||
|
||||
@classmethod
|
||||
def preprocess_audios(
|
||||
cls, audios: list[AudioTensor]
|
||||
) -> tuple[list[AudioTensor], bool, int]:
|
||||
cls, audios: list[AudioDict]
|
||||
) -> tuple[list[AudioDict], bool, int]:
|
||||
max_sample_rate = max([audio["sample_rate"] for audio in audios])
|
||||
|
||||
resampled_audios = [
|
||||
@@ -98,383 +69,6 @@ class MtbAudio:
|
||||
return (audios, is_stereo, max_sample_rate)
|
||||
|
||||
|
||||
class WhisperPipeline(TypedDict):
|
||||
"""Whisper model pipeline."""
|
||||
|
||||
processor: WhisperProcessor
|
||||
model: WhisperForConditionalGeneration
|
||||
|
||||
|
||||
class MTB_LoadWhisper:
|
||||
"""Load Whisper model and processor."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"model_size": (
|
||||
[
|
||||
"tiny",
|
||||
"small",
|
||||
"medium",
|
||||
"medium.en",
|
||||
"base",
|
||||
"large",
|
||||
"large-v2",
|
||||
"large-v3",
|
||||
"large-v3-turbo",
|
||||
],
|
||||
{"default": "tiny"},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"download_missing": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": (
|
||||
"Download missing models if missing,"
|
||||
"otherwise they must be in ComfyUI/models/whisper"
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("WHISPER_PIPELINE",)
|
||||
RETURN_NAMES = ("pipeline",)
|
||||
CATEGORY = "mtb/audio"
|
||||
FUNCTION = "load"
|
||||
|
||||
def load(self, model_size="tiny", download_missing=False):
|
||||
"""Load Whisper model and processor."""
|
||||
whisper_dir = get_model_path("whisper")
|
||||
tag = f"whisper-{model_size}"
|
||||
model_dir = whisper_dir / tag
|
||||
|
||||
if not (whisper_dir.exists() or model_dir.exists()):
|
||||
if not download_missing:
|
||||
raise RuntimeError(
|
||||
"Models not found and download_missing=False"
|
||||
)
|
||||
else:
|
||||
whisper_dir.mkdir(exist_ok=True)
|
||||
model_dir.mkdir(exist_ok=True)
|
||||
|
||||
snapshot_download(
|
||||
repo_id=f"openai/{tag}",
|
||||
resume_download=True,
|
||||
ignore_patterns=["*.msgpack", "*.bin", "*.h5"],
|
||||
local_dir=model_dir.as_posix(),
|
||||
local_dir_use_symlinks=False,
|
||||
)
|
||||
|
||||
device = get_torch_device()
|
||||
log.debug(
|
||||
f"Loading Whisper model {model_size} on {device} from {model_dir}"
|
||||
)
|
||||
|
||||
processor = WhisperProcessor.from_pretrained(model_dir.as_posix())
|
||||
model = WhisperForConditionalGeneration.from_pretrained(
|
||||
model_dir.as_posix()
|
||||
).to(device)
|
||||
|
||||
model.eval()
|
||||
model.requires_grad_(False)
|
||||
|
||||
return ({"processor": processor, "model": model},)
|
||||
|
||||
|
||||
class MTB_AudioToText(MtbAudio):
|
||||
"""Transcribe audio to text using Whisper."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"pipeline": ("WHISPER_PIPELINE",),
|
||||
"audio": ("AUDIO",),
|
||||
"language": (
|
||||
["auto"]
|
||||
+ sorted(
|
||||
[
|
||||
"en",
|
||||
"fr",
|
||||
"es",
|
||||
"de",
|
||||
"it",
|
||||
"pt",
|
||||
"nl",
|
||||
"ru",
|
||||
"zh",
|
||||
"ja",
|
||||
"ko",
|
||||
]
|
||||
),
|
||||
{"default": "auto"},
|
||||
),
|
||||
"return_timestamps": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "WHISPER_OUTPUT")
|
||||
FUNCTION = "transcribe"
|
||||
CATEGORY = "mtb/audio"
|
||||
|
||||
def transcribe(
|
||||
self,
|
||||
pipeline: WhisperPipeline,
|
||||
audio: AudioTensor,
|
||||
language="auto",
|
||||
return_timestamps=True,
|
||||
):
|
||||
"""Transcribe audio to text using Whisper."""
|
||||
processor = pipeline["processor"]
|
||||
model = pipeline["model"]
|
||||
device = model.device
|
||||
|
||||
audio = self.resample(audio, WHISPER_SAMPLE_RATE)
|
||||
|
||||
waveform = audio["waveform"]
|
||||
log.debug(f"Processed waveform shape: {waveform.shape}")
|
||||
|
||||
# - Mono: [1, 1, samples] or [1, samples] or [samples]
|
||||
# - Stereo: [1, 2, samples] or [2, samples] or [samples, 2]
|
||||
if len(waveform.shape) == 3:
|
||||
waveform = waveform.squeeze(0)
|
||||
|
||||
if len(waveform.shape) == 2:
|
||||
if waveform.shape[0] == 2: # [channels, samples]
|
||||
waveform = waveform.mean(dim=0)
|
||||
elif waveform.shape[1] == 2: # [samples, channels]
|
||||
waveform = waveform.mean(dim=1)
|
||||
else: # mono
|
||||
waveform = waveform.squeeze(0)
|
||||
|
||||
sample_rate = audio["sample_rate"]
|
||||
chunk_duration = 30
|
||||
chunk_samples = chunk_duration * sample_rate
|
||||
total_samples = waveform.shape[-1]
|
||||
total_duration = total_samples / sample_rate
|
||||
|
||||
log.debug(f"Audio duration: {total_duration:.2f}s")
|
||||
|
||||
all_tokens = []
|
||||
all_text = []
|
||||
chunk_offsets = []
|
||||
|
||||
last_time = 0.0
|
||||
accumulated_offset = 0.0
|
||||
|
||||
for chunk_start in range(0, total_samples, chunk_samples):
|
||||
chunk_end = min(chunk_start + chunk_samples, total_samples)
|
||||
chunk_waveform = waveform[chunk_start:chunk_end]
|
||||
chunk_offset = chunk_start / sample_rate
|
||||
chunk_offsets.append(chunk_offset)
|
||||
|
||||
log.debug(
|
||||
f"Processing chunk {chunk_offset:.1f}s - {chunk_end / sample_rate:.1f}s"
|
||||
)
|
||||
|
||||
max_length = model.config.max_length or 448
|
||||
attention_mask = torch.ones((1, max_length))
|
||||
|
||||
input_features = processor(
|
||||
chunk_waveform,
|
||||
sampling_rate=sample_rate,
|
||||
return_tensors="pt",
|
||||
).input_features.to(device)
|
||||
|
||||
with torch.no_grad():
|
||||
predicted_ids = model.generate(
|
||||
input_features,
|
||||
attention_mask=attention_mask.to(device),
|
||||
task="transcribe",
|
||||
language=None if language == "auto" else language,
|
||||
return_timestamps=return_timestamps,
|
||||
no_repeat_ngram_size=3,
|
||||
num_beams=5,
|
||||
length_penalty=1.0,
|
||||
max_length=max_length,
|
||||
)
|
||||
|
||||
chunk_tokens = processor.tokenizer.convert_ids_to_tokens(
|
||||
predicted_ids[0]
|
||||
)
|
||||
|
||||
adjusted_tokens = []
|
||||
for token in chunk_tokens:
|
||||
if token.startswith("<|") and token.endswith("|>"):
|
||||
try:
|
||||
time_str = token[2:-2]
|
||||
if time_str.replace(".", "").isdigit():
|
||||
time_val = float(time_str)
|
||||
|
||||
# If this timestamp is less than the last one, we've started a new sequence
|
||||
if time_val < last_time:
|
||||
accumulated_offset += last_time
|
||||
|
||||
adjusted_time = time_val + accumulated_offset
|
||||
adjusted_tokens.append(f"<|{adjusted_time:.2f}|>")
|
||||
last_time = time_val
|
||||
else:
|
||||
adjusted_tokens.append(token)
|
||||
except ValueError:
|
||||
adjusted_tokens.append(token)
|
||||
else:
|
||||
adjusted_tokens.append(token)
|
||||
|
||||
all_tokens.extend(adjusted_tokens)
|
||||
chunk_text = processor.batch_decode(
|
||||
predicted_ids, skip_special_tokens=True
|
||||
)[0]
|
||||
all_text.append(chunk_text)
|
||||
|
||||
detected_language = "en"
|
||||
if language == "auto":
|
||||
try:
|
||||
log.debug("Detecting language")
|
||||
with torch.no_grad():
|
||||
first_chunk_features = processor(
|
||||
waveform[:chunk_samples],
|
||||
sampling_rate=sample_rate,
|
||||
return_tensors="pt",
|
||||
).input_features.to(device)
|
||||
|
||||
predicted_probs = model.detect_language(
|
||||
first_chunk_features
|
||||
)[0]
|
||||
language_token = processor.tokenizer.convert_ids_to_tokens(
|
||||
predicted_probs.argmax(-1).item()
|
||||
)
|
||||
detected_language = (
|
||||
language_token[2:-2]
|
||||
if language_token.startswith("<|")
|
||||
else "en"
|
||||
)
|
||||
log.debug(f"Detected language: {detected_language}")
|
||||
|
||||
except Exception as e:
|
||||
log.warning(f"Language detection failed: {e}")
|
||||
|
||||
full_transcription = " ".join(all_text)
|
||||
|
||||
whisper_output = {
|
||||
"text": full_transcription,
|
||||
"language": detected_language,
|
||||
"tokens": all_tokens,
|
||||
"audio": audio,
|
||||
"chunk_offsets": chunk_offsets,
|
||||
}
|
||||
|
||||
return full_transcription, whisper_output
|
||||
|
||||
|
||||
class MTB_ProcessWhisperOutput:
|
||||
"""Process Whisper output into timestamped chunks."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"whisper_output": ("WHISPER_OUTPUT",),
|
||||
"min_chunk_length": (
|
||||
"FLOAT",
|
||||
{"default": 0.0, "min": 0.0, "max": 10.0, "step": 0.1},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING", "WHISPER_CHUNKS")
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "mtb/audio"
|
||||
|
||||
def process(self, whisper_output, min_chunk_length=0.0):
|
||||
"""Process Whisper output into timestamped chunks."""
|
||||
tokens = whisper_output["tokens"]
|
||||
audio = whisper_output["audio"]
|
||||
timestamp_tokens = []
|
||||
|
||||
audio_duration = audio["waveform"].shape[-1] / audio["sample_rate"]
|
||||
log.debug(f"Audio duration: {audio_duration:.2f}s")
|
||||
|
||||
for i, token in enumerate(tokens):
|
||||
if token.startswith("<|") and token.endswith("|>"):
|
||||
try:
|
||||
time_str = token[2:-2]
|
||||
if time_str.replace(".", "").isdigit():
|
||||
time_val = float(time_str)
|
||||
if 0 <= time_val <= audio_duration:
|
||||
timestamp_tokens.append((i, time_val))
|
||||
log.debug(f"Token {i}: {time_val}")
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
chunks = []
|
||||
if len(timestamp_tokens) > 1:
|
||||
for i in range(len(timestamp_tokens) - 1):
|
||||
start_pos, start_time = timestamp_tokens[i]
|
||||
end_pos, end_time = timestamp_tokens[i + 1]
|
||||
|
||||
if end_time - start_time < min_chunk_length:
|
||||
continue
|
||||
|
||||
chunk_tokens = tokens[start_pos + 1 : end_pos]
|
||||
text = " ".join(
|
||||
t
|
||||
for t in chunk_tokens
|
||||
if not (t.startswith("<|") and t.endswith("|>"))
|
||||
)
|
||||
|
||||
if text.strip():
|
||||
chunks.append(
|
||||
{
|
||||
"text": text.strip(),
|
||||
"timestamp": [start_time, end_time],
|
||||
}
|
||||
)
|
||||
|
||||
if timestamp_tokens:
|
||||
start_pos, start_time = timestamp_tokens[-1]
|
||||
if start_pos < len(tokens) - 1:
|
||||
text = " ".join(
|
||||
t
|
||||
for t in tokens[start_pos + 1 :]
|
||||
if not (t.startswith("<|") and t.endswith("|>"))
|
||||
)
|
||||
if text.strip():
|
||||
if chunks:
|
||||
prev_chunk = chunks[-1]
|
||||
prev_duration = (
|
||||
prev_chunk["timestamp"][1]
|
||||
- prev_chunk["timestamp"][0]
|
||||
)
|
||||
end_time = min(
|
||||
start_time + prev_duration, audio_duration
|
||||
)
|
||||
else:
|
||||
end_time = audio_duration
|
||||
|
||||
if (
|
||||
end_time > start_time
|
||||
and end_time - start_time >= min_chunk_length
|
||||
):
|
||||
chunks.append(
|
||||
{
|
||||
"text": text.strip(),
|
||||
"timestamp": [start_time, end_time],
|
||||
}
|
||||
)
|
||||
|
||||
result = {
|
||||
"text": whisper_output["text"],
|
||||
"chunks": chunks,
|
||||
"language": whisper_output["language"],
|
||||
}
|
||||
|
||||
return whisper_output["text"], result
|
||||
|
||||
|
||||
class MTB_AudioCut(MtbAudio):
|
||||
"""Basic audio cutter, values are in ms."""
|
||||
|
||||
@@ -504,7 +98,7 @@ class MTB_AudioCut(MtbAudio):
|
||||
CATEGORY = "mtb/audio"
|
||||
FUNCTION = "cut"
|
||||
|
||||
def cut(self, audio: AudioTensor, length: float, offset: float):
|
||||
def cut(self, audio: AudioDict, length: float, offset: float):
|
||||
sample_rate = audio["sample_rate"]
|
||||
start_idx = int(offset * sample_rate / 1000)
|
||||
end_idx = min(
|
||||
@@ -523,6 +117,7 @@ class MTB_AudioCut(MtbAudio):
|
||||
|
||||
class MTB_AudioStack(MtbAudio):
|
||||
"""Stack/Overlay audio inputs (dynamic inputs).
|
||||
|
||||
- pad audios to the longest inputs.
|
||||
- resample audios to the highest sample rate in the inputs.
|
||||
- convert them all to stereo if one of the inputs is.
|
||||
@@ -537,7 +132,7 @@ class MTB_AudioStack(MtbAudio):
|
||||
CATEGORY = "mtb/audio"
|
||||
FUNCTION = "stack"
|
||||
|
||||
def stack(self, **kwargs: AudioTensor) -> tuple[AudioTensor]:
|
||||
def stack(self, **kwargs: AudioDict) -> tuple[AudioDict]:
|
||||
audios, is_stereo, max_rate = self.preprocess_audios(
|
||||
list(kwargs.values())
|
||||
)
|
||||
@@ -568,6 +163,7 @@ class MTB_AudioStack(MtbAudio):
|
||||
|
||||
class MTB_AudioSequence(MtbAudio):
|
||||
"""Sequence audio inputs (dynamic inputs).
|
||||
|
||||
- adding silence_duration between each segment
|
||||
can now also be negative to overlap the clips, safely bound
|
||||
to the the input length.
|
||||
@@ -591,7 +187,7 @@ class MTB_AudioSequence(MtbAudio):
|
||||
CATEGORY = "mtb/audio"
|
||||
FUNCTION = "sequence"
|
||||
|
||||
def sequence(self, silence_duration: float, **kwargs: AudioTensor):
|
||||
def sequence(self, silence_duration: float, **kwargs: AudioDict):
|
||||
audios, is_stereo, max_rate = self.preprocess_audios(
|
||||
list(kwargs.values())
|
||||
)
|
||||
@@ -636,287 +232,4 @@ class MTB_AudioSequence(MtbAudio):
|
||||
)
|
||||
|
||||
|
||||
class MTB_AudioResample(MtbAudio):
|
||||
"""Resample audio to a different sample rate."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
"sample_rate": (
|
||||
"INT",
|
||||
{
|
||||
"default": 16000,
|
||||
"min": 1000,
|
||||
"max": 192000,
|
||||
"step": 100,
|
||||
"tooltip": "Target sample rate in Hz. Whisper requires 16000.",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO",)
|
||||
RETURN_NAMES = ("resampled_audio",)
|
||||
CATEGORY = "mtb/audio"
|
||||
FUNCTION = "resample_audio"
|
||||
|
||||
def resample_audio(
|
||||
self, audio: AudioTensor, sample_rate: int
|
||||
) -> tuple[AudioTensor]:
|
||||
resampled = self.resample(audio, sample_rate)
|
||||
return (resampled,)
|
||||
|
||||
|
||||
class MTB_AudioIsolateSpeaker(MtbAudio):
|
||||
"""Isolate or mute specific speakers using WhisperData"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
"whisper_data": ("WHISPER_CHUNKS",),
|
||||
"target_speaker": ("STRING", {"default": "SPEAKER_00"}),
|
||||
"mode": (["isolate", "mute"], {"default": "isolate"}),
|
||||
"fade_ms": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 100.0,
|
||||
"min": 0.0,
|
||||
"max": 1000.0,
|
||||
"step": 10,
|
||||
"tooltip": "Fade duration in milliseconds to avoid clicks",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("AUDIO",)
|
||||
RETURN_NAMES = ("processed_audio",)
|
||||
CATEGORY = "mtb/audio"
|
||||
FUNCTION = "process_audio"
|
||||
|
||||
def process_audio(
|
||||
self,
|
||||
audio: AudioTensor,
|
||||
whisper_data: WhisperData,
|
||||
target_speaker: str,
|
||||
mode: str = "isolate",
|
||||
fade_ms: float = 100.0,
|
||||
) -> tuple[AudioTensor]:
|
||||
fade_samples = int((fade_ms / 1000.0) * audio["sample_rate"])
|
||||
|
||||
mask = (
|
||||
torch.zeros_like(audio["waveform"])
|
||||
if mode == "isolate"
|
||||
else torch.ones_like(audio["waveform"])
|
||||
)
|
||||
|
||||
for chunk in whisper_data["chunks"]:
|
||||
if not chunk.get("speaker"):
|
||||
continue
|
||||
|
||||
speaker_present = target_speaker in chunk["speaker"]
|
||||
if (mode == "isolate" and speaker_present) or (
|
||||
mode == "mute" and not speaker_present
|
||||
):
|
||||
start_sample = int(
|
||||
chunk["timestamp"][0] * audio["sample_rate"]
|
||||
)
|
||||
end_sample = int(chunk["timestamp"][1] * audio["sample_rate"])
|
||||
|
||||
mask[:, start_sample:end_sample] = 1.0
|
||||
|
||||
if fade_samples > 0:
|
||||
fade = torch.linspace(0, 1, fade_samples)
|
||||
|
||||
transitions = torch.where(mask[0, 1:] != mask[0, :-1])[0] + 1
|
||||
|
||||
for trans_idx in transitions:
|
||||
if (
|
||||
trans_idx >= fade_samples
|
||||
and trans_idx <= mask.shape[1] - fade_samples
|
||||
):
|
||||
if mask[0, trans_idx] == 1:
|
||||
mask[:, trans_idx : trans_idx + fade_samples] *= fade
|
||||
else:
|
||||
mask[:, trans_idx - fade_samples : trans_idx] *= (
|
||||
fade.flip(0)
|
||||
)
|
||||
|
||||
processed_waveform = audio["waveform"] * mask
|
||||
|
||||
return (
|
||||
{
|
||||
"sample_rate": audio["sample_rate"],
|
||||
"waveform": processed_waveform,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class MTB_ProcessWhisperDiarization:
|
||||
"""Process Whisper chunks with speaker diarization using either pyannote or NeMo."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"whisper_chunks": ("WHISPER_CHUNKS",),
|
||||
"audio": ("AUDIO",),
|
||||
"backend": (["pyannote", "nemo"], {"default": "pyannote"}),
|
||||
"num_speakers": (
|
||||
"INT",
|
||||
{"default": 2, "min": 1, "max": 10, "step": 1},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"device": (["cuda", "cpu"], {"default": "cuda"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("WHISPER_CHUNKS",)
|
||||
FUNCTION = "process"
|
||||
CATEGORY = "mtb/audio"
|
||||
|
||||
def process_pyannote(self, audio, num_speakers, device):
|
||||
"""Process audio using pyannote backend."""
|
||||
try:
|
||||
from pyannote.audio import Pipeline
|
||||
from pyannote.audio.pipelines.utils.hook import ProgressHook
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"pyannote.audio not found. Install with: pip install pyannote.audio"
|
||||
)
|
||||
|
||||
pipeline = Pipeline.from_pretrained(
|
||||
"pyannote/speaker-diarization-3.1", use_auth_token=None
|
||||
)
|
||||
pipeline.to(torch.device(device))
|
||||
with ProgressHook() as hook:
|
||||
diarization = pipeline(
|
||||
{
|
||||
"waveform": audio["waveform"][0],
|
||||
"sample_rate": audio["sample_rate"],
|
||||
},
|
||||
num_speakers=num_speakers,
|
||||
hook=hook,
|
||||
)
|
||||
|
||||
speaker_segments = []
|
||||
for turn, _, speaker in diarization.itertracks(yield_label=True):
|
||||
speaker_segments.append(
|
||||
{
|
||||
"start": turn.start,
|
||||
"end": turn.end,
|
||||
"speaker": speaker,
|
||||
}
|
||||
)
|
||||
|
||||
return speaker_segments
|
||||
|
||||
def process_nemo(self, audio, num_speakers, device):
|
||||
"""Process audio using NeMo backend."""
|
||||
try:
|
||||
import nemo.collections.asr as nemo_asr
|
||||
except ImportError:
|
||||
raise ImportError(
|
||||
"NeMo not found. Install with: pip install nemo_toolkit[asr]"
|
||||
)
|
||||
|
||||
model = nemo_asr.models.ClusteringDiarizer.from_pretrained(
|
||||
"nvidia/speakerverification_en_titanet_large"
|
||||
).to(device)
|
||||
|
||||
diarization = model.diarize(
|
||||
audio=audio["waveform"][0],
|
||||
sample_rate=audio["sample_rate"],
|
||||
num_speakers=num_speakers,
|
||||
)
|
||||
|
||||
speaker_segments = []
|
||||
for segment in diarization:
|
||||
speaker_segments.append(
|
||||
{
|
||||
"start": segment["start"],
|
||||
"end": segment["end"],
|
||||
"speaker": f"SPEAKER_{segment['speaker']}",
|
||||
}
|
||||
)
|
||||
|
||||
return speaker_segments
|
||||
|
||||
def process(
|
||||
self,
|
||||
whisper_chunks,
|
||||
audio,
|
||||
backend="pyannote",
|
||||
num_speakers=2,
|
||||
device="cuda",
|
||||
):
|
||||
if backend == "pyannote":
|
||||
speaker_segments = self.process_pyannote(
|
||||
audio, num_speakers, device
|
||||
)
|
||||
else: # nemo
|
||||
speaker_segments = self.process_nemo(audio, num_speakers, device)
|
||||
|
||||
for chunk in whisper_chunks["chunks"]:
|
||||
chunk_start, chunk_end = chunk["timestamp"]
|
||||
chunk_speakers = set()
|
||||
for segment in speaker_segments:
|
||||
if (
|
||||
segment["start"] <= chunk_end
|
||||
and segment["end"] >= chunk_start
|
||||
):
|
||||
chunk_speakers.add(segment["speaker"])
|
||||
|
||||
if chunk_speakers:
|
||||
chunk["speaker"] = list(chunk_speakers)[0]
|
||||
else:
|
||||
chunk["speaker"] = "unknown"
|
||||
|
||||
return (whisper_chunks,)
|
||||
|
||||
|
||||
class MTB_AudioDuration:
|
||||
"""Get audio duration in milliseconds."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"audio": ("AUDIO",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("INT",)
|
||||
RETURN_NAMES = ("duration_ms",)
|
||||
FUNCTION = "get_duration"
|
||||
CATEGORY = "mtb/audio"
|
||||
|
||||
def get_duration(self, audio):
|
||||
waveform = audio["waveform"]
|
||||
sample_rate = audio["sample_rate"]
|
||||
|
||||
duration_ms = int((waveform.shape[-1] / sample_rate) * 1000)
|
||||
log.debug(
|
||||
f"Audio duration: {duration_ms}ms ({duration_ms / 1000:.2f}s)"
|
||||
)
|
||||
|
||||
return (duration_ms,)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
MTB_AudioSequence,
|
||||
MTB_AudioStack,
|
||||
MTB_AudioCut,
|
||||
MTB_AudioResample,
|
||||
MTB_AudioIsolateSpeaker,
|
||||
MTB_LoadWhisper,
|
||||
MTB_AudioToText,
|
||||
MTB_ProcessWhisperOutput,
|
||||
MTB_ProcessWhisperDiarization,
|
||||
MTB_AudioDuration,
|
||||
]
|
||||
__nodes__ = [MTB_AudioSequence, MTB_AudioStack, MTB_AudioCut]
|
||||
|
||||
+17
-518
@@ -1,18 +1,12 @@
|
||||
import os
|
||||
import random
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
import comfy.utils
|
||||
import cv2
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..log import log
|
||||
from ..utils import EASINGS, apply_easing, glob_multiple, pil2tensor
|
||||
from ..utils import EASINGS, apply_easing, pil2tensor
|
||||
from .transform import MTB_TransformImage
|
||||
|
||||
|
||||
@@ -52,7 +46,7 @@ class MTB_BatchFloatMath:
|
||||
for v in vals:
|
||||
if len(v) != ref_count:
|
||||
raise ValueError(
|
||||
f"All values must have the same length (current: {len(v)}, ref: {ref_count})"
|
||||
f"All values must have the same length (current: {len(v)}, ref: {ref_count}"
|
||||
)
|
||||
|
||||
match operation:
|
||||
@@ -175,124 +169,6 @@ class MTB_BatchTimeWrap:
|
||||
return (warped_tensor, interpolated_curve)
|
||||
|
||||
|
||||
class MTB_ImageBatchToSublist:
|
||||
"""
|
||||
# Image Batch To Sublist 🔄
|
||||
|
||||
Splits a large batched tensor into smaller sub-batches for memory-efficient processing.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"sub_batch_size": (
|
||||
"INT",
|
||||
{"default": 1, "min": 1, "max": 1000, "step": 1},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK", "INT")
|
||||
RETURN_NAMES = ("image_list", "mask_list", "item_count")
|
||||
|
||||
OUTPUT_IS_LIST = (True, True)
|
||||
FUNCTION = "split_batch"
|
||||
CATEGORY = "batch_processing"
|
||||
|
||||
def split_batch(
|
||||
self,
|
||||
sub_batch_size: int,
|
||||
image: torch.Tensor | None = None,
|
||||
mask: torch.Tensor | None = None,
|
||||
):
|
||||
if image is None and mask is None:
|
||||
raise ValueError(
|
||||
"You must either pass mask or image, none received"
|
||||
)
|
||||
|
||||
image_count = 0
|
||||
if image is not None:
|
||||
image_count = image.size(0)
|
||||
|
||||
mask_count = 0
|
||||
if mask is not None:
|
||||
mask_count = mask.size(0)
|
||||
|
||||
if image_count > 0 and mask_count > 0 and mask_count != image_count:
|
||||
raise ValueError(
|
||||
f"When providing image and mask, batch size must match (got {mask.size(0)} mask and {image.size(0)} images)"
|
||||
)
|
||||
|
||||
batch_size = max(image_count, mask_count)
|
||||
|
||||
num_full_batches = batch_size // sub_batch_size
|
||||
im_batches = []
|
||||
mask_batches = []
|
||||
|
||||
for i in range(num_full_batches):
|
||||
start_idx = i * sub_batch_size
|
||||
end_idx = start_idx + sub_batch_size
|
||||
if image_count > 0:
|
||||
im_batches.append(image[start_idx:end_idx, ...])
|
||||
|
||||
if mask_count > 0:
|
||||
mask_batches.append(mask[start_idx:end_idx, ...])
|
||||
|
||||
if batch_size % sub_batch_size != 0:
|
||||
remaining_start = num_full_batches * sub_batch_size
|
||||
if image_count > 0:
|
||||
im_batches.append(image[remaining_start:, ...])
|
||||
|
||||
if mask_count > 0:
|
||||
mask_batches.append(mask[remaining_start:, ...])
|
||||
|
||||
return (im_batches, mask_batches, len(im_batches))
|
||||
|
||||
|
||||
class MTB_SublistToImageBatch:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"tensors": ("IMAGE",),
|
||||
}
|
||||
}
|
||||
|
||||
INPUT_IS_LIST = True
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "merge_batches"
|
||||
CATEGORY = "batch_processing"
|
||||
DOCUMENTATION = """# Sublist to Image Batch 🔄
|
||||
|
||||
Merges a list of sub-batched tensors back into a single large batch.
|
||||
"""
|
||||
|
||||
def merge_batches(self, tensors: list[torch.Tensor]):
|
||||
if len(tensors) <= 1:
|
||||
return (tensors[0],)
|
||||
|
||||
result = tensors[0]
|
||||
|
||||
for next_tensor in tensors[1:]:
|
||||
if result.shape[1:] != next_tensor.shape[1:]:
|
||||
next_tensor = comfy.utils.common_upscale(
|
||||
next_tensor.movedim(-1, 1),
|
||||
result.shape[2],
|
||||
result.shape[1],
|
||||
"lanczos",
|
||||
"center",
|
||||
).movedim(1, -1)
|
||||
|
||||
result = torch.cat((result, next_tensor), dim=0)
|
||||
|
||||
return (result,)
|
||||
|
||||
|
||||
class MTB_BatchMake:
|
||||
"""Simply duplicates the input frame as a batch"""
|
||||
|
||||
@@ -302,22 +178,18 @@ class MTB_BatchMake:
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"count": ("INT", {"default": 1}),
|
||||
},
|
||||
"optional": {"mask": ("MASK",)},
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "generate_batch"
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def generate_batch(self, image: torch.Tensor, count, mask=None):
|
||||
def generate_batch(self, image: torch.Tensor, count):
|
||||
if len(image.shape) == 3:
|
||||
image = image.unsqueeze(0)
|
||||
|
||||
return (
|
||||
image.repeat(count, 1, 1, 1),
|
||||
mask.repeat(count, 1, 1) if mask else mask,
|
||||
)
|
||||
return (image.repeat(count, 1, 1, 1),)
|
||||
|
||||
|
||||
class MTB_BatchShape:
|
||||
@@ -502,14 +374,8 @@ class MTB_BatchFloat:
|
||||
{"default": "Steps"},
|
||||
),
|
||||
"count": ("INT", {"default": 2}),
|
||||
"min": (
|
||||
"FLOAT",
|
||||
{"default": 0.0, "min": -1e4, "max": 1e4, "step": 0.001},
|
||||
),
|
||||
"max": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": -1e4, "max": 1e4, "step": 0.001},
|
||||
),
|
||||
"min": ("FLOAT", {"default": 0.0, "step": 0.001}),
|
||||
"max": ("FLOAT", {"default": 1.0, "step": 0.001}),
|
||||
"easing": (
|
||||
[
|
||||
"Linear",
|
||||
@@ -544,14 +410,7 @@ class MTB_BatchFloat:
|
||||
RETURN_TYPES = ("FLOATS",)
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def set_floats(
|
||||
self,
|
||||
mode: Literal["Steps"] | Literal["Single"] = "Steps",
|
||||
count: int = 1,
|
||||
min: float = 0.0, # noqa: A002
|
||||
max: float = 1.0, # noqa: A002
|
||||
easing: str = "Linear",
|
||||
):
|
||||
def set_floats(self, mode, count, min, max, easing):
|
||||
if mode == "Steps" and count == 1:
|
||||
raise ValueError(
|
||||
"Steps mode requires at least a count of 2 values"
|
||||
@@ -570,210 +429,6 @@ class MTB_BatchFloat:
|
||||
return (keyframes,)
|
||||
|
||||
|
||||
class MTB_BatchSequencePlus:
|
||||
"""Sequences multiple image batches with transition effects."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"transition": (
|
||||
[
|
||||
"none",
|
||||
"crossfade",
|
||||
"slide_left",
|
||||
"slide_right",
|
||||
"slide_up",
|
||||
"slide_down",
|
||||
"wipe_left",
|
||||
"wipe_right",
|
||||
"wipe_up",
|
||||
"wipe_down",
|
||||
"band_wipe_h",
|
||||
"band_wipe_v",
|
||||
],
|
||||
{"default": "none"},
|
||||
),
|
||||
"overlap_frames": (
|
||||
"INT",
|
||||
{"default": 0, "min": 0, "max": 120, "step": 1},
|
||||
),
|
||||
"reverse": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "sequence_batches"
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def apply_transition(
|
||||
self,
|
||||
frame1: torch.Tensor,
|
||||
frame2: torch.Tensor,
|
||||
transition: str,
|
||||
progress: float,
|
||||
):
|
||||
"""Apply transition effect between two frames."""
|
||||
if transition == "none":
|
||||
return frame1 if progress < 0.5 else frame2
|
||||
|
||||
elif transition == "crossfade":
|
||||
return frame1 * (1 - progress) + frame2 * progress
|
||||
|
||||
elif transition.startswith("slide_"):
|
||||
h, w = frame1.shape[1:3]
|
||||
if transition == "slide_left":
|
||||
offset = int(w * progress)
|
||||
frame2 = torch.roll(frame2, shifts=-offset, dims=2)
|
||||
elif transition == "slide_right":
|
||||
offset = int(w * progress)
|
||||
frame2 = torch.roll(frame2, shifts=offset, dims=2)
|
||||
elif transition == "slide_up":
|
||||
offset = int(h * progress)
|
||||
frame2 = torch.roll(frame2, shifts=-offset, dims=1)
|
||||
elif transition == "slide_down":
|
||||
offset = int(h * progress)
|
||||
frame2 = torch.roll(frame2, shifts=offset, dims=1)
|
||||
return frame1 * (1 - progress) + frame2 * progress
|
||||
|
||||
elif transition.startswith("wipe_"):
|
||||
h, w = frame1.shape[1:3]
|
||||
mask = torch.zeros_like(frame1)
|
||||
if transition == "wipe_left":
|
||||
edge = int(w * progress)
|
||||
mask[:, :, :edge, :] = 1
|
||||
elif transition == "wipe_right":
|
||||
edge = int(w * (1 - progress))
|
||||
mask[:, :, edge:, :] = 1
|
||||
elif transition == "wipe_up":
|
||||
edge = int(h * progress)
|
||||
mask[:, :edge, :, :] = 1
|
||||
elif transition == "wipe_down":
|
||||
edge = int(h * (1 - progress))
|
||||
mask[:, edge:, :, :] = 1
|
||||
return frame1 * (1 - mask) + frame2 * mask
|
||||
|
||||
elif transition.startswith("band_wipe_"):
|
||||
h, w = frame1.shape[1:3]
|
||||
mask = torch.zeros_like(frame1)
|
||||
num_bands = 10 # Number of bands
|
||||
|
||||
if transition == "band_wipe_h":
|
||||
band_width = w / num_bands
|
||||
for i in range(num_bands):
|
||||
edge = int((w * progress) - (i * band_width))
|
||||
start = int(i * band_width)
|
||||
end = int(min(start + edge, (i + 1) * band_width))
|
||||
if end > start:
|
||||
mask[:, :, start:end, :] = 1
|
||||
else: # band_wipe_v
|
||||
band_height = h / num_bands
|
||||
for i in range(num_bands):
|
||||
edge = int((h * progress) - (i * band_height))
|
||||
start = int(i * band_height)
|
||||
end = int(min(start + edge, (i + 1) * band_height))
|
||||
if end > start:
|
||||
mask[:, start:end, :, :] = 1
|
||||
|
||||
return frame1 * (1 - mask) + frame2 * mask
|
||||
|
||||
return frame1
|
||||
|
||||
def sequence_batches(
|
||||
self, transition: str, overlap_frames: int, reverse: bool, **kwargs
|
||||
):
|
||||
images: list[torch.Tensor] = list(kwargs.values())
|
||||
|
||||
if reverse:
|
||||
images = images[::-1]
|
||||
|
||||
processed_images: list[torch.Tensor] = []
|
||||
for img in images:
|
||||
if len(img.shape) == 3:
|
||||
img = img.unsqueeze(0)
|
||||
processed_images.append(img)
|
||||
|
||||
if overlap_frames == 0 or transition == "none":
|
||||
return (torch.cat(processed_images, dim=0),)
|
||||
|
||||
result_frames: list[torch.Tensor] = []
|
||||
|
||||
if len(processed_images) > 0:
|
||||
result_frames.extend(
|
||||
list(processed_images[0][: -overlap_frames // 2])
|
||||
)
|
||||
|
||||
for i in range(1, len(processed_images)):
|
||||
prev_batch = processed_images[i - 1]
|
||||
curr_batch = processed_images[i]
|
||||
|
||||
prev_frames = min(overlap_frames // 2, len(prev_batch))
|
||||
next_frames = min(overlap_frames // 2, len(curr_batch))
|
||||
total_overlap = prev_frames + next_frames
|
||||
|
||||
if total_overlap < 2:
|
||||
# when not enough frames for transition, just concatenate
|
||||
result_frames.extend(list(prev_batch[-prev_frames:]))
|
||||
result_frames.extend(list(curr_batch[:next_frames]))
|
||||
continue
|
||||
|
||||
for t in range(total_overlap):
|
||||
progress = t / (total_overlap - 1)
|
||||
|
||||
prev_idx = (
|
||||
len(prev_batch) - prev_frames + min(t, prev_frames - 1)
|
||||
)
|
||||
next_idx = max(0, t - prev_frames)
|
||||
|
||||
transition_frame = self.apply_transition(
|
||||
prev_batch[prev_idx : prev_idx + 1],
|
||||
curr_batch[next_idx : next_idx + 1],
|
||||
transition,
|
||||
progress,
|
||||
)
|
||||
result_frames.append(transition_frame[0])
|
||||
|
||||
if i < len(processed_images) - 1:
|
||||
result_frames.extend(
|
||||
list(curr_batch[next_frames : -overlap_frames // 2])
|
||||
)
|
||||
else:
|
||||
result_frames.extend(list(curr_batch[next_frames:]))
|
||||
|
||||
result = torch.stack(result_frames, dim=0)
|
||||
|
||||
return (result,)
|
||||
|
||||
|
||||
class MTB_BatchSequence:
|
||||
"""Sequences multiple image batches one after another"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"reverse": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "sequence_batches"
|
||||
CATEGORY = "mtb/batch"
|
||||
|
||||
def sequence_batches(self, reverse: bool, **kwargs):
|
||||
images = list(kwargs.values())
|
||||
if reverse:
|
||||
images = images[::-1]
|
||||
|
||||
processed = []
|
||||
for img in images:
|
||||
if len(img.shape) == 3:
|
||||
img = img.unsqueeze(0)
|
||||
processed.append(img)
|
||||
|
||||
return (torch.cat(processed, dim=0),)
|
||||
|
||||
|
||||
class MTB_BatchMerge:
|
||||
"""Merges multiple image batches with different frame counts"""
|
||||
|
||||
@@ -850,13 +505,6 @@ class MTB_Batch2dTransform:
|
||||
"zoom": ("FLOATS",),
|
||||
"angle": ("FLOATS",),
|
||||
"shear": ("FLOATS",),
|
||||
"use_normalized": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "If true, transform values will be scaled to image dimensions.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -885,7 +533,6 @@ class MTB_Batch2dTransform:
|
||||
zoom: list[float] | None = None,
|
||||
angle: list[float] | None = None,
|
||||
shear: list[float] | None = None,
|
||||
use_normalized: bool = False,
|
||||
):
|
||||
if all(
|
||||
self.get_num_elements(param) <= 0
|
||||
@@ -937,7 +584,6 @@ class MTB_Batch2dTransform:
|
||||
keyframes["shear"][i],
|
||||
border_handling,
|
||||
constant_color,
|
||||
use_normalized=use_normalized,
|
||||
)[0]
|
||||
for i in range(image.shape[0])
|
||||
]
|
||||
@@ -1065,9 +711,7 @@ class MTB_PlotBatchFloat:
|
||||
ax.set_xlim(1, max_length) # Set X-axis limits
|
||||
np.random.seed(seed)
|
||||
colors = np.random.rand(len(kwargs), 3) # Generate random RGB values
|
||||
for color, (label, values) in zip(
|
||||
colors, kwargs.items(), strict=False
|
||||
):
|
||||
for color, (label, values) in zip(colors, kwargs.items()):
|
||||
ax.plot(x_values[: len(values)], values, label=label, color=color)
|
||||
ax.legend(
|
||||
title="Legend",
|
||||
@@ -1381,163 +1025,18 @@ class MTB_BatchShake:
|
||||
return (shaken_images, x_translations, y_translations, rotations)
|
||||
|
||||
|
||||
class MTB_BatchFromFolder:
|
||||
"""Load images from a folder with options for latest, oldest, or random selection."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"enable": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": True,
|
||||
"tooltip": "Enable or disable the node. If disabled, returns passthrough_image or an empty tensor.",
|
||||
},
|
||||
),
|
||||
"folder_path": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": "Path to the folder containing images. Relative paths are resolved to the ComfyUI output directory.",
|
||||
},
|
||||
),
|
||||
"mode": (
|
||||
["latest", "oldest", "random"],
|
||||
{
|
||||
"default": "latest",
|
||||
"tooltip": "How to select images: latest, oldest, or random.",
|
||||
},
|
||||
),
|
||||
"count": (
|
||||
"INT",
|
||||
{
|
||||
"default": 10,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"tooltip": "Number of images to load from the folder.",
|
||||
},
|
||||
),
|
||||
"filter": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "*",
|
||||
"tooltip": "Glob filter for image filenames (e.g. *.png).",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"passthrough_image": (
|
||||
"IMAGE",
|
||||
{
|
||||
"tooltip": "If provided and node is disabled, this image is passed through instead of returning an empty tensor."
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("images",)
|
||||
CATEGORY = "mtb/batch"
|
||||
FUNCTION = "load_from_folder"
|
||||
|
||||
def load_from_folder(
|
||||
self,
|
||||
enable: bool,
|
||||
folder_path: str,
|
||||
mode: str,
|
||||
count: int,
|
||||
filter: str,
|
||||
passthrough_image=None,
|
||||
):
|
||||
"""Load images from a folder with the specified selection mode."""
|
||||
if not enable:
|
||||
if passthrough_image is not None:
|
||||
log.debug(
|
||||
"MTB_BatchFromFolder: Using passthrough image (disabled)"
|
||||
)
|
||||
return (passthrough_image,)
|
||||
log.debug(
|
||||
"MTB_BatchFromFolder: Disabled and no passthrough_image provided, returning empty tensor"
|
||||
)
|
||||
return (torch.zeros(0, 0, 0, 3),)
|
||||
|
||||
path_obj = Path(folder_path)
|
||||
if not path_obj.is_absolute():
|
||||
output_dir = Path(folder_paths.get_output_directory())
|
||||
path_obj = output_dir / folder_path
|
||||
path_obj = path_obj.resolve()
|
||||
|
||||
if not path_obj.exists():
|
||||
log.error(f"Folder path does not exist: {path_obj}")
|
||||
return (torch.zeros(0, 0, 0, 3),)
|
||||
|
||||
if not path_obj.is_dir():
|
||||
log.error(f"Path is not a directory: {path_obj}")
|
||||
return (torch.zeros(0, 0, 0, 3),)
|
||||
|
||||
patterns = [filter] if filter else ["*"]
|
||||
files = glob_multiple(path_obj, patterns)
|
||||
|
||||
image_extensions = [".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tiff"]
|
||||
image_files = [
|
||||
f for f in files if f.suffix.lower() in image_extensions
|
||||
]
|
||||
|
||||
if not image_files:
|
||||
log.warning(
|
||||
f"No image files found in {path_obj} with filter {filter}"
|
||||
)
|
||||
return (torch.zeros(0, 0, 0, 3),)
|
||||
|
||||
if mode == "latest":
|
||||
image_files.sort(key=lambda x: os.path.getmtime(x), reverse=True)
|
||||
elif mode == "oldest":
|
||||
image_files.sort(key=lambda x: os.path.getmtime(x))
|
||||
elif mode == "random":
|
||||
random.shuffle(image_files)
|
||||
|
||||
selected_files = image_files[:count]
|
||||
|
||||
if len(selected_files) < count:
|
||||
log.warning(
|
||||
f"Requested {count} images but only found {len(selected_files)}"
|
||||
)
|
||||
|
||||
loaded_images = []
|
||||
for file_path in selected_files:
|
||||
try:
|
||||
img = Image.open(file_path)
|
||||
if img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
loaded_images.append(img)
|
||||
except Exception as e:
|
||||
log.error(f"Error loading image {file_path}: {e}")
|
||||
|
||||
if not loaded_images:
|
||||
log.error("Failed to load any images")
|
||||
return (torch.zeros(0, 0, 0, 3),)
|
||||
|
||||
return (pil2tensor(loaded_images),)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
MTB_Batch2dTransform,
|
||||
MTB_BatchFloat,
|
||||
MTB_Batch2dTransform,
|
||||
MTB_BatchShape,
|
||||
MTB_BatchMake,
|
||||
MTB_BatchFloatAssemble,
|
||||
MTB_BatchFloatFill,
|
||||
MTB_BatchFloatNormalize,
|
||||
MTB_BatchMerge,
|
||||
MTB_BatchShake,
|
||||
MTB_PlotBatchFloat,
|
||||
MTB_BatchTimeWrap,
|
||||
MTB_BatchFloatFit,
|
||||
MTB_BatchFloatMath,
|
||||
MTB_BatchFloatNormalize,
|
||||
MTB_BatchFromFolder,
|
||||
MTB_BatchMake,
|
||||
MTB_BatchMerge,
|
||||
MTB_BatchSequence,
|
||||
MTB_BatchSequencePlus,
|
||||
MTB_BatchShake,
|
||||
MTB_BatchShape,
|
||||
MTB_BatchTimeWrap,
|
||||
MTB_PlotBatchFloat,
|
||||
MTB_SublistToImageBatch,
|
||||
MTB_ImageBatchToSublist,
|
||||
]
|
||||
|
||||
+1
-123
@@ -3,127 +3,10 @@ import shutil
|
||||
from pathlib import Path
|
||||
|
||||
import folder_paths
|
||||
import torch
|
||||
|
||||
from ..log import log
|
||||
from ..utils import here
|
||||
|
||||
Conditioning = list[tuple[torch.Tensor, dict[str, torch.Tensor]]]
|
||||
|
||||
|
||||
def check_condition(conditioning: Conditioning):
|
||||
has_cn = False
|
||||
if len(conditioning) > 1:
|
||||
log.warn(
|
||||
"More than one conditioning was provided. Only the first one will be used."
|
||||
)
|
||||
first = conditioning[0]
|
||||
cond, kwargs = first
|
||||
|
||||
log.debug("Conditioning Shape")
|
||||
log.debug(cond.shape)
|
||||
log.debug("Conditioning keys")
|
||||
log.debug([f"\t{k} - {type(kwargs[k])}" for k in kwargs])
|
||||
if "control" in kwargs:
|
||||
log.debug("Conditioning contains a controlnet")
|
||||
has_cn = True
|
||||
if "pooled_output" not in kwargs:
|
||||
raise ValueError(
|
||||
"Conditioning is not valid. Missing 'pooled_output' key."
|
||||
)
|
||||
return has_cn
|
||||
|
||||
|
||||
class MTB_InterpolateCondition:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"blend": (
|
||||
"FLOAT",
|
||||
{"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("CONDITIONING",)
|
||||
CATEGORY = "mtb/conditioning"
|
||||
FUNCTION = "execute"
|
||||
|
||||
def execute(
|
||||
self, blend: float, **kwargs: Conditioning
|
||||
) -> tuple[Conditioning]:
|
||||
blend = max(0.0, min(1.0, blend))
|
||||
|
||||
conditions: list[Conditioning] = list(kwargs.values())
|
||||
num_conditions = len(conditions)
|
||||
|
||||
if num_conditions < 2:
|
||||
raise ValueError("At least two conditioning inputs are required.")
|
||||
|
||||
segment_length = 1.0 / (num_conditions - 1)
|
||||
|
||||
segment_index = min(int(blend // segment_length), num_conditions - 2)
|
||||
|
||||
local_blend = (
|
||||
blend - (segment_index * segment_length)
|
||||
) / segment_length
|
||||
|
||||
cond_from = conditions[segment_index]
|
||||
cond_to = conditions[segment_index + 1]
|
||||
|
||||
from_cn = check_condition(cond_from)
|
||||
to_cn = check_condition(cond_to)
|
||||
|
||||
if from_cn and to_cn:
|
||||
raise ValueError(
|
||||
"Interpolating conditions cannot both contain ControlNets"
|
||||
)
|
||||
|
||||
try:
|
||||
interpolated_condition = [
|
||||
(1.0 - local_blend) * c_from + local_blend * c_to
|
||||
for c_from, c_to in zip(
|
||||
cond_from[0][0], cond_to[0][0], strict=False
|
||||
)
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Error during interpolation: {e}")
|
||||
raise
|
||||
|
||||
pooled_from = cond_from[0][1].get(
|
||||
"pooled_output",
|
||||
torch.zeros_like(
|
||||
next(iter(cond_from[0][1].values()), torch.tensor([]))
|
||||
),
|
||||
)
|
||||
|
||||
pooled_to = cond_to[0][1].get(
|
||||
"pooled_output",
|
||||
torch.zeros_like(
|
||||
next(iter(cond_from[0][1].values()), torch.tensor([]))
|
||||
),
|
||||
)
|
||||
|
||||
interpolated_pooled = (
|
||||
1.0 - local_blend
|
||||
) * pooled_from + local_blend * pooled_to
|
||||
|
||||
res = {"pooled_output": interpolated_pooled}
|
||||
|
||||
if from_cn:
|
||||
res["control"] = cond_from[0][1]["control"]
|
||||
res["control_apply_to_uncond"] = cond_from[0][1][
|
||||
"control_apply_to_uncond"
|
||||
]
|
||||
if to_cn:
|
||||
res["control"] = cond_to[0][1]["control"]
|
||||
res["control_apply_to_uncond"] = cond_to[0][1][
|
||||
"control_apply_to_uncond"
|
||||
]
|
||||
|
||||
return ([(torch.stack(interpolated_condition), res)],)
|
||||
|
||||
|
||||
class MTB_InterpolateClipSequential:
|
||||
@classmethod
|
||||
@@ -330,9 +213,4 @@ class MTB_StylesLoader:
|
||||
return (self.options[style_name][0], self.options[style_name][1])
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
MTB_SmartStep,
|
||||
MTB_StylesLoader,
|
||||
MTB_InterpolateClipSequential,
|
||||
MTB_InterpolateCondition,
|
||||
]
|
||||
__nodes__ = [MTB_SmartStep, MTB_StylesLoader, MTB_InterpolateClipSequential]
|
||||
|
||||
+1
-98
@@ -59,39 +59,6 @@ class MTB_SplitBbox:
|
||||
return (bbox[0], bbox[1], bbox[2], bbox[3])
|
||||
|
||||
|
||||
class MTB_UpscaleBboxBy:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bbox": ("BBOX",),
|
||||
"scale": ("FLOAT", {"default": 1.0}),
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY = "mtb/crop"
|
||||
RETURN_TYPES = ("BBOX",)
|
||||
|
||||
FUNCTION = "upscale"
|
||||
|
||||
def upscale(
|
||||
self, bbox: tuple[int, int, int, int], scale: float
|
||||
) -> tuple[tuple[int, int, int, int]]:
|
||||
x, y, width, height = bbox
|
||||
|
||||
center_x = x + width // 2
|
||||
center_y = y + height // 2
|
||||
|
||||
new_width = int(width * scale)
|
||||
new_height = int(height * scale)
|
||||
|
||||
new_x = center_x - new_width // 2
|
||||
new_y = center_y - new_height // 2
|
||||
|
||||
scaled = (new_x, new_y, new_width, new_height)
|
||||
return (scaled,)
|
||||
|
||||
|
||||
class MTB_BboxFromMask:
|
||||
"""From a mask extract the bounding box"""
|
||||
|
||||
@@ -375,68 +342,4 @@ class MTB_Uncrop:
|
||||
return (pil2tensor(out_images),)
|
||||
|
||||
|
||||
class MTB_BBoxForceDimensions:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"bbox": ("BBOX",),
|
||||
"width": ("INT", {"default": 512, "min": 1, "max": 8192}),
|
||||
"height": ("INT", {"default": 512, "min": 1, "max": 8192}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY = "mtb/crop"
|
||||
RETURN_TYPES = ("BBOX",)
|
||||
FUNCTION = "force_dimensions"
|
||||
|
||||
def force_dimensions(
|
||||
self,
|
||||
bbox: tuple[int, int, int, int],
|
||||
width: int,
|
||||
height: int,
|
||||
image: torch.Tensor = None,
|
||||
) -> tuple[tuple[int, int, int, int]]:
|
||||
x, y, curr_width, curr_height = bbox
|
||||
|
||||
center_x = x + curr_width // 2
|
||||
center_y = y + curr_height // 2
|
||||
|
||||
new_x = center_x - width // 2
|
||||
new_y = center_y - height // 2
|
||||
|
||||
if image is not None:
|
||||
img_height, img_width = image.shape[1:3]
|
||||
x_overflow = max(0, new_x + width - img_width) + min(0, new_x)
|
||||
y_overflow = max(0, new_y + height - img_height) + min(0, new_y)
|
||||
if width > img_width or height > img_height:
|
||||
x_exceed = width - img_width if width > img_width else 0
|
||||
y_exceed = height - img_height if height > img_height else 0
|
||||
raise ValueError(
|
||||
f"Target bbox dimensions ({width}x{height}) exceed image bounds ({img_width}x{img_height}) "
|
||||
f"by {x_exceed}px horizontally and {y_exceed}px vertically"
|
||||
)
|
||||
|
||||
if x_overflow > 0 or x_overflow < 0:
|
||||
new_x -= x_overflow
|
||||
|
||||
if y_overflow > 0:
|
||||
new_y -= y_overflow
|
||||
elif y_overflow < 0:
|
||||
new_y -= y_overflow # Add the negative overflow
|
||||
|
||||
return ((int(new_x), int(new_y), width, height),)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
MTB_BboxFromMask,
|
||||
MTB_Bbox,
|
||||
MTB_Crop,
|
||||
MTB_Uncrop,
|
||||
MTB_SplitBbox,
|
||||
MTB_UpscaleBboxBy,
|
||||
MTB_BBoxForceDimensions,
|
||||
]
|
||||
__nodes__ = [MTB_BboxFromMask, MTB_Bbox, MTB_Crop, MTB_Uncrop, MTB_SplitBbox]
|
||||
|
||||
+24
-101
@@ -2,6 +2,7 @@ import base64
|
||||
import io
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import folder_paths
|
||||
import torch
|
||||
@@ -10,66 +11,13 @@ from ..log import log
|
||||
from ..utils import tensor2pil
|
||||
|
||||
|
||||
def get_detailed_type_info(obj):
|
||||
type_info = []
|
||||
|
||||
type_name = type(obj).__name__
|
||||
type_info.append(f"Type: {type_name}")
|
||||
|
||||
if isinstance(obj, torch.Tensor):
|
||||
type_info.extend(
|
||||
[
|
||||
f"Shape: {obj.shape}",
|
||||
f"Dtype: {obj.dtype}",
|
||||
f"Device: {obj.device}",
|
||||
f"Requires grad: {obj.requires_grad}",
|
||||
f"Stride: {obj.stride()}",
|
||||
f"Contiguous: {obj.is_contiguous()}",
|
||||
]
|
||||
)
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
type_info.extend(
|
||||
[
|
||||
f"Length: {len(obj)}",
|
||||
f"Container type: {type_name}",
|
||||
]
|
||||
)
|
||||
if obj:
|
||||
type_info.append(f"Element type: {type(obj[0]).__name__}")
|
||||
elif isinstance(obj, dict):
|
||||
type_info.extend(
|
||||
[
|
||||
f"Length: {len(obj)}",
|
||||
f"Keys: {list(obj.keys())}",
|
||||
]
|
||||
)
|
||||
elif hasattr(obj, "__dict__"):
|
||||
attributes = [attr for attr in dir(obj) if not attr.startswith("_")]
|
||||
type_info.append(f"Attributes: {attributes}")
|
||||
|
||||
return type_info
|
||||
|
||||
|
||||
# region processors
|
||||
def process_tensor(tensor: torch.Tensor, as_type=False):
|
||||
def process_tensor(tensor):
|
||||
log.debug(f"Tensor: {tensor.shape}")
|
||||
|
||||
if as_type:
|
||||
return {
|
||||
"text": [f"Tensor of shape {tensor.shape} of type {tensor.dtype}"]
|
||||
}
|
||||
|
||||
is_mask = len(tensor.shape) == 3
|
||||
|
||||
if is_mask:
|
||||
tensor = tensor.unsqueeze(-1).repeat(1, 1, 1, 3)
|
||||
|
||||
image = tensor2pil(tensor)
|
||||
b64_imgs = []
|
||||
for im in image:
|
||||
if is_mask:
|
||||
im = im.convert("L")
|
||||
|
||||
buffered = io.BytesIO()
|
||||
im.save(buffered, format="PNG")
|
||||
b64_imgs.append(
|
||||
@@ -80,16 +28,11 @@ def process_tensor(tensor: torch.Tensor, as_type=False):
|
||||
return {"b64_images": b64_imgs}
|
||||
|
||||
|
||||
def process_list(anything, as_type=False):
|
||||
def process_list(anything):
|
||||
text = []
|
||||
if not anything:
|
||||
return {"text": []}
|
||||
|
||||
if as_type:
|
||||
type_info = get_detailed_type_info(anything)
|
||||
type_info.extend(get_detailed_type_info(anything[0]))
|
||||
return {"text": type_info}
|
||||
|
||||
first_element = anything[0]
|
||||
if (
|
||||
isinstance(first_element, list)
|
||||
@@ -111,41 +54,25 @@ def process_list(anything, as_type=False):
|
||||
return {"text": text}
|
||||
|
||||
|
||||
def process_dict(anything, as_type=False):
|
||||
def process_dict(anything):
|
||||
text = []
|
||||
if as_type:
|
||||
return {"text": get_detailed_type_info(anything)}
|
||||
|
||||
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}")
|
||||
|
||||
elif "waveform" in anything:
|
||||
is_empty = (
|
||||
"(empty) " if torch.count_nonzero(anything["samples"]) == 0 else ""
|
||||
)
|
||||
|
||||
text.append(
|
||||
f"Audio Samples: {anything['waveform'].shape}{is_empty} | sample rate {anything['sample_rate']}"
|
||||
)
|
||||
|
||||
else:
|
||||
log.debug(f"Unhandled dict: {anything.keys()}")
|
||||
text.append(json.dumps(anything, indent=2))
|
||||
|
||||
return {"text": text}
|
||||
|
||||
|
||||
def process_bool(anything, as_type=False):
|
||||
def process_bool(anything):
|
||||
return {"text": ["True" if anything else "False"]}
|
||||
|
||||
|
||||
def process_text(anything, as_type=False):
|
||||
if as_type:
|
||||
return {"text": get_detailed_type_info(anything)}
|
||||
|
||||
def process_text(anything):
|
||||
return {"text": [str(anything)]}
|
||||
|
||||
|
||||
@@ -162,7 +89,6 @@ class MTB_Debug:
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"output_to_console": ("BOOLEAN", {"default": False})},
|
||||
"optional": {"as_detailed_types": ("BOOLEAN", {"default": False})},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ()
|
||||
@@ -170,25 +96,29 @@ class MTB_Debug:
|
||||
CATEGORY = "mtb/debug"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def do_debug(
|
||||
self, output_to_console: bool, as_detailed_types: bool, **kwargs
|
||||
):
|
||||
output = {"ui": {"items": []}}
|
||||
def do_debug(self, output_to_console: bool, **kwargs):
|
||||
output = {
|
||||
"ui": {"b64_images": [], "text": []},
|
||||
# "result": ("A"),
|
||||
}
|
||||
|
||||
processors = {
|
||||
torch.Tensor: process_tensor,
|
||||
list: process_list,
|
||||
dict: process_dict,
|
||||
bool: process_bool,
|
||||
}
|
||||
if output_to_console:
|
||||
for k, v in kwargs.items():
|
||||
log.info(f"{k}: {v}")
|
||||
|
||||
for input_name, anything in kwargs.items():
|
||||
for anything in kwargs.values():
|
||||
processor = processors.get(type(anything), process_text)
|
||||
|
||||
processed = processor(anything, as_detailed_types)
|
||||
processed_data = processor(anything)
|
||||
|
||||
item = {
|
||||
"input": input_name,
|
||||
**processed,
|
||||
}
|
||||
output["ui"]["items"].append(item)
|
||||
for ui_key, ui_value in processed_data.items():
|
||||
output["ui"][ui_key].extend(ui_value)
|
||||
|
||||
return output
|
||||
|
||||
@@ -224,9 +154,9 @@ class MTB_SaveTensors:
|
||||
def save(
|
||||
self,
|
||||
filename_prefix,
|
||||
image: torch.Tensor | None = None,
|
||||
mask: torch.Tensor | None = None,
|
||||
latent: torch.Tensor | None = None,
|
||||
image: Optional[torch.Tensor] = None,
|
||||
mask: Optional[torch.Tensor] = None,
|
||||
latent: Optional[torch.Tensor] = None,
|
||||
):
|
||||
(
|
||||
full_output_folder,
|
||||
@@ -258,11 +188,4 @@ class MTB_SaveTensors:
|
||||
return f"{filename_prefix}_{counter:05}"
|
||||
|
||||
|
||||
processors = {
|
||||
torch.Tensor: process_tensor,
|
||||
list: process_list,
|
||||
dict: process_dict,
|
||||
bool: process_bool,
|
||||
}
|
||||
|
||||
__nodes__ = [MTB_Debug, MTB_SaveTensors]
|
||||
|
||||
+4
-25
@@ -2,16 +2,13 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
|
||||
# torch must be imported prior to onnx for the CUDAProvider.
|
||||
import torch # isort:skip
|
||||
import onnxruntime as ort
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..errors import ModelNotFound
|
||||
from ..log import mklog
|
||||
from ..utils import (
|
||||
download_model,
|
||||
get_model_path,
|
||||
tensor2pil,
|
||||
tiles_infer,
|
||||
@@ -26,12 +23,7 @@ log = mklog(__name__)
|
||||
|
||||
# - COLOR to NORMALS
|
||||
def color_to_normals(
|
||||
color_img,
|
||||
overlap,
|
||||
progress_callback,
|
||||
*,
|
||||
save_temp=False,
|
||||
auto_download=False,
|
||||
color_img, overlap, progress_callback, *, save_temp=False
|
||||
):
|
||||
"""Compute a normal map from the given color map.
|
||||
|
||||
@@ -75,13 +67,7 @@ def color_to_normals(
|
||||
log.debug("DeepBump Color → Normals : loading model")
|
||||
model = get_model_path("deepbump", "deepbump256.onnx")
|
||||
if not model or not model.exists():
|
||||
if not auto_download:
|
||||
raise ModelNotFound(f"deepbump ({model})")
|
||||
log.debug("Downloading models...")
|
||||
download_model(
|
||||
"https://github.com/HugoTini/DeepBump/raw/master/deepbump256.onnx",
|
||||
"deepbump",
|
||||
)
|
||||
raise ModelNotFound(f"deepbump ({model})")
|
||||
|
||||
providers = [
|
||||
"TensorrtExecutionProvider",
|
||||
@@ -365,9 +351,6 @@ class MTB_DeepBump:
|
||||
),
|
||||
"normals_to_height_seamless": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
"optional": {
|
||||
"auto_download": ("BOOLEAN", {"default": True}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
@@ -383,7 +366,6 @@ class MTB_DeepBump:
|
||||
color_to_normals_overlap="SMALL",
|
||||
normals_to_curvature_blur_radius="SMALL",
|
||||
normals_to_height_seamless=True,
|
||||
auto_download=False,
|
||||
):
|
||||
images = tensor2pil(image)
|
||||
out_images = []
|
||||
@@ -398,10 +380,7 @@ class MTB_DeepBump:
|
||||
# Apply processing
|
||||
if mode == "Color to Normals":
|
||||
out_img = color_to_normals(
|
||||
in_img,
|
||||
color_to_normals_overlap,
|
||||
None,
|
||||
auto_download=auto_download,
|
||||
in_img, color_to_normals_overlap, None
|
||||
)
|
||||
if mode == "Normals to Curvature":
|
||||
out_img = normals_to_curvature(
|
||||
|
||||
@@ -78,7 +78,6 @@ class MTB_LoadFaceEnhanceModel:
|
||||
RETURN_NAMES = ("model",)
|
||||
FUNCTION = "load_model"
|
||||
CATEGORY = "mtb/facetools"
|
||||
DEPRECATED = True
|
||||
|
||||
def load_model(self, model_name, upscale=2, bg_upsampler=None):
|
||||
from gfpgan import GFPGANer
|
||||
@@ -164,7 +163,6 @@ class MTB_RestoreFace:
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "restore"
|
||||
CATEGORY = "mtb/facetools"
|
||||
DEPRECATED = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
|
||||
@@ -40,7 +40,6 @@ class MTB_LoadFaceAnalysisModel:
|
||||
RETURN_TYPES = ("FACE_ANALYSIS_MODEL",)
|
||||
FUNCTION = "load_model"
|
||||
CATEGORY = "mtb/facetools"
|
||||
DEPRECATED = True
|
||||
|
||||
def load_model(self, faceswap_model: str):
|
||||
if faceswap_model == "antelopev2":
|
||||
@@ -78,7 +77,6 @@ class MTB_LoadFaceSwapModel:
|
||||
RETURN_TYPES = ("FACESWAP_MODEL",)
|
||||
FUNCTION = "load_model"
|
||||
CATEGORY = "mtb/facetools"
|
||||
DEPRECATED = True
|
||||
|
||||
def load_model(self, faceswap_model: str):
|
||||
model_path = get_model_path("insightface", faceswap_model)
|
||||
@@ -128,7 +126,6 @@ class MTB_FaceSwap:
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "swap"
|
||||
CATEGORY = "mtb/facetools"
|
||||
DEPRECATED = True
|
||||
|
||||
def swap(
|
||||
self,
|
||||
|
||||
+52
-191
@@ -1,8 +1,4 @@
|
||||
import io
|
||||
|
||||
import requests
|
||||
import torch
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from PIL import Image
|
||||
|
||||
from ..log import log
|
||||
from ..utils import comfy_dir, font_path, pil2tensor
|
||||
@@ -85,6 +81,10 @@ class MTB_UnsplashImage:
|
||||
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:
|
||||
@@ -213,90 +213,14 @@ by default it fallsback to a default font.
|
||||
"INT",
|
||||
{"default": 100, "min": 1, "max": 100, "step": 1},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"whisper_chunks": ("WHISPER_CHUNKS",),
|
||||
"fps": (
|
||||
"INT",
|
||||
{"default": 24, "min": 1, "max": 60, "step": 1},
|
||||
),
|
||||
"fade_duration": (
|
||||
"FLOAT",
|
||||
{"default": 0.5, "min": 0.0, "max": 5.0, "step": 0.1},
|
||||
),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("image",)
|
||||
FUNCTION = "text_to_image"
|
||||
CATEGORY = "mtb/generate"
|
||||
|
||||
def create_animation_frames(
|
||||
self,
|
||||
chunks,
|
||||
base_image,
|
||||
font,
|
||||
font_size,
|
||||
color,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
fade_duration,
|
||||
):
|
||||
"""Create animation frames from Whisper chunks."""
|
||||
if not chunks or not chunks.get("chunks"):
|
||||
return [base_image]
|
||||
|
||||
frames = []
|
||||
total_duration = chunks["chunks"][-1]["timestamp"][1]
|
||||
frame_count = int(total_duration * fps)
|
||||
fade_frames = int(fade_duration * fps)
|
||||
|
||||
for frame_idx in range(frame_count):
|
||||
time = frame_idx / fps
|
||||
frame = base_image.copy()
|
||||
draw = ImageDraw.Draw(frame)
|
||||
|
||||
active_chunks = []
|
||||
for chunk in chunks["chunks"]:
|
||||
start, end = chunk["timestamp"]
|
||||
if start <= time <= end:
|
||||
fade_in_alpha = min(
|
||||
1.0, (time - start) * fps / fade_frames
|
||||
)
|
||||
fade_out_alpha = min(1.0, (end - time) * fps / fade_frames)
|
||||
alpha = min(fade_in_alpha, fade_out_alpha)
|
||||
active_chunks.append((chunk["text"], alpha))
|
||||
|
||||
y = height // 4
|
||||
for text, alpha in active_chunks:
|
||||
# Create a temporary image for the text with alpha
|
||||
text_img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
text_draw = ImageDraw.Draw(text_img)
|
||||
|
||||
text_draw.text(
|
||||
(width // 2, y),
|
||||
text,
|
||||
font=font,
|
||||
fill=color,
|
||||
anchor="mm",
|
||||
)
|
||||
|
||||
text_img.putalpha(
|
||||
Image.fromarray(
|
||||
(torch.ones((height, width)) * (alpha * 255))
|
||||
.byte()
|
||||
.numpy()
|
||||
)
|
||||
)
|
||||
|
||||
frame = Image.alpha_composite(frame, text_img)
|
||||
y += font_size * 1.5
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
return frames
|
||||
|
||||
def text_to_image(
|
||||
self,
|
||||
text: str,
|
||||
@@ -314,121 +238,58 @@ by default it fallsback to a default font.
|
||||
h_offset=0,
|
||||
v_offset=0,
|
||||
h_coverage=100,
|
||||
whisper_chunks=None,
|
||||
fps=24,
|
||||
fade_duration=0.5,
|
||||
):
|
||||
"""Convert text to image, with optional animation support."""
|
||||
import textwrap
|
||||
|
||||
from PIL import ImageColor
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
font_path = self.fonts[font]
|
||||
font = ImageFont.truetype(font_path, size=font_size)
|
||||
|
||||
try:
|
||||
if isinstance(color, str):
|
||||
color = ImageColor.getrgb(color)
|
||||
if isinstance(background, str):
|
||||
background = ImageColor.getrgb(background)
|
||||
|
||||
if len(color) == 3:
|
||||
color = color + (255,)
|
||||
if len(background) == 3:
|
||||
background = background + (255,)
|
||||
except ValueError as e:
|
||||
log.error(f"Color parsing error: {e}")
|
||||
color = (255, 255, 255, 255)
|
||||
background = (0, 0, 0, 255)
|
||||
|
||||
def render_text(text_to_render, alpha=None):
|
||||
if trim:
|
||||
text_to_render = (
|
||||
text_to_render.encode("ascii", "ignore").decode().strip()
|
||||
)
|
||||
if wrap:
|
||||
wrap_width = (((width / 100) * h_coverage) / font_size) * 2
|
||||
lines = textwrap.wrap(text_to_render, width=wrap_width)
|
||||
else:
|
||||
lines = [text_to_render]
|
||||
|
||||
img = Image.new("RGBA", (width, height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
line_height_px = line_height * font_size
|
||||
|
||||
if v_align == "top":
|
||||
y_text = v_offset
|
||||
elif v_align == "center":
|
||||
y_text = (
|
||||
(height - (line_height_px * len(lines))) // 2
|
||||
) + v_offset
|
||||
else:
|
||||
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)
|
||||
|
||||
for line in lines:
|
||||
line_width = get_width(line)
|
||||
if h_align == "left":
|
||||
x_text = h_offset
|
||||
elif h_align == "center":
|
||||
x_text = ((width - line_width) // 2) + h_offset
|
||||
else:
|
||||
x_text = (width - line_width) - h_offset
|
||||
|
||||
text_color = color
|
||||
if alpha is not None:
|
||||
text_color = tuple(
|
||||
list(color[:3]) + [int(alpha * color[3])]
|
||||
)
|
||||
|
||||
draw.text((x_text, y_text), line, fill=text_color, font=font)
|
||||
y_text += line_height_px
|
||||
|
||||
return img
|
||||
|
||||
base_img = Image.new("RGBA", (width, height), background)
|
||||
|
||||
if whisper_chunks and whisper_chunks.get("chunks"):
|
||||
frames = []
|
||||
total_duration = whisper_chunks["chunks"][-1]["timestamp"][1]
|
||||
frame_count = int(total_duration * fps)
|
||||
fade_frames = int(fade_duration * fps)
|
||||
|
||||
for frame_idx in range(frame_count):
|
||||
time = frame_idx / fps
|
||||
frame = base_img.copy()
|
||||
|
||||
active_chunks = []
|
||||
for chunk in whisper_chunks["chunks"]:
|
||||
start, end = chunk["timestamp"]
|
||||
if start <= time <= end:
|
||||
fade_in_alpha = min(
|
||||
1.0, (time - start) * fps / fade_frames
|
||||
)
|
||||
fade_out_alpha = min(
|
||||
1.0, (end - time) * fps / fade_frames
|
||||
)
|
||||
alpha = min(fade_in_alpha, fade_out_alpha)
|
||||
active_chunks.append((chunk["text"], alpha))
|
||||
|
||||
for chunk_text, alpha in active_chunks:
|
||||
chunk_img = render_text(chunk_text, alpha)
|
||||
frame = Image.alpha_composite(frame, chunk_img)
|
||||
|
||||
frames.append(frame)
|
||||
|
||||
frame_tensors = [pil2tensor(frame) for frame in frames]
|
||||
return (torch.cat(frame_tensors, dim=0),)
|
||||
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:
|
||||
text_img = render_text(text)
|
||||
result = Image.alpha_composite(base_img, text_img)
|
||||
return (pil2tensor(result),)
|
||||
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__ = [
|
||||
|
||||
+13
-228
@@ -1,6 +1,5 @@
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from math import pi
|
||||
@@ -45,22 +44,14 @@ class MTB_ToDevice:
|
||||
if torch.backends.mps.is_available():
|
||||
devices.append("mps")
|
||||
if torch.cuda.is_available():
|
||||
devices.append("cuda:0")
|
||||
for i in range(1, torch.cuda.device_count()):
|
||||
devices.append(f"cuda:{i}")
|
||||
devices.append("cuda")
|
||||
for i in range(torch.cuda.device_count()):
|
||||
devices.append(f"cuda{i}")
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"ignore_errors": ("BOOLEAN", {"default": False}),
|
||||
"device": (
|
||||
devices,
|
||||
{
|
||||
"default": "cuda"
|
||||
if torch.cuda.is_available()
|
||||
else "cpu"
|
||||
},
|
||||
),
|
||||
"device": (devices, {"default": "cpu"}),
|
||||
},
|
||||
"optional": {
|
||||
"image": ("IMAGE",),
|
||||
@@ -76,36 +67,20 @@ class MTB_ToDevice:
|
||||
def to_device(
|
||||
self,
|
||||
*,
|
||||
ignore_errors: bool = False,
|
||||
device: str = "cuda",
|
||||
ignore_errors=False,
|
||||
device="cuda",
|
||||
image: torch.Tensor | None = None,
|
||||
mask: torch.Tensor | None = 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 (
|
||||
device.startswith("cuda")
|
||||
and ":" not in device
|
||||
and device != "cuda"
|
||||
):
|
||||
device = f"cuda:{device[4:]}"
|
||||
|
||||
try:
|
||||
if image is not None:
|
||||
image = image.to(device)
|
||||
if mask is not None:
|
||||
mask = mask.to(device)
|
||||
except RuntimeError as e:
|
||||
if not ignore_errors:
|
||||
raise RuntimeError(
|
||||
f"Failed to move tensor to device {device}: {str(e)}"
|
||||
) from e
|
||||
log.warning(
|
||||
f"Failed to move tensor to device {device}, ignoring: {str(e)}"
|
||||
" 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)
|
||||
|
||||
|
||||
@@ -472,7 +447,7 @@ class MTB_AnyToString:
|
||||
|
||||
|
||||
class MTB_StringReplace:
|
||||
"""Basic string replacement with regex support."""
|
||||
"""Basic string replacement."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -481,7 +456,6 @@ class MTB_StringReplace:
|
||||
"string": ("STRING", {"forceInput": True}),
|
||||
"old": ("STRING", {"default": ""}),
|
||||
"new": ("STRING", {"default": ""}),
|
||||
"use_regex": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,19 +463,12 @@ class MTB_StringReplace:
|
||||
RETURN_TYPES = ("STRING",)
|
||||
CATEGORY = "mtb/string"
|
||||
|
||||
def replace_str(self, string: str, old: str, new: str, use_regex: bool):
|
||||
def replace_str(self, string: str, old: str, new: str):
|
||||
log.debug(f"Current string: {string}")
|
||||
log.debug(f"Find string: {old}")
|
||||
log.debug(f"Replace string: {new}")
|
||||
log.debug(f"Use regex: {use_regex}")
|
||||
|
||||
if use_regex:
|
||||
try:
|
||||
string = re.sub(old, new, string)
|
||||
except re.error as e:
|
||||
raise ValueError(f"Regex error: {e}") from e
|
||||
else:
|
||||
string = string.replace(old, new)
|
||||
string = string.replace(old, new)
|
||||
|
||||
log.debug(f"New string: {string}")
|
||||
|
||||
@@ -686,187 +653,6 @@ class MTB_ConcatImages:
|
||||
return (concatenated,)
|
||||
|
||||
|
||||
class MTB_TensorOps:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"tensor": ("IMAGE",),
|
||||
"operation": (
|
||||
[
|
||||
"multiply",
|
||||
"divide",
|
||||
"add",
|
||||
"subtract",
|
||||
"power",
|
||||
"clamp",
|
||||
"abs",
|
||||
"log",
|
||||
"exp",
|
||||
"convert_dtype",
|
||||
"normalize_range",
|
||||
"normalize_per_channel",
|
||||
],
|
||||
{"default": "multiply"},
|
||||
),
|
||||
"value": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": -1000000.0,
|
||||
"max": 1000000.0,
|
||||
"step": 0.01,
|
||||
},
|
||||
),
|
||||
"source_min": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": -1000000.0,
|
||||
"max": 1000000.0,
|
||||
"step": 0.01,
|
||||
},
|
||||
),
|
||||
"source_max": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": -1000000.0,
|
||||
"max": 1000000.0,
|
||||
"step": 0.01,
|
||||
},
|
||||
),
|
||||
"target_min": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.0,
|
||||
"min": -1000000.0,
|
||||
"max": 1000000.0,
|
||||
"step": 0.01,
|
||||
},
|
||||
),
|
||||
"target_max": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 16.0,
|
||||
"min": -1000000.0,
|
||||
"max": 1000000.0,
|
||||
"step": 0.01,
|
||||
},
|
||||
),
|
||||
"dtype": (
|
||||
["uint8", "float32", "float16", "bfloat16"],
|
||||
{"default": "float32"},
|
||||
),
|
||||
"use_mean": ("BOOLEAN", {"default": False}),
|
||||
},
|
||||
"optional": {
|
||||
"target_tensor": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "apply"
|
||||
CATEGORY = "mtb/tensor_ops"
|
||||
|
||||
def apply(
|
||||
self,
|
||||
tensor,
|
||||
operation="multiply",
|
||||
value=1.0,
|
||||
source_min=0.0,
|
||||
source_max=1.0,
|
||||
target_min=0.0,
|
||||
target_max=1.0,
|
||||
dtype="float32",
|
||||
use_mean=False,
|
||||
target_tensor=None,
|
||||
):
|
||||
log.debug(
|
||||
f"Input tensor stats: shape={tensor.shape}, dtype={tensor.dtype}, range=[{tensor.min().item():.6f}, {tensor.max().item():.6f}]"
|
||||
)
|
||||
if operation == "normalize_per_channel":
|
||||
if target_tensor is None:
|
||||
raise ValueError(
|
||||
"Target tensor required for per-channel normalization"
|
||||
)
|
||||
|
||||
result = tensor.clone()
|
||||
for c in range(tensor.shape[-1]):
|
||||
if use_mean:
|
||||
source_mean = tensor[..., c].mean()
|
||||
target_mean = target_tensor[..., c].mean()
|
||||
scale = target_mean / source_mean
|
||||
result[..., c] = tensor[..., c] * scale
|
||||
else:
|
||||
source_min = tensor[..., c].min()
|
||||
source_max = tensor[..., c].max()
|
||||
target_min = target_tensor[..., c].min()
|
||||
target_max = target_tensor[..., c].max()
|
||||
|
||||
normalized = (tensor[..., c] - source_min) / (
|
||||
source_max - source_min
|
||||
)
|
||||
result[..., c] = (
|
||||
normalized * (target_max - target_min) + target_min
|
||||
)
|
||||
|
||||
log.debug(
|
||||
f"Channel {c} - Scale: source=[{source_min:.6f}, {source_max:.6f}], target=[{target_min:.6f}, {target_max:.6f}]"
|
||||
)
|
||||
|
||||
elif operation == "normalize_range":
|
||||
if target_tensor is not None:
|
||||
target_min = target_tensor.min().item()
|
||||
target_max = target_tensor.max().item()
|
||||
log.debug(
|
||||
f"Using target tensor range: [{target_min:.6f}, {target_max:.6f}]"
|
||||
)
|
||||
|
||||
normalized = (tensor - source_min) / (source_max - source_min)
|
||||
result = normalized * (target_max - target_min) + target_min
|
||||
elif operation == "convert_dtype":
|
||||
if dtype == "float32":
|
||||
result = tensor.float()
|
||||
elif dtype == "float16":
|
||||
result = tensor.half()
|
||||
elif dtype == "bfloat16":
|
||||
result = tensor.bfloat16()
|
||||
|
||||
else:
|
||||
result = tensor
|
||||
if operation == "multiply":
|
||||
result = tensor * value
|
||||
elif operation == "divide":
|
||||
result = tensor / value if value != 0 else tensor
|
||||
elif operation == "add":
|
||||
result = tensor + value
|
||||
elif operation == "subtract":
|
||||
result = tensor - value
|
||||
elif operation == "power":
|
||||
result = torch.pow(tensor, value)
|
||||
elif operation == "clamp":
|
||||
if target_tensor is not None:
|
||||
result = torch.clamp(
|
||||
tensor,
|
||||
target_tensor.min().item(),
|
||||
target_tensor.max().item(),
|
||||
)
|
||||
else:
|
||||
result = torch.clamp(tensor, source_min, source_max)
|
||||
elif operation == "abs":
|
||||
result = torch.abs(tensor)
|
||||
elif operation == "log":
|
||||
result = torch.log(tensor.clamp(min=1e-10))
|
||||
elif operation == "exp":
|
||||
result = torch.exp(tensor)
|
||||
|
||||
log.debug(
|
||||
f"Output tensor stats: shape={result.shape}, dtype={result.dtype}, range=[{result.min().item():.6f}, {result.max().item():.6f}]"
|
||||
)
|
||||
return (result,)
|
||||
|
||||
|
||||
__nodes__ = [
|
||||
MTB_StringReplace,
|
||||
MTB_FitNumber,
|
||||
@@ -881,5 +667,4 @@ __nodes__ = [
|
||||
MTB_FloatsToFloat,
|
||||
MTB_FloatToFloats,
|
||||
MTB_FloatsToInts,
|
||||
MTB_TensorOps,
|
||||
]
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
import comfy
|
||||
import comfy.model_management as model_management
|
||||
@@ -14,13 +15,10 @@ from ..utils import get_model_path
|
||||
|
||||
|
||||
class MTB_LoadFilmModel:
|
||||
"""Loads a FILM model
|
||||
|
||||
[DEPRECATED] Use ComfyUI-FrameInterpolation instead
|
||||
"""
|
||||
"""Loads a FILM model"""
|
||||
|
||||
@staticmethod
|
||||
def get_models() -> list[Path]:
|
||||
def get_models() -> List[Path]:
|
||||
models_paths = get_model_path("FILM").iterdir()
|
||||
|
||||
return [x for x in models_paths if x.suffix in [".onnx", ".pth"]]
|
||||
@@ -39,7 +37,6 @@ class MTB_LoadFilmModel:
|
||||
RETURN_TYPES = ("FILM_MODEL",)
|
||||
FUNCTION = "load_model"
|
||||
CATEGORY = "mtb/frame iterpolation"
|
||||
DEPRECATED = True
|
||||
|
||||
def load_model(self, film_model: str):
|
||||
model_path = get_model_path("FILM", film_model)
|
||||
@@ -59,10 +56,7 @@ class MTB_LoadFilmModel:
|
||||
|
||||
|
||||
class MTB_FilmInterpolation:
|
||||
"""Google Research FILM frame interpolation for large motion
|
||||
|
||||
[DEPRECATED] Use ComfyUI-FrameInterpolation instead
|
||||
"""
|
||||
"""Google Research FILM frame interpolation for large motion"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -77,7 +71,6 @@ class MTB_FilmInterpolation:
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "do_interpolation"
|
||||
CATEGORY = "mtb/frame iterpolation"
|
||||
DEPRECATED = True
|
||||
|
||||
def do_interpolation(
|
||||
self,
|
||||
|
||||
@@ -543,7 +543,7 @@ class MTB_ColorCorrect:
|
||||
adjusted = self.hsv_adjustment(adjusted, hue, saturation, value)
|
||||
|
||||
if clamp:
|
||||
adjusted = torch.clamp(adjusted, 0.0, 1.0)
|
||||
adjusted = torch.clamp(image, 0.0, 1.0)
|
||||
|
||||
result = (
|
||||
adjusted
|
||||
@@ -702,6 +702,7 @@ class MTB_Blur:
|
||||
)
|
||||
blurred_images.append(blurred)
|
||||
|
||||
image_np = np.array(blurred_images)
|
||||
else:
|
||||
for i in range(image.size(0)):
|
||||
blurred = gaussian(
|
||||
@@ -709,7 +710,8 @@ class MTB_Blur:
|
||||
)
|
||||
blurred_images.append(blurred)
|
||||
|
||||
return (np2tensor(blurred_images),)
|
||||
image_np = np.array(blurred_images)
|
||||
return (np2tensor(image_np).squeeze(0),)
|
||||
|
||||
|
||||
class MTB_Sharpen:
|
||||
|
||||
+30
-187
@@ -1,11 +1,4 @@
|
||||
import json
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from comfy.cli_args import args
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from ..log import log
|
||||
|
||||
@@ -15,21 +8,13 @@ class MTB_StackImages:
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {"vertical": ("BOOLEAN", {"default": False})},
|
||||
"optional": {
|
||||
"match_method": (
|
||||
["error", "smallest", "largest"],
|
||||
{"default": "error"},
|
||||
)
|
||||
},
|
||||
}
|
||||
return {"required": {"vertical": ("BOOLEAN", {"default": False})}}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "stack"
|
||||
CATEGORY = "mtb/image utils"
|
||||
|
||||
def stack(self, vertical, match_method="error", **kwargs):
|
||||
def stack(self, vertical, **kwargs):
|
||||
if not kwargs:
|
||||
raise ValueError("At least one tensor must be provided.")
|
||||
|
||||
@@ -39,62 +24,31 @@ class MTB_StackImages:
|
||||
f"{'vertically' if vertical else 'horizontally'}"
|
||||
)
|
||||
|
||||
target_device = tensors[0].device
|
||||
|
||||
normalized_tensors = [
|
||||
self.normalize_to_rgba(tensor.to(target_device))
|
||||
for tensor in tensors
|
||||
self.normalize_to_rgba(tensor) for tensor in tensors
|
||||
]
|
||||
|
||||
max_batch_size = max(tensor.shape[0] for tensor in normalized_tensors)
|
||||
normalized_tensors = [
|
||||
self.duplicate_frames(tensor, max_batch_size)
|
||||
for tensor in normalized_tensors
|
||||
]
|
||||
if match_method != "error":
|
||||
if vertical:
|
||||
# match widths
|
||||
widths = [tensor.shape[2] for tensor in normalized_tensors]
|
||||
target_width = (
|
||||
min(widths) if match_method == "smallest" else max(widths)
|
||||
)
|
||||
normalized_tensors = [
|
||||
self.resize_tensor(tensor, width=target_width)
|
||||
for tensor in normalized_tensors
|
||||
]
|
||||
else:
|
||||
# match heights
|
||||
heights = [tensor.shape[1] for tensor in normalized_tensors]
|
||||
target_height = (
|
||||
min(heights)
|
||||
if match_method == "smallest"
|
||||
else max(heights)
|
||||
)
|
||||
normalized_tensors = [
|
||||
self.resize_tensor(tensor, height=target_height)
|
||||
for tensor in normalized_tensors
|
||||
]
|
||||
else:
|
||||
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."
|
||||
)
|
||||
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 = 1 if vertical else 2
|
||||
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)
|
||||
|
||||
@@ -110,7 +64,7 @@ class MTB_StackImages:
|
||||
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(
|
||||
@@ -133,30 +87,6 @@ class MTB_StackImages:
|
||||
else:
|
||||
return tensor
|
||||
|
||||
def resize_tensor(self, tensor, width=None, height=None):
|
||||
"""Resize tensor to specified width or height while maintaining aspect ratio."""
|
||||
current_height, current_width = tensor.shape[1:3]
|
||||
|
||||
if width is not None and width != current_width:
|
||||
scale_factor = width / current_width
|
||||
new_height = int(current_height * scale_factor)
|
||||
new_width = width
|
||||
elif height is not None and height != current_height:
|
||||
scale_factor = height / current_height
|
||||
new_width = int(current_width * scale_factor)
|
||||
new_height = height
|
||||
else:
|
||||
return tensor
|
||||
|
||||
resized = torch.nn.functional.interpolate(
|
||||
tensor.permute(0, 3, 1, 2),
|
||||
size=(new_height, new_width),
|
||||
mode="bilinear",
|
||||
align_corners=False,
|
||||
)
|
||||
|
||||
return resized.permute(0, 2, 3, 1)
|
||||
|
||||
|
||||
class MTB_PickFromBatch:
|
||||
"""Pick a specific number of images from a batch.
|
||||
@@ -171,117 +101,30 @@ class MTB_PickFromBatch:
|
||||
"image": ("IMAGE",),
|
||||
"from_direction": (["end", "start"], {"default": "start"}),
|
||||
"count": ("INT", {"default": 1}),
|
||||
},
|
||||
"optional": {
|
||||
"mask": ("MASK",),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE", "MASK")
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "pick_from_batch"
|
||||
CATEGORY = "mtb/image utils"
|
||||
|
||||
def pick_from_batch(self, image, from_direction, count, mask=None):
|
||||
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)
|
||||
|
||||
selected_masks = None
|
||||
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:]
|
||||
if mask is not None:
|
||||
selected_masks = mask[-count:]
|
||||
else:
|
||||
selected_tensors = image[:count]
|
||||
if mask is not None:
|
||||
selected_masks = mask[:count]
|
||||
|
||||
return (selected_tensors, selected_masks)
|
||||
return (selected_tensors,)
|
||||
|
||||
|
||||
import folder_paths
|
||||
|
||||
|
||||
class MTB_SaveImage:
|
||||
def __init__(self):
|
||||
self.output_dir = folder_paths.get_output_directory()
|
||||
self.type = "output"
|
||||
self.prefix_append = ""
|
||||
self.compress_level = 4
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": ("IMAGE", {"tooltip": "The images to save."}),
|
||||
"filename_prefix": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "ComfyUI",
|
||||
"tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "save_images"
|
||||
|
||||
# OUTPUT_NODE = True
|
||||
|
||||
CATEGORY = "mtb/image utils"
|
||||
DESCRIPTION = """Saves the input images to your ComfyUI output directory.
|
||||
This behaves exactly like the native SaveImage node but isn't an output node.
|
||||
The reason I made this is to allow 'inlining' image save in loops for instance,
|
||||
using the native node there wouldn't run for each iteration of the loop."""
|
||||
|
||||
def save_images(
|
||||
self,
|
||||
images,
|
||||
filename_prefix="ComfyUI",
|
||||
prompt=None,
|
||||
extra_pnginfo=None,
|
||||
):
|
||||
filename_prefix += self.prefix_append
|
||||
full_output_folder, filename, counter, subfolder, filename_prefix = (
|
||||
folder_paths.get_save_image_path(
|
||||
filename_prefix,
|
||||
self.output_dir,
|
||||
images[0].shape[1],
|
||||
images[0].shape[0],
|
||||
)
|
||||
)
|
||||
results = list()
|
||||
for batch_number, image in enumerate(images):
|
||||
i = 255.0 * image.cpu().numpy()
|
||||
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
|
||||
metadata = None
|
||||
if not args.disable_metadata:
|
||||
metadata = PngInfo()
|
||||
if prompt is not None:
|
||||
metadata.add_text("prompt", json.dumps(prompt))
|
||||
if extra_pnginfo is not None:
|
||||
for x in extra_pnginfo:
|
||||
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
|
||||
|
||||
filename_with_batch_num = filename.replace(
|
||||
"%batch_num%", str(batch_number)
|
||||
)
|
||||
file = f"{filename_with_batch_num}_{counter:05}_.png"
|
||||
img.save(
|
||||
os.path.join(full_output_folder, file),
|
||||
pnginfo=metadata,
|
||||
compress_level=self.compress_level,
|
||||
)
|
||||
results.append(
|
||||
{"filename": file, "subfolder": subfolder, "type": self.type}
|
||||
)
|
||||
counter += 1
|
||||
|
||||
return {"ui": {"images": results}, "result": (images,)}
|
||||
|
||||
|
||||
__nodes__ = [MTB_StackImages, MTB_PickFromBatch, MTB_SaveImage]
|
||||
__nodes__ = [MTB_StackImages, MTB_PickFromBatch]
|
||||
|
||||
+19
-55
@@ -2,9 +2,9 @@ import json
|
||||
import subprocess
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
import comfy.model_management as model_management
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import torch
|
||||
@@ -41,7 +41,6 @@ class MTB_ReadPlaylist:
|
||||
RETURN_TYPES = ("PLAYLIST",)
|
||||
FUNCTION = "read_playlist"
|
||||
CATEGORY = "mtb/IO"
|
||||
EXPERIMENTAL = True
|
||||
|
||||
def read_playlist(
|
||||
self,
|
||||
@@ -84,7 +83,6 @@ class MTB_AddToPlaylist:
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "add_to_playlist"
|
||||
CATEGORY = "mtb/IO"
|
||||
EXPERIMENTAL = True
|
||||
|
||||
def add_to_playlist(
|
||||
self,
|
||||
@@ -119,10 +117,7 @@ class MTB_AddToPlaylist:
|
||||
|
||||
|
||||
class MTB_ExportWithFfmpeg:
|
||||
"""Export with FFmpeg (Experimental).
|
||||
|
||||
[DEPRACATED] Use VHS nodes instead
|
||||
"""
|
||||
"""Export with FFmpeg (Experimental)"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -148,7 +143,6 @@ class MTB_ExportWithFfmpeg:
|
||||
RETURN_TYPES = ("VIDEO",)
|
||||
OUTPUT_NODE = True
|
||||
FUNCTION = "export_prores"
|
||||
DEPRECATED = True
|
||||
CATEGORY = "mtb/IO"
|
||||
|
||||
def export_prores(
|
||||
@@ -157,9 +151,10 @@ class MTB_ExportWithFfmpeg:
|
||||
prefix: str,
|
||||
format: str,
|
||||
codec: str,
|
||||
images: torch.Tensor | None = None,
|
||||
playlist: list[str] | None = None,
|
||||
images: Optional[torch.Tensor] = None,
|
||||
playlist: Optional[List[str]] = None,
|
||||
):
|
||||
pix_fmt = "rgb48le" if codec == "prores_ks" else "yuv420p"
|
||||
file_ext = format
|
||||
file_id = f"{prefix}_{uuid.uuid4()}.{file_ext}"
|
||||
|
||||
@@ -213,11 +208,9 @@ class MTB_ExportWithFfmpeg:
|
||||
frames = tensor2np(images)
|
||||
log.debug(f"Frames type {type(frames[0])}")
|
||||
log.debug(f"Exporting {len(frames)} frames")
|
||||
height, width, channels = frames[0].shape
|
||||
has_alpha = channels == 4
|
||||
out_path = (output_dir / file_id).as_posix()
|
||||
|
||||
if codec == "gif":
|
||||
out_path = (output_dir / file_id).as_posix()
|
||||
command = [
|
||||
"ffmpeg",
|
||||
"-f",
|
||||
@@ -240,28 +233,12 @@ class MTB_ExportWithFfmpeg:
|
||||
|
||||
process.stdin.close()
|
||||
process.wait()
|
||||
return (out_path,)
|
||||
else:
|
||||
if has_alpha:
|
||||
if codec in ["prores_ks", "libx264", "libx265"]:
|
||||
pix_fmt = (
|
||||
"yuva444p" if codec == "prores_ks" else "yuva420p"
|
||||
)
|
||||
frames = [
|
||||
frame.astype(np.uint16) * 257 for frame in frames
|
||||
]
|
||||
else:
|
||||
log.warning(
|
||||
f"Alpha channel not supported for codec {codec}. Alpha will be ignored."
|
||||
)
|
||||
frames = [
|
||||
frame[:, :, :3].astype(np.uint16) * 257
|
||||
for frame in frames
|
||||
]
|
||||
pix_fmt = "rgb48le" if codec == "prores_ks" else "yuv420p"
|
||||
else:
|
||||
pix_fmt = "rgb48le" if codec == "prores_ks" else "yuv420p"
|
||||
frames = [frame.astype(np.uint16) * 257 for frame in frames]
|
||||
frames = [frame.astype(np.uint16) * 257 for frame in frames]
|
||||
|
||||
height, width, _ = frames[0].shape
|
||||
|
||||
out_path = (output_dir / file_id).as_posix()
|
||||
|
||||
# Prepare the FFmpeg command
|
||||
command = [
|
||||
@@ -281,26 +258,17 @@ class MTB_ExportWithFfmpeg:
|
||||
"-",
|
||||
"-c:v",
|
||||
codec,
|
||||
"-r",
|
||||
str(fps),
|
||||
"-y",
|
||||
out_path,
|
||||
]
|
||||
if codec == "prores_ks":
|
||||
command.extend(["-profile:v", "4444"])
|
||||
|
||||
command.extend(
|
||||
[
|
||||
"-r",
|
||||
str(fps),
|
||||
"-y",
|
||||
out_path,
|
||||
]
|
||||
)
|
||||
|
||||
process = subprocess.Popen(command, stdin=subprocess.PIPE)
|
||||
|
||||
pbar = comfy.utils.ProgressBar(len(frames))
|
||||
|
||||
for frame in frames:
|
||||
model_management.throw_exception_if_processing_interrupted()
|
||||
process.stdin.write(frame.tobytes())
|
||||
pbar.update(1)
|
||||
|
||||
process.stdin.close()
|
||||
process.wait()
|
||||
@@ -312,9 +280,9 @@ def prepare_animated_batch(
|
||||
batch: torch.Tensor,
|
||||
pingpong=False,
|
||||
resize_by=1.0,
|
||||
resample_filter: Image.Resampling | None = None,
|
||||
resample_filter: Optional[Image.Resampling] = None,
|
||||
image_type=np.uint8,
|
||||
) -> list[Image.Image]:
|
||||
) -> List[Image.Image]:
|
||||
images = tensor2np(batch)
|
||||
images = [frame.astype(image_type) for frame in images]
|
||||
|
||||
@@ -340,10 +308,7 @@ def prepare_animated_batch(
|
||||
|
||||
# todo: deprecate for apng
|
||||
class MTB_SaveGif:
|
||||
"""Save the images from the batch as a GIF.
|
||||
|
||||
[DEPRACATED] Use VHS nodes instead
|
||||
"""
|
||||
"""Save the images from the batch as a GIF"""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
@@ -363,7 +328,6 @@ class MTB_SaveGif:
|
||||
OUTPUT_NODE = True
|
||||
CATEGORY = "mtb/IO"
|
||||
FUNCTION = "save_gif"
|
||||
DEPRECATED = True
|
||||
|
||||
def save_gif(
|
||||
self,
|
||||
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
from ..log import log
|
||||
|
||||
|
||||
class ImageH264Compression:
|
||||
"""Encodes the input with h264 compression using a configurable CRF."""
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"image": (
|
||||
"IMAGE",
|
||||
{
|
||||
"tooltip": "The input image tensor to be compressed and decompressed."
|
||||
},
|
||||
),
|
||||
"crf": (
|
||||
"INT",
|
||||
{
|
||||
"default": 23,
|
||||
"min": 0,
|
||||
"max": 51,
|
||||
"step": 1,
|
||||
"tooltip": "Constant Rate Factor for h264 encoding (lower values mean higher quality).",
|
||||
},
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "compress_and_decompress"
|
||||
|
||||
CATEGORY = "image"
|
||||
DESCRIPTION = """
|
||||
**Encodes the input with h264 compression using a configurable CRF**.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> This node is not really needed with the latest version of LTXVideo.
|
||||
|
||||
> [!NOTE]
|
||||
> This was recommended by the creators of LTX over banodoco's discord.
|
||||
|
||||
*Orginal code from [mix](https://github.com/XmYx)*"""
|
||||
|
||||
def _compress_decompress_ffmpeg(self, img_array, crf):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
input_path = os.path.join(temp_dir, "input.png")
|
||||
output_path = os.path.join(temp_dir, "output.mp4")
|
||||
decoded_path = os.path.join(temp_dir, "decoded.png")
|
||||
|
||||
Image.fromarray(img_array).save(input_path)
|
||||
|
||||
encode_command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
input_path,
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-crf",
|
||||
str(crf),
|
||||
"-pix_fmt",
|
||||
"yuv420p",
|
||||
"-frames:v",
|
||||
"1",
|
||||
output_path,
|
||||
]
|
||||
subprocess.run(encode_command, capture_output=True)
|
||||
|
||||
decode_command = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-i",
|
||||
output_path,
|
||||
"-frames:v",
|
||||
"1",
|
||||
decoded_path,
|
||||
]
|
||||
subprocess.run(decode_command, capture_output=True)
|
||||
|
||||
decoded_img = np.array(Image.open(decoded_path))
|
||||
return decoded_img
|
||||
|
||||
def compress_and_decompress(self, image, crf):
|
||||
import io
|
||||
|
||||
output_images = []
|
||||
|
||||
try:
|
||||
import av
|
||||
|
||||
for img_tensor in image:
|
||||
img_array = img_tensor.cpu().numpy()
|
||||
img_array = (img_array * 255).astype(np.uint8)
|
||||
img_array = img_array.copy(
|
||||
order="C"
|
||||
) # Ensure contiguous array
|
||||
|
||||
output = io.BytesIO()
|
||||
|
||||
# Encode the image to h264 with the given CRF
|
||||
container = av.open(output, mode="w", format="mp4")
|
||||
stream = container.add_stream("h264", rate=1)
|
||||
stream.width = img_array.shape[1]
|
||||
stream.height = img_array.shape[0]
|
||||
stream.pix_fmt = "yuv420p"
|
||||
stream.options = {"crf": str(crf)}
|
||||
|
||||
frame = av.VideoFrame.from_ndarray(img_array, format="rgb24")
|
||||
for packet in stream.encode(frame):
|
||||
container.mux(packet)
|
||||
for packet in stream.encode():
|
||||
container.mux(packet)
|
||||
container.close()
|
||||
|
||||
# Decode the video back to an image
|
||||
output.seek(0)
|
||||
container = av.open(output, mode="r", format="mp4")
|
||||
decoded_frames = []
|
||||
for frame in container.decode(video=0):
|
||||
img_decoded = frame.to_ndarray(format="rgb24")
|
||||
decoded_frames.append(img_decoded)
|
||||
container.close()
|
||||
|
||||
if len(decoded_frames) > 0:
|
||||
img_decoded = decoded_frames[0]
|
||||
img_decoded = torch.from_numpy(
|
||||
img_decoded.astype(np.float32) / 255.0
|
||||
)
|
||||
output_images.append(img_decoded)
|
||||
else:
|
||||
# If decoding failed, use the original image
|
||||
output_images.append(img_tensor)
|
||||
except ImportError:
|
||||
log.warning(
|
||||
"PyAv is not installed... Falling back to the ffmpeg cli"
|
||||
)
|
||||
for img_tensor in image:
|
||||
img_array = (img_tensor.cpu().numpy() * 255).astype(np.uint8)
|
||||
decoded_img = self._compress_decompress_ffmpeg(img_array, crf)
|
||||
img_decoded = torch.from_numpy(
|
||||
decoded_img.astype(np.float32) / 255.0
|
||||
)
|
||||
output_images.append(img_decoded)
|
||||
|
||||
output_images = torch.stack(output_images).to(image.device)
|
||||
return (output_images,)
|
||||
|
||||
|
||||
# fmt: off
|
||||
__nodes__ = [
|
||||
ImageH264Compression
|
||||
]
|
||||
+1
-2
@@ -1,5 +1,6 @@
|
||||
import comfy.utils
|
||||
from PIL import Image
|
||||
from rembg import remove
|
||||
|
||||
from ..utils import pil2tensor, tensor2pil
|
||||
|
||||
@@ -63,8 +64,6 @@ class MTB_ImageRemoveBackgroundRembg:
|
||||
post_process_mask,
|
||||
bgcolor,
|
||||
):
|
||||
from rembg import remove
|
||||
|
||||
pbar = comfy.utils.ProgressBar(image.size(0))
|
||||
images = tensor2pil(image)
|
||||
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
import comfy.utils
|
||||
import torch
|
||||
|
||||
from ..log import log
|
||||
from ..utils import nextAvailable, tensor2pil
|
||||
|
||||
RELATIVE_NOTICE = """
|
||||
Absolute paths are kept as is, relatives are from the output directory.
|
||||
"""
|
||||
|
||||
|
||||
class MTB_PostshotTrain:
|
||||
CATEGORY = "mtb/postshot"
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"images": (
|
||||
"IMAGE",
|
||||
{"tooltip": "These image will get save to disk first"},
|
||||
),
|
||||
"profile": (
|
||||
[
|
||||
"NeRF L",
|
||||
"NeRF M",
|
||||
"NeRF S",
|
||||
"NeRF XL",
|
||||
"NeRF XXL",
|
||||
"Splat ADC",
|
||||
"Splat MCMC",
|
||||
],
|
||||
{
|
||||
"default": "Splat MCMC",
|
||||
"tooltip": "The radiance field model profile to train",
|
||||
},
|
||||
),
|
||||
"image_select": (
|
||||
["all", "best"],
|
||||
{
|
||||
"default": "best",
|
||||
"tooltip": "How to select training images from the source image sets",
|
||||
},
|
||||
),
|
||||
"train_steps_limit": (
|
||||
"INT",
|
||||
{
|
||||
"default": 30,
|
||||
"min": 1,
|
||||
"max": 1000,
|
||||
"tooltip": "Number of kSteps to train the model for",
|
||||
},
|
||||
),
|
||||
"output_path": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "output",
|
||||
"tooltip": (
|
||||
"path to save the project to" f"{RELATIVE_NOTICE}"
|
||||
),
|
||||
},
|
||||
),
|
||||
"postshot_cli": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "C:/Program Files/Jawset Postshot/bin/postshot-cli.exe"
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"gpu": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"max": 255,
|
||||
"tooltip": "Specify the index of the GPU to use",
|
||||
},
|
||||
),
|
||||
"num_train_images": (
|
||||
"INT",
|
||||
{
|
||||
"default": 0,
|
||||
"min": 0,
|
||||
"tooltip": "If image-select best is used, specifies the number of training images to select",
|
||||
},
|
||||
),
|
||||
"max_image_size": (
|
||||
"INT",
|
||||
{
|
||||
"default": 1600,
|
||||
"min": 0,
|
||||
"tooltip": "Downscale training images such that their longer edge is at most this value in pixels. Disabled if zero.",
|
||||
},
|
||||
),
|
||||
"max_num_features": (
|
||||
"INT",
|
||||
{
|
||||
"default": 8,
|
||||
"min": 1,
|
||||
"tooltip": "Maximum number of 2D kFeatures extracted from each image.",
|
||||
},
|
||||
),
|
||||
"splat_density": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 1.0,
|
||||
"min": 0.125,
|
||||
"max": 8.0,
|
||||
"tooltip": (
|
||||
"Controls how much additional splats "
|
||||
"are generated during training."
|
||||
"Applies only in 'Splat ADC' profile."
|
||||
),
|
||||
},
|
||||
),
|
||||
"max_num_splats": (
|
||||
"INT",
|
||||
{
|
||||
"default": 3000,
|
||||
"min": 1,
|
||||
"tooltip": (
|
||||
"Sets the maximum number of splats (in kSplats)"
|
||||
" created during training. "
|
||||
"Applies only in 'Splat MCMC' profile."
|
||||
),
|
||||
},
|
||||
),
|
||||
"export_splat_ply": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"If not empty will also save a ply file."
|
||||
f"{RELATIVE_NOTICE}"
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
OUTPUT_NODE = True
|
||||
RETURN_NAMES = ("project_file_path",)
|
||||
FUNCTION = "train_model"
|
||||
|
||||
def train_model(
|
||||
self,
|
||||
images: torch.Tensor,
|
||||
profile: str,
|
||||
image_select: str,
|
||||
train_steps_limit: int,
|
||||
output_path: str,
|
||||
gpu=0,
|
||||
num_train_images=0,
|
||||
max_image_size=1600,
|
||||
max_num_features=8,
|
||||
splat_density=1.0,
|
||||
max_num_splats=3000,
|
||||
export_splat_ply="",
|
||||
postshot_cli="",
|
||||
):
|
||||
if not output_path.endswith(".psht"):
|
||||
output_path += ".psht"
|
||||
|
||||
output_path = nextAvailable(output_path)
|
||||
output_path.parent.mkdir(exist_ok=True)
|
||||
|
||||
pbar = comfy.utils.ProgressBar(200 + images.size(0))
|
||||
|
||||
try:
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
image_paths = []
|
||||
images_pil = tensor2pil(images)
|
||||
for i, img in enumerate(images_pil):
|
||||
try:
|
||||
img_path = os.path.join(temp_dir, f"image_{i:04d}.png")
|
||||
img.save(img_path)
|
||||
image_paths.append(img_path)
|
||||
except Exception as e:
|
||||
raise RuntimeError(
|
||||
f"Failed to save image {i}: {str(e)}"
|
||||
) from e
|
||||
pbar.update(1)
|
||||
|
||||
if not image_paths:
|
||||
raise ValueError("No valid images to process")
|
||||
|
||||
cmd = [postshot_cli, "train"]
|
||||
|
||||
for img_path in image_paths:
|
||||
cmd.extend(["-i", img_path])
|
||||
|
||||
cmd.extend(
|
||||
[
|
||||
"-p",
|
||||
profile,
|
||||
"--image-select",
|
||||
image_select,
|
||||
"-s",
|
||||
str(train_steps_limit),
|
||||
"-o",
|
||||
output_path.as_posix(),
|
||||
]
|
||||
)
|
||||
|
||||
if gpu is not None:
|
||||
cmd.extend(["--gpu", str(gpu)])
|
||||
if num_train_images > 0 and image_select == "best":
|
||||
cmd.extend(["--num-train-images", str(num_train_images)])
|
||||
if max_image_size > 0:
|
||||
cmd.extend(["--max-image-size", str(max_image_size)])
|
||||
if max_num_features != 8:
|
||||
cmd.extend(["--max-num-features", str(max_num_features)])
|
||||
if profile == "Splat ADC" and splat_density != 1.0:
|
||||
cmd.extend(["--splat-density", str(splat_density)])
|
||||
if profile == "Splat MCMC" and max_num_splats != 3000:
|
||||
cmd.extend(["--max-num-splats", str(max_num_splats)])
|
||||
if export_splat_ply:
|
||||
export_splat_ply = nextAvailable(export_splat_ply)
|
||||
cmd.extend(
|
||||
["--export-splat-ply", export_splat_ply.as_posix()]
|
||||
)
|
||||
|
||||
log.debug(f"Running {cmd}")
|
||||
|
||||
process = subprocess.Popen(
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
universal_newlines=True,
|
||||
)
|
||||
|
||||
last_step_c = 0
|
||||
last_step_t = 0
|
||||
while True:
|
||||
output = process.stdout.readline()
|
||||
if output == "" and process.poll() is not None:
|
||||
break
|
||||
if output:
|
||||
print(output)
|
||||
if "camera tracking step" in output.lower():
|
||||
try:
|
||||
current_step = int(
|
||||
output.split("%")[0].split(":")[1].strip()
|
||||
)
|
||||
if current_step > last_step_c:
|
||||
pbar.update(1)
|
||||
last_step_c = current_step
|
||||
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if "training radiance field:" in output.lower():
|
||||
try:
|
||||
current_step = int(
|
||||
output.split("%")[0].split(":")[1].strip()
|
||||
)
|
||||
if current_step > last_step_t:
|
||||
pbar.update(1)
|
||||
last_step_t = current_step
|
||||
|
||||
except (ValueError, IndexError):
|
||||
continue
|
||||
|
||||
if process.returncode != 0:
|
||||
_, stderr = process.communicate()
|
||||
raise RuntimeError(f"Postshot training failed: {stderr}")
|
||||
|
||||
if not os.path.exists(output_path):
|
||||
raise RuntimeError("Output file was not created")
|
||||
|
||||
return (output_path.as_posix(),)
|
||||
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Training failed: {str(e)}")
|
||||
finally:
|
||||
pbar.update(train_steps_limit)
|
||||
|
||||
|
||||
class MTB_PostshotExport:
|
||||
CATEGORY = "mtb/postshot"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"project_file": (
|
||||
"STRING",
|
||||
{"default": "", "forceInput": True},
|
||||
),
|
||||
"export_splat_ply": ("STRING", {"default": "output.ply"}),
|
||||
"postshot_cli": (
|
||||
"STRING",
|
||||
{
|
||||
"default": "C:/Program Files/Jawset Postshot/bin/postshot-cli.exe"
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
RETURN_NAMES = ("exported_ply_path",)
|
||||
FUNCTION = "export_model"
|
||||
|
||||
def export_model(
|
||||
self, project_file: str, export_splat_ply: str, postshot_cli: str
|
||||
):
|
||||
if not project_file.endswith(".psht"):
|
||||
raise ValueError("Project file must have .psht extension")
|
||||
|
||||
if not os.path.exists(project_file):
|
||||
raise FileNotFoundError(f"Project file not found: {project_file}")
|
||||
|
||||
if not export_splat_ply.endswith(".ply"):
|
||||
export_splat_ply += ".ply"
|
||||
|
||||
_export_splat_ply = nextAvailable(export_splat_ply)
|
||||
_export_splat_ply.parent.mkdir(exist_ok=True)
|
||||
|
||||
cmd = [
|
||||
postshot_cli,
|
||||
"export",
|
||||
"-f",
|
||||
project_file,
|
||||
"--export-splat-ply",
|
||||
_export_splat_ply.as_posix(),
|
||||
]
|
||||
|
||||
try:
|
||||
_result = subprocess.run(
|
||||
cmd, check=True, capture_output=True, text=True
|
||||
)
|
||||
|
||||
if not _export_splat_ply.exists():
|
||||
log.error("Export file was not created")
|
||||
|
||||
return (_export_splat_ply.as_posix(),)
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
raise RuntimeError(f"Export failed: {e.stderr}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Export failed: {str(e)}")
|
||||
|
||||
|
||||
__nodes__ = [MTB_PostshotExport, MTB_PostshotTrain]
|
||||
+11
-91
@@ -45,34 +45,6 @@ class MTB_TransformImage:
|
||||
),
|
||||
"constant_color": ("COLOR", {"default": "#000000"}),
|
||||
},
|
||||
"optional": {
|
||||
"filter_type": (
|
||||
[
|
||||
"nearest",
|
||||
"box",
|
||||
"bilinear",
|
||||
"hamming",
|
||||
"bicubic",
|
||||
"lanczos",
|
||||
],
|
||||
{"default": "bilinear"},
|
||||
),
|
||||
"stretch_x": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.001, "max": 10.0, "step": 0.01},
|
||||
),
|
||||
"stretch_y": (
|
||||
"FLOAT",
|
||||
{"default": 1.0, "min": 0.001, "max": 10.0, "step": 0.01},
|
||||
),
|
||||
"use_normalized": (
|
||||
"BOOLEAN",
|
||||
{
|
||||
"default": False,
|
||||
"tooltip": "If true, transform values are scaled to image dimensions.",
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
FUNCTION = "transform"
|
||||
@@ -89,36 +61,21 @@ class MTB_TransformImage:
|
||||
shear: float,
|
||||
border_handling="edge",
|
||||
constant_color=None,
|
||||
filter_type="nearest",
|
||||
stretch_x=1.0,
|
||||
stretch_y=1.0,
|
||||
use_normalized: bool = False,
|
||||
):
|
||||
filter_map = {
|
||||
"nearest": Image.NEAREST,
|
||||
"box": Image.BOX,
|
||||
"bilinear": Image.BILINEAR,
|
||||
"hamming": Image.HAMMING,
|
||||
"bicubic": Image.BICUBIC,
|
||||
"lanczos": Image.LANCZOS,
|
||||
}
|
||||
resampling_filter = filter_map[filter_type]
|
||||
|
||||
_, frame_height, frame_width, _ = image.size()
|
||||
if use_normalized:
|
||||
x = float(x) * frame_width
|
||||
y = float(y) * frame_height
|
||||
x = int(x)
|
||||
y = int(y)
|
||||
angle = int(angle)
|
||||
|
||||
log.debug(
|
||||
f"Zoom: {zoom} | x: {x}, y: {y}, angle: {angle}, shear: {shear} | stretch_x: {stretch_x}, stretch_y: {stretch_y}"
|
||||
f"Zoom: {zoom} | x: {x}, y: {y}, angle: {angle}, shear: {shear}"
|
||||
)
|
||||
|
||||
if image.size(0) == 0:
|
||||
return (torch.zeros(0),)
|
||||
transformed_images = []
|
||||
frames_count, frame_height, frame_width, frame_channel_count = (
|
||||
image.size()
|
||||
)
|
||||
|
||||
new_height, new_width = (
|
||||
int(frame_height * zoom),
|
||||
@@ -149,55 +106,18 @@ class MTB_TransformImage:
|
||||
|
||||
for img in tensor2pil(image):
|
||||
img = TF.pad(
|
||||
img,
|
||||
img, # transformed_frame,
|
||||
padding=padding,
|
||||
padding_mode=border_handling,
|
||||
fill=constant_color or 0,
|
||||
)
|
||||
|
||||
if stretch_x != 1.0 or stretch_y != 1.0:
|
||||
img = cast(
|
||||
Image.Image,
|
||||
TF.affine(
|
||||
img,
|
||||
angle=angle,
|
||||
scale=zoom,
|
||||
translate=[x, y],
|
||||
shear=shear,
|
||||
interpolation=resampling_filter,
|
||||
),
|
||||
)
|
||||
|
||||
width, height = img.size
|
||||
center = (width // 2, height // 2)
|
||||
|
||||
stretch_x_factor = 1.0 / stretch_x
|
||||
stretch_y_factor = 1.0 / stretch_y
|
||||
|
||||
matrix = [
|
||||
stretch_x_factor,
|
||||
0,
|
||||
center[0] - center[0] * stretch_x_factor,
|
||||
0,
|
||||
stretch_y_factor,
|
||||
center[1] - center[1] * stretch_y_factor,
|
||||
]
|
||||
|
||||
img = img.transform(
|
||||
img.size, Image.AFFINE, matrix, resampling_filter
|
||||
)
|
||||
else:
|
||||
img = cast(
|
||||
Image.Image,
|
||||
TF.affine(
|
||||
img,
|
||||
angle=angle,
|
||||
scale=zoom,
|
||||
translate=[x, y],
|
||||
shear=shear,
|
||||
interpolation=resampling_filter,
|
||||
),
|
||||
)
|
||||
img = cast(
|
||||
Image.Image,
|
||||
TF.affine(
|
||||
img, angle=angle, scale=zoom, translate=[x, y], shear=shear
|
||||
),
|
||||
)
|
||||
|
||||
left = abs(padding[0])
|
||||
upper = abs(padding[1])
|
||||
|
||||
+179
-182
@@ -1,182 +1,179 @@
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "comfy-mtb"
|
||||
version = "0.3.0"
|
||||
description = "Animation oriented nodes pack for ComfyUI."
|
||||
license = { text = "MIT" }
|
||||
readme = "README.md"
|
||||
# repository = ""
|
||||
# url = "https://github.com/melMass/comfy_mtb"
|
||||
authors = [{ name = "Mel Massadian", email = "mel@melmassadian.com" }]
|
||||
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",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"qrcode",
|
||||
"cachetools",
|
||||
"onnxruntime-gpu",
|
||||
"requirements-parserx",
|
||||
"rembg",
|
||||
"imageio_ffmpeg",
|
||||
"rich",
|
||||
"rich_argparse",
|
||||
"matplotlib",
|
||||
"pillow",
|
||||
]
|
||||
optional-dependencies = { mel = [
|
||||
"jupyterlab==4.1.6",
|
||||
], dev = [
|
||||
"black[jupyter]",
|
||||
"codespell",
|
||||
"marimo",
|
||||
"mypy",
|
||||
"pre-commit",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-random-order",
|
||||
"ruff",
|
||||
], doc = [
|
||||
"docutils==0.17.1",
|
||||
"jupyter-book>=0.15",
|
||||
"sphinx-autobuild",
|
||||
] }
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/melMass/comfy_mtb"
|
||||
Documentation = "https://github.com/melMass/comfy_mtb/wiki"
|
||||
Repository = "https://github.com/melMass/comfy_mtb"
|
||||
Issues = "https://github.com/melMass/comfy_mtb/issues"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "mel"
|
||||
DisplayName = "comfy-mtb"
|
||||
Icon = "https://avatars.githubusercontent.com/u/7041726?v=4"
|
||||
|
||||
[tool.bumpversion]
|
||||
current_version = "0.3.0"
|
||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||
serialize = ["{major}.{minor}.{patch}"]
|
||||
search = "{current_version}"
|
||||
replace = "{new_version}"
|
||||
regex = false
|
||||
ignore_missing_version = false
|
||||
ignore_missing_files = false
|
||||
tag = true
|
||||
sign_tags = true
|
||||
tag_name = "v{new_version}"
|
||||
tag_message = "⬆️ Bump version: {current_version} → {new_version}"
|
||||
allow_dirty = true
|
||||
commit = true
|
||||
message = "⬆️ Bump version: {current_version} → {new_version}"
|
||||
commit_args = ""
|
||||
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "__init__.py"
|
||||
search = "__version__ = \"{current_version}\""
|
||||
replace = "__version__ = \"{new_version}\""
|
||||
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "pyproject.toml"
|
||||
search = "version = \"{current_version}\""
|
||||
replace = "version = \"{new_version}\""
|
||||
|
||||
# [[tool.bumpversion.files]]
|
||||
# filename = "your_package/__init__.py"
|
||||
# search = "__version__ = '{current_version}'"
|
||||
# replace = "__version__ = '{new_version}'"
|
||||
|
||||
# INFO: All those remaining keys are meant for local dev
|
||||
[tool.pyright]
|
||||
include = ["."]
|
||||
exclude = [
|
||||
"**/node_modules",
|
||||
"**/__pycache__",
|
||||
"src/experimental",
|
||||
"src/typestubs",
|
||||
]
|
||||
ignore = ["src/oldstuff"]
|
||||
defineConstant = { DEBUG = true }
|
||||
extraPaths = ["python", "../.."]
|
||||
stubPath = "src/stubs"
|
||||
|
||||
reportMissingImports = true
|
||||
reportMissingTypeStubs = false
|
||||
typeCheckingMode = "basic"
|
||||
|
||||
pythonVersion = "3.10"
|
||||
pythonPlatform = "Windows"
|
||||
|
||||
[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
|
||||
extend-exclude = ["./docs/conf.py", "notebooks", "stubs"]
|
||||
|
||||
[tool.ruff.lint]
|
||||
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"]
|
||||
|
||||
[tool.ruff.lint.per-file-ignores]
|
||||
# imported but unused
|
||||
"__init__.py" = ["F401"]
|
||||
# use of assert detected
|
||||
"tests/*" = ["S101"]
|
||||
|
||||
[tool.ruff.lint.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
|
||||
[build-system]
|
||||
requires = ["setuptools", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "comfy-mtb"
|
||||
version = "0.1.6"
|
||||
description = "Animation oriented nodes pack for ComfyUI."
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
# repository = ""
|
||||
# url = "https://github.com/melMass/comfy_mtb"
|
||||
authors = [{ name = "Mel Massadian", email = "mel@melmassadian.com" }]
|
||||
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",
|
||||
]
|
||||
requires-python = ">=3.10"
|
||||
dependencies = [
|
||||
"qrcode",
|
||||
"onnxruntime-gpu",
|
||||
"requirements-parserx",
|
||||
"rembg",
|
||||
"imageio_ffmpeg",
|
||||
"rich",
|
||||
"rich_argparse",
|
||||
"matplotlib",
|
||||
"pillow",
|
||||
]
|
||||
optional-dependencies = { mel = [
|
||||
"jupyterlab==4.1.6",
|
||||
], dev = [
|
||||
"black[jupyter]",
|
||||
"codespell",
|
||||
"mypy",
|
||||
"pre-commit",
|
||||
"pytest",
|
||||
"pytest-cov",
|
||||
"pytest-random-order",
|
||||
"ruff",
|
||||
], doc = [
|
||||
"docutils==0.17.1",
|
||||
"jupyter-book>=0.15",
|
||||
"sphinx-autobuild",
|
||||
] }
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/melMass/comfy_mtb"
|
||||
Documentation = "https://github.com/melMass/comfy_mtb/wiki"
|
||||
Repository = "https://github.com/melMass/comfy_mtb"
|
||||
Issues = "https://github.com/melMass/comfy_mtb/issues"
|
||||
|
||||
[tool.comfy]
|
||||
PublisherId = "mel"
|
||||
DisplayName = "comfy-mtb"
|
||||
Icon = "https://avatars.githubusercontent.com/u/7041726?v=4"
|
||||
|
||||
[tool.bumpversion]
|
||||
current_version = "0.1.6"
|
||||
parse = "(?P<major>\\d+)\\.(?P<minor>\\d+)\\.(?P<patch>\\d+)"
|
||||
serialize = ["{major}.{minor}.{patch}"]
|
||||
search = "{current_version}"
|
||||
replace = "{new_version}"
|
||||
regex = false
|
||||
ignore_missing_version = false
|
||||
ignore_missing_files = false
|
||||
tag = true
|
||||
sign_tags = true
|
||||
tag_name = "v{new_version}"
|
||||
tag_message = "⬆️ Bump version: {current_version} → {new_version}"
|
||||
allow_dirty = true
|
||||
commit = true
|
||||
message = "⬆️ Bump version: {current_version} → {new_version}"
|
||||
commit_args = ""
|
||||
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "__init__.py"
|
||||
search = "__version__ = \"{current_version}\""
|
||||
replace = "__version__ = \"{new_version}\""
|
||||
|
||||
[[tool.bumpversion.files]]
|
||||
filename = "pyproject.toml"
|
||||
search = "version = \"{current_version}\""
|
||||
replace = "version = \"{new_version}\""
|
||||
|
||||
# [[tool.bumpversion.files]]
|
||||
# filename = "your_package/__init__.py"
|
||||
# search = "__version__ = '{current_version}'"
|
||||
# replace = "__version__ = '{new_version}'"
|
||||
|
||||
# INFO: All those remaining keys are meant for local dev
|
||||
[tool.pyright]
|
||||
include = ["."]
|
||||
exclude = [
|
||||
"**/node_modules",
|
||||
"**/__pycache__",
|
||||
"src/experimental",
|
||||
"src/typestubs",
|
||||
]
|
||||
ignore = ["src/oldstuff"]
|
||||
defineConstant = { DEBUG = true }
|
||||
extraPaths = ["python", "../.."]
|
||||
stubPath = "src/stubs"
|
||||
|
||||
reportMissingImports = true
|
||||
reportMissingTypeStubs = false
|
||||
typeCheckingMode = "basic"
|
||||
|
||||
pythonVersion = "3.10"
|
||||
pythonPlatform = "Windows"
|
||||
|
||||
[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
|
||||
|
||||
@@ -8,5 +8,3 @@ rich
|
||||
rich_argparse
|
||||
matplotlib
|
||||
pillow
|
||||
cachetools
|
||||
transformers
|
||||
|
||||
@@ -16,50 +16,3 @@
|
||||
* @typedef {import("./shared.d.ts").INodeOutputSlot} INodeOutputSlot
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ResultItem
|
||||
* @property {string} [filename] - The filename of the item.
|
||||
* @property {string} [subfolder] - The subfolder of the item.
|
||||
* @property {string} [type] - The type of the item.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Outputs
|
||||
* @property {ResultItem[]} [audio] - Audio result items.
|
||||
* @property {ResultItem[]} [images] - Image result items.
|
||||
* @property {ResultItem[]} [animated] - Animated result items.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Record<string, Outputs>} TaskOutput
|
||||
* - A record mapping Node IDs to their Outputs.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Array} TaskPrompt
|
||||
* @property {QueueIndex} [0] - The queue index.
|
||||
* @property {PromptId} [1] - The unique prompt ID.
|
||||
* @property {PromptInputs} [2] - The prompt inputs.
|
||||
* @property {ExtraData} [3] - Extra data.
|
||||
* @property {OutputsToExecute} [4] - The outputs to execute.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} HistoryTaskItem
|
||||
* @property {'History'} taskType - The type of task.
|
||||
* @property {TaskPrompt} prompt - The task prompt.
|
||||
* @property {Status} [status] - The status of the task.
|
||||
* @property {TaskOutput} outputs - The task outputs.
|
||||
* @property {TaskMeta} [meta] - Optional task metadata.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} ExecInfo
|
||||
* @property {number} queue_remaining - The number of items remaining in the queue.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StatusWsMessageStatus
|
||||
* @property {ExecInfo} exec_info - Execution information.
|
||||
*/
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ import contextlib
|
||||
import functools
|
||||
import importlib
|
||||
import math
|
||||
import operator
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
@@ -12,12 +11,9 @@ import sys
|
||||
import uuid
|
||||
from collections.abc import Callable, Sequence
|
||||
from enum import Enum
|
||||
from functools import reduce
|
||||
from pathlib import Path
|
||||
from typing import TypeVar
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import comfy.utils
|
||||
import folder_paths
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
@@ -167,9 +163,9 @@ class IPChecker:
|
||||
def __init__(self):
|
||||
self.ips = list(self.get_local_ips())
|
||||
log.debug(f"Found {len(self.ips)} local ips")
|
||||
self.checked_ips: set[str] = set()
|
||||
self.checked_ips = set()
|
||||
|
||||
def get_working_ip(self, test_url_template: str):
|
||||
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)
|
||||
@@ -179,7 +175,7 @@ class IPChecker:
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def get_local_ips(prefix: str = "192.168."):
|
||||
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):
|
||||
@@ -189,9 +185,9 @@ class IPChecker:
|
||||
if info[0] == socket.AF_INET and info[4][0].startswith(prefix):
|
||||
yield info[4][0]
|
||||
|
||||
def _test_url(self, url: str):
|
||||
def _test_url(self, url):
|
||||
try:
|
||||
response = requests.get(url, timeout=10)
|
||||
response = requests.get(url)
|
||||
return response.status_code == 200
|
||||
except Exception:
|
||||
return False
|
||||
@@ -202,7 +198,7 @@ def get_server_info():
|
||||
from comfy.cli_args import args
|
||||
|
||||
ip_checker = IPChecker()
|
||||
base_url: str = args.listen
|
||||
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(
|
||||
@@ -216,37 +212,6 @@ def get_server_info():
|
||||
|
||||
|
||||
# region MISC Utilities
|
||||
def glob_multiple(
|
||||
path: Path, patterns: list[str], recursive: bool = False
|
||||
) -> list[Path]:
|
||||
"""Combine multiple glob patterns into a single iterator."""
|
||||
return list(reduce(operator.or_, (set(path.glob(p)) for p in patterns)))
|
||||
|
||||
|
||||
def build_glob_patterns(
|
||||
extensions: list[str], recursive: bool = False
|
||||
) -> list[str]:
|
||||
"""Build glob patterns for given extensions."""
|
||||
prefix = "**/" if recursive else ""
|
||||
return [f"{prefix}*.{ext}" for ext in extensions]
|
||||
|
||||
|
||||
class SortMode(Enum):
|
||||
NONE = "none"
|
||||
MODIFIED = "modified"
|
||||
MODIFIED_REVERSE = "modified-reverse"
|
||||
NAME = "name"
|
||||
NAME_REVERSE = "name-reverse"
|
||||
|
||||
@classmethod
|
||||
def from_str(cls, value: str | None) -> "SortMode|None":
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return cls(value.lower())
|
||||
except ValueError:
|
||||
log.warning(f"Sort mode {value} not supported")
|
||||
return None
|
||||
|
||||
|
||||
# TODO: use mtb.core directly instead of copying parts here
|
||||
@@ -500,12 +465,8 @@ 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)
|
||||
|
||||
|
||||
# NOTE: these aren't reliable, better call the getters each time
|
||||
output_dir = Path(folder_paths.output_directory)
|
||||
input_dir = Path(folder_paths.input_directory)
|
||||
|
||||
styles_dir = comfy_dir / "styles"
|
||||
session_id = str(uuid.uuid4())
|
||||
# - Construct the path to the font file
|
||||
@@ -515,10 +476,9 @@ font_path = here / "data" / "font.ttf"
|
||||
extern_root = here / "extern"
|
||||
add_path(extern_root)
|
||||
|
||||
if extern_root.exists():
|
||||
for pth in extern_root.iterdir():
|
||||
if pth.is_dir():
|
||||
add_path(pth)
|
||||
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_path(comfy_dir)
|
||||
@@ -632,37 +592,6 @@ def tensor2np(tensor: torch.Tensor) -> list[npt.NDArray[np.uint8]]:
|
||||
return handle_batch(tensor, single_tensor2np)
|
||||
|
||||
|
||||
def nextAvailable(path: Path | str) -> Path:
|
||||
"""
|
||||
Find the next available path by adding a numbered suffix. (mimics comfy's version).
|
||||
|
||||
Args:
|
||||
path (Path): The original path to check
|
||||
|
||||
Returns
|
||||
-------
|
||||
Path: A path that doesn't exist yet
|
||||
"""
|
||||
path = Path(path)
|
||||
|
||||
if not path.is_absolute():
|
||||
path = output_dir / path
|
||||
|
||||
if not path.exists():
|
||||
return path
|
||||
|
||||
stem = path.stem
|
||||
suffix = path.suffix
|
||||
parent = path.parent
|
||||
|
||||
counter = 1
|
||||
while True:
|
||||
new_path = parent / f"{stem}_{counter:04d}{suffix}"
|
||||
if not new_path.exists():
|
||||
return new_path
|
||||
counter += 1
|
||||
|
||||
|
||||
def pad(img, left, right, top, bottom):
|
||||
pad_width = np.array(((0, 0), (top, bottom), (left, right)))
|
||||
print(
|
||||
@@ -860,62 +789,6 @@ def tiles_split(img, tile_size, stride_size):
|
||||
|
||||
|
||||
# region MODEL Utilities
|
||||
|
||||
|
||||
def download_model(model_url: str, destination: str):
|
||||
if isinstance(model_url, list):
|
||||
for url in model_url:
|
||||
download_model(url, destination)
|
||||
return
|
||||
|
||||
filename = Path(urlparse(model_url).path).name
|
||||
|
||||
if "drive.google.com" in model_url:
|
||||
try:
|
||||
import gdown
|
||||
except ImportError:
|
||||
log.info("Installing gdown")
|
||||
subprocess.check_call(
|
||||
[
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pip",
|
||||
"install",
|
||||
"gdown",
|
||||
]
|
||||
)
|
||||
import gdown
|
||||
|
||||
if "/folders/" in model_url:
|
||||
# download folder
|
||||
try:
|
||||
gdown.download_folder(
|
||||
model_url, output=destination, resume=True
|
||||
)
|
||||
except TypeError:
|
||||
gdown.download_folder(model_url, output=destination)
|
||||
|
||||
return
|
||||
# download from google drive
|
||||
gdown.download(model_url, destination, quiet=False, resume=True)
|
||||
return True
|
||||
response = requests.get(model_url, stream=True)
|
||||
total_size = int(response.headers.get("content-length", 0))
|
||||
|
||||
destination_path = get_model_path(destination, filename)
|
||||
destination_path.parent.mkdir(exist_ok=True)
|
||||
|
||||
pbar = comfy.utils.ProgressBar(total_size)
|
||||
with open(destination_path, "wb") as file:
|
||||
for data in response.iter_content(chunk_size=4096):
|
||||
file.write(data)
|
||||
pbar.update(len(data))
|
||||
|
||||
log.info(
|
||||
f"Downloaded model from {model_url} to {destination_path}",
|
||||
)
|
||||
|
||||
|
||||
def download_antelopev2():
|
||||
antelopev2_url = (
|
||||
"https://drive.google.com/uc?id=18wEUfMNohBJ4K3Ly5wpTejPfDzp-8fI8"
|
||||
|
||||
+54
-92
@@ -1,9 +1,10 @@
|
||||
/**
|
||||
* @module Shared utilities
|
||||
* File: comfy_shared.js
|
||||
* Project: comfy_mtb
|
||||
* Author: Mel Massadian
|
||||
*
|
||||
* Copyright (c) 2023-2024 Mel Massadian
|
||||
*
|
||||
*/
|
||||
|
||||
// Reference the shared typedefs file
|
||||
@@ -260,16 +261,6 @@ export function inner_value_change(widget, val, event = undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
export const getNamedWidget = (node, ...names) => {
|
||||
const out = {}
|
||||
|
||||
for (const name of names) {
|
||||
out[name] = node.widgets.find((w) => w.name === name)
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {LGraphNode} node
|
||||
* @param {LLink} link
|
||||
@@ -371,37 +362,23 @@ export function getWidgetType(config) {
|
||||
* @param {NodeType} nodeType The nodetype to attach the documentation to
|
||||
* @param {str} prefix A prefix added to each dynamic inputs
|
||||
* @param {str | [str]} inputType The datatype(s) of those dynamic inputs
|
||||
* @param {{separator?:string, start_index?:number, link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}?} [opts] Extra options
|
||||
* @param {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}?} opts
|
||||
* @returns
|
||||
*/
|
||||
export const setupDynamicConnections = (
|
||||
nodeType,
|
||||
prefix,
|
||||
inputType,
|
||||
opts = undefined,
|
||||
) => {
|
||||
export const setupDynamicConnections = (nodeType, prefix, inputType, opts) => {
|
||||
infoLogger(
|
||||
'Setting up dynamic connections for',
|
||||
Object.getOwnPropertyDescriptors(nodeType).title.value,
|
||||
)
|
||||
|
||||
/** @type {{separator:string, start_index:number, link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}?} */
|
||||
const options = Object.assign(
|
||||
{
|
||||
separator: '_',
|
||||
start_index: 1,
|
||||
},
|
||||
opts || {},
|
||||
)
|
||||
/** @type {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} */
|
||||
const options = opts || {}
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
const inputList = typeof inputType === 'object'
|
||||
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, []) : undefined
|
||||
this.addInput(
|
||||
`${prefix}${options.separator}${options.start_index}`,
|
||||
inputList ? '*' : inputType,
|
||||
)
|
||||
this.addInput(`${prefix}_1`, inputList ? '*' : inputType)
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -436,7 +413,7 @@ export const setupDynamicConnections = (
|
||||
this,
|
||||
slotIndex,
|
||||
isConnected,
|
||||
`${prefix}${options.separator}`,
|
||||
`${prefix}_`,
|
||||
inputType,
|
||||
options,
|
||||
)
|
||||
@@ -452,7 +429,7 @@ export const setupDynamicConnections = (
|
||||
* @param {bool} connected - Was this event connecting or disconnecting
|
||||
* @param {string} [connectionPrefix] - The common prefix of the dynamic inputs
|
||||
* @param {string|[string]} [connectionType] - The type of the dynamic connection
|
||||
* @param {{start_index?:number, link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options
|
||||
* @param {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options
|
||||
*/
|
||||
export const dynamic_connection = (
|
||||
node,
|
||||
@@ -462,18 +439,13 @@ export const dynamic_connection = (
|
||||
connectionType = '*',
|
||||
opts = undefined,
|
||||
) => {
|
||||
/* {{start_index:number, link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options*/
|
||||
const options = Object.assign(
|
||||
{
|
||||
start_index: 1,
|
||||
},
|
||||
opts || {},
|
||||
)
|
||||
/* @type {{link?:LLink, ioSlot?:INodeInputSlot | INodeOutputSlot}} [opts] - extra options*/
|
||||
const options = opts || {}
|
||||
|
||||
// function to test if input is a dynamic one
|
||||
const isDynamicInput = (inputName) => inputName.startsWith(connectionPrefix)
|
||||
|
||||
if (node.inputs.length > 0 && !isDynamicInput(node.inputs[index].name)) {
|
||||
if (
|
||||
node.inputs.length > 0 &&
|
||||
!node.inputs[index].name.startsWith(connectionPrefix)
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -492,7 +464,7 @@ export const dynamic_connection = (
|
||||
const to_remove = []
|
||||
for (let n = 1; n < node.inputs.length; n++) {
|
||||
const element = node.inputs[n]
|
||||
if (!element.link && isDynamicInput(element.name)) {
|
||||
if (!element.link) {
|
||||
if (node.widgets) {
|
||||
const w = node.widgets.find((w) => w.name === element.name)
|
||||
if (w) {
|
||||
@@ -518,25 +490,14 @@ export const dynamic_connection = (
|
||||
|
||||
infoLogger('Cleaning inputs: making it sequential again')
|
||||
// make inputs sequential again
|
||||
let prefixed_idx = options.start_index
|
||||
for (let i = 0; i < node.inputs.length; i++) {
|
||||
let name = ''
|
||||
// rename only prefixed inputs
|
||||
if (isDynamicInput(node.inputs[i].name)) {
|
||||
// prefixed => rename and increase index
|
||||
name = `${connectionPrefix}${prefixed_idx}`
|
||||
prefixed_idx += 1
|
||||
} else {
|
||||
// not prefixed => keep same name
|
||||
name = node.inputs[i].name
|
||||
}
|
||||
let name = `${connectionPrefix}${i + 1}`
|
||||
|
||||
if (nameArray.length > 0) {
|
||||
name = i < nameArray.length ? nameArray[i] : name
|
||||
}
|
||||
|
||||
// preserve label if it exists
|
||||
node.inputs[i].label = node.inputs[i].label || name
|
||||
node.inputs[i].label = name
|
||||
node.inputs[i].name = name
|
||||
}
|
||||
}
|
||||
@@ -576,16 +537,11 @@ export const dynamic_connection = (
|
||||
if (node.inputs.length === 0) return
|
||||
// add an extra input
|
||||
if (node.inputs[node.inputs.length - 1].link !== null) {
|
||||
// count only the prefixed inputs
|
||||
const nextIndex = node.inputs.reduce(
|
||||
(acc, cur) => (isDynamicInput(cur.name) ? ++acc : acc),
|
||||
0,
|
||||
)
|
||||
|
||||
const nextIndex = node.inputs.length
|
||||
const name =
|
||||
nextIndex < nameArray.length
|
||||
? nameArray[nextIndex]
|
||||
: `${connectionPrefix}${nextIndex + options.start_index}`
|
||||
: `${connectionPrefix}${nextIndex + 1}`
|
||||
|
||||
infoLogger(`Adding input ${nextIndex + 1} (${name})`)
|
||||
node.addInput(name, conType)
|
||||
@@ -675,6 +631,39 @@ export const loadScript = (
|
||||
})
|
||||
}
|
||||
|
||||
export function defineClass(className, classStyles) {
|
||||
const styleSheets = document.styleSheets
|
||||
|
||||
// Helper function to check if the class exists in a style sheet
|
||||
function classExistsInStyleSheet(styleSheet) {
|
||||
const rules = styleSheet.rules || styleSheet.cssRules
|
||||
for (const rule of rules) {
|
||||
if (rule.selectorText === `.${className}`) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if the class is already defined in any of the style sheets
|
||||
let classExists = false
|
||||
for (const styleSheet of styleSheets) {
|
||||
if (classExistsInStyleSheet(styleSheet)) {
|
||||
classExists = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If the class doesn't exist, add the new class definition to the first style sheet
|
||||
if (!classExists) {
|
||||
if (styleSheets[0].insertRule) {
|
||||
styleSheets[0].insertRule(`.${className} { ${classStyles} }`, 0)
|
||||
} else if (styleSheets[0].addRule) {
|
||||
styleSheets[0].addRule(`.${className}`, classStyles, 0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region documentation widget
|
||||
@@ -1129,33 +1118,7 @@ export const addDeprecation = (nodeType, reason) => {
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Actions API
|
||||
export const runAction = async (name, ...args) => {
|
||||
const req = await api.fetchApi('/mtb/actions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
args,
|
||||
}),
|
||||
})
|
||||
|
||||
const res = await req.json()
|
||||
return res.result
|
||||
}
|
||||
export const getServerInfo = async () => {
|
||||
const res = await api.fetchApi('/mtb/server-info')
|
||||
return await res.json()
|
||||
}
|
||||
export const setServerInfo = async (opts) => {
|
||||
await api.fetchApi('/mtb/server-info', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(opts),
|
||||
})
|
||||
}
|
||||
|
||||
// #endregion
|
||||
|
||||
// #region Authoring API / graph utilities
|
||||
// #region API / graph utilities
|
||||
export const getAPIInputs = () => {
|
||||
const inputs = {}
|
||||
let counter = 1
|
||||
@@ -1208,4 +1171,3 @@ export const getNodes = (skip_unused) => {
|
||||
}
|
||||
return nodes
|
||||
}
|
||||
// #endregion
|
||||
|
||||
+40
-114
@@ -11,8 +11,11 @@
|
||||
/// <reference path="../types/typedefs.js" />
|
||||
|
||||
import { app } from '../../scripts/app.js'
|
||||
|
||||
import * as shared from './comfy_shared.js'
|
||||
import * as mtb_ui from './mtb_ui.js'
|
||||
import { MtbWidgets } from './mtb_widgets.js'
|
||||
|
||||
// TODO: respect inputs order...
|
||||
|
||||
function escapeHtml(unsafe) {
|
||||
return unsafe
|
||||
@@ -22,54 +25,6 @@ function escapeHtml(unsafe) {
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
}
|
||||
|
||||
function createDebugSection(title) {
|
||||
const section = mtb_ui.makeElement('div', {
|
||||
margin: '8px 0',
|
||||
padding: '8px',
|
||||
borderRadius: '4px',
|
||||
backgroundColor: 'rgba(0,0,0,0.2)'
|
||||
})
|
||||
|
||||
const header = mtb_ui.makeElement('h3', {
|
||||
margin: '0 0 8px 0',
|
||||
padding: '4px 0',
|
||||
borderBottom: '1px solid rgba(255,255,255,0.1)',
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
color: '#9f9'
|
||||
})
|
||||
header.textContent = title
|
||||
section.appendChild(header)
|
||||
|
||||
return section
|
||||
}
|
||||
|
||||
function createDebugContent(content, type) {
|
||||
const wrapper = mtb_ui.makeElement('div', {
|
||||
margin: '4px 0'
|
||||
})
|
||||
|
||||
if (type === 'text') {
|
||||
const text = mtb_ui.makeElement('p', {
|
||||
margin: '2px 0',
|
||||
fontFamily: 'monospace',
|
||||
whiteSpace: 'pre-wrap'
|
||||
})
|
||||
text.innerHTML = content
|
||||
wrapper.appendChild(text)
|
||||
} else if (type === 'image') {
|
||||
const img = mtb_ui.makeElement('img', {
|
||||
width: '100%',
|
||||
borderRadius: '2px'
|
||||
})
|
||||
img.src = content
|
||||
wrapper.appendChild(img)
|
||||
}
|
||||
|
||||
return wrapper
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
name: 'mtb.Debug',
|
||||
|
||||
@@ -81,10 +36,12 @@ app.registerExtension({
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name === 'Debug (mtb)') {
|
||||
const onNodeCreated = nodeType.prototype.onNodeCreated
|
||||
nodeType.prototype.onNodeCreated = function (...args) {
|
||||
nodeType.prototype.onNodeCreated = function () {
|
||||
this.options = {}
|
||||
const r = onNodeCreated ? onNodeCreated.apply(this, args) : undefined
|
||||
this.addInput('anything_1', '*')
|
||||
const r = onNodeCreated
|
||||
? onNodeCreated.apply(this, arguments)
|
||||
: undefined
|
||||
this.addInput(`anything_1`, '*')
|
||||
return r
|
||||
}
|
||||
|
||||
@@ -124,82 +81,51 @@ app.registerExtension({
|
||||
}
|
||||
|
||||
const onExecuted = nodeType.prototype.onExecuted
|
||||
nodeType.prototype.onExecuted = function (...args) {
|
||||
onExecuted?.apply(this, args)
|
||||
const [data, ..._rest] = args
|
||||
nodeType.prototype.onExecuted = function (data) {
|
||||
onExecuted?.apply(this, arguments)
|
||||
|
||||
const prefix = 'anything_'
|
||||
|
||||
if (this.widgets) {
|
||||
let tgt_len = this.widgets.length
|
||||
for (let i = 0; i < this.widgets.length; i++) {
|
||||
if (
|
||||
this.widgets[i].name !== 'output_to_console' &&
|
||||
this.widgets[i].name !== 'as_detailed_types'
|
||||
) {
|
||||
this.widgets[i].onRemove?.()
|
||||
if (this.widgets[i].name !== 'output_to_console') {
|
||||
this.widgets[i].onRemoved?.()
|
||||
tgt_len -= 1
|
||||
}
|
||||
}
|
||||
this.widgets.length = tgt_len
|
||||
this.widgets.length = 1
|
||||
}
|
||||
|
||||
const inputData = {}
|
||||
|
||||
const uiData = data.ui || data
|
||||
|
||||
if (uiData.items) {
|
||||
uiData.items.forEach(item => {
|
||||
const inputName = item.input
|
||||
if (!inputData[inputName]) {
|
||||
inputData[inputName] = { text: [], b64_images: [] }
|
||||
}
|
||||
if (item.text) {
|
||||
inputData[inputName].text.push(...item.text)
|
||||
}
|
||||
if (item.b64_images) {
|
||||
inputData[inputName].b64_images.push(...item.b64_images)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
let widgetI = 1
|
||||
for (const [inputName, content] of Object.entries(inputData)) {
|
||||
if (content.text.length === 0 && content.b64_images.length === 0) {
|
||||
continue
|
||||
// console.log(message)
|
||||
if (data.text) {
|
||||
for (const txt of data.text) {
|
||||
const w = this.addCustomWidget(
|
||||
MtbWidgets.DEBUG_STRING(`${prefix}_${widgetI}`, escapeHtml(txt)),
|
||||
)
|
||||
w.parent = this
|
||||
widgetI++
|
||||
}
|
||||
|
||||
const section = createDebugSection(inputName)
|
||||
|
||||
if (content.text.length > 0) {
|
||||
content.text.forEach(text => {
|
||||
section.appendChild(createDebugContent(text, 'text'))
|
||||
})
|
||||
}
|
||||
|
||||
if (content.b64_images.length > 0) {
|
||||
content.b64_images.forEach(img => {
|
||||
section.appendChild(createDebugContent(img, 'image'))
|
||||
})
|
||||
}
|
||||
|
||||
this.addDOMWidget(
|
||||
`debug_section_${widgetI}`,
|
||||
'CUSTOM',
|
||||
section,
|
||||
{}
|
||||
)
|
||||
widgetI++
|
||||
}
|
||||
if (data.b64_images) {
|
||||
for (const img of data.b64_images) {
|
||||
const w = this.addCustomWidget(
|
||||
MtbWidgets.DEBUG_IMG(`${prefix}_${widgetI}`, img),
|
||||
)
|
||||
w.parent = this
|
||||
widgetI++
|
||||
}
|
||||
}
|
||||
|
||||
// this.setSize(this.computeSize())
|
||||
|
||||
this.onRemoved = function () {
|
||||
for (const widget of this.widgets) {
|
||||
if (widget.canvas) {
|
||||
widget.canvas.remove()
|
||||
// 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()
|
||||
}
|
||||
widget.onRemoved?.()
|
||||
widget.onRemove?.()
|
||||
shared.cleanupNode(this)
|
||||
this.widgets[y].onRemoved?.()
|
||||
}
|
||||
shared.cleanupNode(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+295
-296
@@ -13,40 +13,40 @@ import { api } from '../../scripts/api.js'
|
||||
import { app } from '../../scripts/app.js'
|
||||
import { LocalStorageManager } from './comfy_shared.js'
|
||||
const styles = {
|
||||
lighbox: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'none',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 999,
|
||||
},
|
||||
lightboxBtn: (extra) => ({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
zIndex: 1000,
|
||||
fontSize: '30px',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
...extra,
|
||||
}),
|
||||
img_list: {
|
||||
minHeight: '30px',
|
||||
maxHeight: '300px',
|
||||
width: '100vw',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
background: '#333',
|
||||
overflow: 'auto',
|
||||
},
|
||||
lighbox: {
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: '100vw',
|
||||
height: '100vh',
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'none',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
zIndex: 999,
|
||||
},
|
||||
lightboxBtn: (extra) => ({
|
||||
position: 'absolute',
|
||||
top: '50%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
zIndex: 1000,
|
||||
fontSize: '30px',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'auto',
|
||||
...extra,
|
||||
}),
|
||||
img_list: {
|
||||
minHeight: '30px',
|
||||
maxHeight: '300px',
|
||||
width: '100vw',
|
||||
position: 'absolute',
|
||||
bottom: 0,
|
||||
zIndex: 10,
|
||||
background: '#333',
|
||||
overflow: 'auto',
|
||||
},
|
||||
}
|
||||
|
||||
let currentImageIndex = 0
|
||||
@@ -58,299 +58,298 @@ const storage = new LocalStorageManager('mtb')
|
||||
let activated = storage.get('image_feed', false)
|
||||
|
||||
app.registerExtension({
|
||||
name: 'mtb.ImageFeed',
|
||||
setup: () => {
|
||||
app.ui.settings.addSetting({
|
||||
id: 'mtb.Main.image-feed-enabled',
|
||||
category: ['mtb', 'Main', 'image-feed-enabled'],
|
||||
name: 'Enable Image Feed',
|
||||
type: 'boolean',
|
||||
defaultValue: false,
|
||||
attrs: {
|
||||
style: {
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
},
|
||||
async onChange(value) {
|
||||
storage.set('image_feed', value)
|
||||
activated = value
|
||||
},
|
||||
})
|
||||
},
|
||||
init: async () => {
|
||||
if (!activated) {
|
||||
return
|
||||
}
|
||||
const pythongossFeed = app.extensions.find(
|
||||
(e) => e.name === 'pysssss.ImageFeed',
|
||||
)
|
||||
if (pythongossFeed) {
|
||||
console.warn(
|
||||
"[mtb] - Aborting the loading of mtb's imageFeed in favor of pysssss.ImageFeed",
|
||||
)
|
||||
activated = false // just in case other methods are added later on
|
||||
return
|
||||
}
|
||||
// - HTML & CSS
|
||||
//- lightbox
|
||||
const lightboxContainer = document.createElement('div')
|
||||
Object.assign(lightboxContainer.style, styles.lighbox)
|
||||
name: 'mtb.ImageFeed',
|
||||
setup: () => {
|
||||
app.ui.settings.addSetting({
|
||||
id: 'mtb.imageFeed.enabled',
|
||||
name: '[⚡mtb] Enable image feed',
|
||||
type: 'boolean',
|
||||
defaultValue: true,
|
||||
attrs: {
|
||||
style: {
|
||||
fontFamily: 'monospace',
|
||||
},
|
||||
},
|
||||
async onChange(value) {
|
||||
storage.set('image_feed', value)
|
||||
activated = value
|
||||
},
|
||||
})
|
||||
},
|
||||
init: async () => {
|
||||
if (!activated) {
|
||||
return
|
||||
}
|
||||
const pythongossFeed = app.extensions.find(
|
||||
(e) => e.name === 'pysssss.ImageFeed',
|
||||
)
|
||||
if (pythongossFeed) {
|
||||
console.warn(
|
||||
"[mtb] - Aborting the loading of mtb's imageFeed in favor of pysssss.ImageFeed",
|
||||
)
|
||||
activated = false // just in case other methods are added later on
|
||||
return
|
||||
}
|
||||
// - HTML & CSS
|
||||
//- lightbox
|
||||
const lightboxContainer = document.createElement('div')
|
||||
Object.assign(lightboxContainer.style, styles.lighbox)
|
||||
|
||||
const lightboxImage = document.createElement('img')
|
||||
Object.assign(lightboxImage.style, {
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
borderRadius: '5px',
|
||||
})
|
||||
const lightboxImage = document.createElement('img')
|
||||
Object.assign(lightboxImage.style, {
|
||||
maxHeight: '100%',
|
||||
maxWidth: '100%',
|
||||
borderRadius: '5px',
|
||||
})
|
||||
|
||||
// previous and next buttons
|
||||
const lightboxPrevBtn = document.createElement('button')
|
||||
const lightboxNextBtn = document.createElement('button')
|
||||
// previous and next buttons
|
||||
const lightboxPrevBtn = document.createElement('button')
|
||||
const lightboxNextBtn = document.createElement('button')
|
||||
|
||||
lightboxPrevBtn.textContent = '❮'
|
||||
lightboxNextBtn.textContent = '❯'
|
||||
lightboxPrevBtn.textContent = '❮'
|
||||
lightboxNextBtn.textContent = '❯'
|
||||
|
||||
Object.assign(lightboxPrevBtn.style, styles.lightboxBtn({ left: '0%' }))
|
||||
Object.assign(lightboxNextBtn.style, styles.lightboxBtn({ right: '0%' }))
|
||||
Object.assign(lightboxPrevBtn.style, styles.lightboxBtn({ left: '0%' }))
|
||||
Object.assign(lightboxNextBtn.style, styles.lightboxBtn({ right: '0%' }))
|
||||
|
||||
// close button
|
||||
const lightboxCloseBtn = document.createElement('button')
|
||||
Object.assign(
|
||||
lightboxCloseBtn.style,
|
||||
styles.lightboxBtn({ right: '0', top: '0' }),
|
||||
)
|
||||
lightboxCloseBtn.textContent = '❌'
|
||||
// close button
|
||||
const lightboxCloseBtn = document.createElement('button')
|
||||
Object.assign(
|
||||
lightboxCloseBtn.style,
|
||||
styles.lightboxBtn({ right: '0', top: '0' }),
|
||||
)
|
||||
lightboxCloseBtn.textContent = '❌'
|
||||
|
||||
const lightboxButtons = document.createElement('div')
|
||||
Object.assign(lightboxButtons.style, {
|
||||
position: 'absolute',
|
||||
top: '0%',
|
||||
right: '0%',
|
||||
// transform: "translate(50%, -50%)",
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '30px',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
const lightboxButtons = document.createElement('div')
|
||||
Object.assign(lightboxButtons.style, {
|
||||
position: 'absolute',
|
||||
top: '0%',
|
||||
right: '0%',
|
||||
// transform: "translate(50%, -50%)",
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
fontSize: '30px',
|
||||
cursor: 'pointer',
|
||||
pointerEvents: 'none',
|
||||
})
|
||||
|
||||
lightboxButtons.append(lightboxPrevBtn, lightboxNextBtn, lightboxCloseBtn)
|
||||
lightboxContainer.append(lightboxButtons, lightboxImage)
|
||||
lightboxButtons.append(lightboxPrevBtn, lightboxNextBtn, lightboxCloseBtn)
|
||||
lightboxContainer.append(lightboxButtons, lightboxImage)
|
||||
|
||||
//- image list
|
||||
const imageListContainer = document.createElement('div')
|
||||
Object.assign(imageListContainer.style, styles.img_list)
|
||||
//- image list
|
||||
const imageListContainer = document.createElement('div')
|
||||
Object.assign(imageListContainer.style, styles.img_list)
|
||||
|
||||
const createImgListBtn = (text, style) => {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.textContent = text
|
||||
Object.assign(btn.style, {
|
||||
...style,
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
background: 'none',
|
||||
height: '20px',
|
||||
cursor: 'pointer',
|
||||
position: 'absolute',
|
||||
top: '5px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '12px',
|
||||
})
|
||||
imageListContainer.append(btn)
|
||||
return btn
|
||||
}
|
||||
const showBtn = document.createElement('button')
|
||||
const closeBtn = createImgListBtn('❌', {
|
||||
width: '20px',
|
||||
textIndent: '-4px',
|
||||
right: '5px',
|
||||
})
|
||||
const loadButton = createImgListBtn('Load Session History', {
|
||||
right: '90px',
|
||||
})
|
||||
const clearButton = createImgListBtn('Clear', {
|
||||
right: '30px',
|
||||
})
|
||||
const createImgListBtn = (text, style) => {
|
||||
const btn = document.createElement('button')
|
||||
btn.type = 'button'
|
||||
btn.textContent = text
|
||||
Object.assign(btn.style, {
|
||||
...style,
|
||||
border: 'none',
|
||||
color: '#fff',
|
||||
background: 'none',
|
||||
height: '20px',
|
||||
cursor: 'pointer',
|
||||
position: 'absolute',
|
||||
top: '5px',
|
||||
fontSize: '12px',
|
||||
lineHeight: '12px',
|
||||
})
|
||||
imageListContainer.append(btn)
|
||||
return btn
|
||||
}
|
||||
const showBtn = document.createElement('button')
|
||||
const closeBtn = createImgListBtn('❌', {
|
||||
width: '20px',
|
||||
textIndent: '-4px',
|
||||
right: '5px',
|
||||
})
|
||||
const loadButton = createImgListBtn('Load Session History', {
|
||||
right: '90px',
|
||||
})
|
||||
const clearButton = createImgListBtn('Clear', {
|
||||
right: '30px',
|
||||
})
|
||||
|
||||
//- tools popup button
|
||||
showBtn.classList.add('comfy-settings-btn')
|
||||
Object.assign(showBtn.style, {
|
||||
right: '16px',
|
||||
cursor: 'pointer',
|
||||
display: 'none',
|
||||
})
|
||||
//- tools popup button
|
||||
showBtn.classList.add('comfy-settings-btn')
|
||||
Object.assign(showBtn.style, {
|
||||
right: '16px',
|
||||
cursor: 'pointer',
|
||||
display: 'none',
|
||||
})
|
||||
|
||||
//- append to DOM
|
||||
document.body.append(imageListContainer)
|
||||
//- append to DOM
|
||||
document.body.append(imageListContainer)
|
||||
|
||||
showBtn.textContent = '🖼'
|
||||
showBtn.onclick = () => {
|
||||
imageListContainer.style.display = 'block'
|
||||
showBtn.style.display = 'none'
|
||||
}
|
||||
document.querySelector('.comfy-settings-btn').after(showBtn)
|
||||
document.querySelector('.comfy-settings-btn').after(lightboxContainer)
|
||||
showBtn.textContent = '🖼'
|
||||
showBtn.onclick = () => {
|
||||
imageListContainer.style.display = 'block'
|
||||
showBtn.style.display = 'none'
|
||||
}
|
||||
document.querySelector('.comfy-settings-btn').after(showBtn)
|
||||
document.querySelector('.comfy-settings-btn').after(lightboxContainer)
|
||||
|
||||
// for (const { output } of history) {
|
||||
// if (output?.images) {
|
||||
// for (const src of output.images) {
|
||||
// const img = document.createElement("img");
|
||||
// const but = document.createElement("button");
|
||||
// for (const { output } of history) {
|
||||
// if (output?.images) {
|
||||
// for (const src of output.images) {
|
||||
// const img = document.createElement("img");
|
||||
// const but = document.createElement("button");
|
||||
|
||||
//- callbacks
|
||||
closeBtn.onclick = () => {
|
||||
imageListContainer.style.display = 'none'
|
||||
showBtn.style.display = 'unset'
|
||||
}
|
||||
//- callbacks
|
||||
closeBtn.onclick = () => {
|
||||
imageListContainer.style.display = 'none'
|
||||
showBtn.style.display = 'unset'
|
||||
}
|
||||
|
||||
clearButton.onclick = () => {
|
||||
imageListContainer.replaceChildren(closeBtn, clearButton, loadButton)
|
||||
}
|
||||
clearButton.onclick = () => {
|
||||
imageListContainer.replaceChildren(closeBtn, clearButton, loadButton)
|
||||
}
|
||||
|
||||
lightboxNextBtn.onclick = () => {
|
||||
currentImageIndex = (currentImageIndex + 1) % imageUrls.length
|
||||
const imageUrl = imageUrls[currentImageIndex]
|
||||
lightboxImage.src = imageUrl
|
||||
}
|
||||
lightboxNextBtn.onclick = () => {
|
||||
currentImageIndex = (currentImageIndex + 1) % imageUrls.length
|
||||
const imageUrl = imageUrls[currentImageIndex]
|
||||
lightboxImage.src = imageUrl
|
||||
}
|
||||
|
||||
// Modify the lightboxPrevBtn onclick callback
|
||||
lightboxPrevBtn.onclick = () => {
|
||||
currentImageIndex =
|
||||
(currentImageIndex - 1 + imageUrls.length) % imageUrls.length
|
||||
const imageUrl = imageUrls[currentImageIndex]
|
||||
lightboxImage.src = imageUrl
|
||||
}
|
||||
// Modify the lightboxPrevBtn onclick callback
|
||||
lightboxPrevBtn.onclick = () => {
|
||||
currentImageIndex =
|
||||
(currentImageIndex - 1 + imageUrls.length) % imageUrls.length
|
||||
const imageUrl = imageUrls[currentImageIndex]
|
||||
lightboxImage.src = imageUrl
|
||||
}
|
||||
|
||||
lightboxCloseBtn.onclick = () => {
|
||||
lightboxContainer.style.display = 'none'
|
||||
}
|
||||
lightboxImage.onclick = lightboxNextBtn.onclick
|
||||
/**
|
||||
* This is the function that creates the image buttons for the image list
|
||||
* They are wrapped in a button so that they can be clicked and open
|
||||
* the image in the lightbox.
|
||||
* @param {*} src
|
||||
*/
|
||||
const createImageBtn = (src) => {
|
||||
console.debug(`making image ${src.filename}`)
|
||||
const img = document.createElement('img')
|
||||
const but = document.createElement('button')
|
||||
lightboxCloseBtn.onclick = () => {
|
||||
lightboxContainer.style.display = 'none'
|
||||
}
|
||||
lightboxImage.onclick = lightboxNextBtn.onclick
|
||||
/**
|
||||
* This is the function that creates the image buttons for the image list
|
||||
* They are wrapped in a button so that they can be clicked and open
|
||||
* the image in the lightbox.
|
||||
* @param {*} src
|
||||
*/
|
||||
const createImageBtn = (src) => {
|
||||
console.debug(`making image ${src.filename}`)
|
||||
const img = document.createElement('img')
|
||||
const but = document.createElement('button')
|
||||
|
||||
Object.assign(but.style, {
|
||||
height: '120px',
|
||||
width: '120px',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
})
|
||||
Object.assign(img.style, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
})
|
||||
Object.assign(but.style, {
|
||||
height: '120px',
|
||||
width: '120px',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
})
|
||||
Object.assign(img.style, {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
})
|
||||
|
||||
img.src = `/view?filename=${encodeURIComponent(src.filename)}&type=${
|
||||
src.type
|
||||
}&subfolder=${encodeURIComponent(src.subfolder)}`
|
||||
img.src = `/view?filename=${encodeURIComponent(src.filename)}&type=${
|
||||
src.type
|
||||
}&subfolder=${encodeURIComponent(src.subfolder)}`
|
||||
|
||||
imageUrls.push(img.src)
|
||||
imageUrls.push(img.src)
|
||||
|
||||
console.debug(img.src)
|
||||
console.debug(img.src)
|
||||
|
||||
img.onload = () => {
|
||||
but.style.width = `${120 * (img.naturalWidth / img.naturalHeight)}px`
|
||||
}
|
||||
img.onload = () => {
|
||||
but.style.width = `${120 * (img.naturalWidth / img.naturalHeight)}px`
|
||||
}
|
||||
|
||||
but.onclick = () => {
|
||||
lightboxContainer.style.display = 'flex'
|
||||
// add the same image to the lightbox
|
||||
lightboxImage.src = img.src
|
||||
// lighboxContainer.replaceChildren(lightboxButtons, img);
|
||||
}
|
||||
but.onclick = () => {
|
||||
lightboxContainer.style.display = 'flex'
|
||||
// add the same image to the lightbox
|
||||
lightboxImage.src = img.src
|
||||
// lighboxContainer.replaceChildren(lightboxButtons, img);
|
||||
}
|
||||
|
||||
// add right click menu
|
||||
but.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault()
|
||||
// add right click menu
|
||||
but.addEventListener('contextmenu', (e) => {
|
||||
e.preventDefault()
|
||||
|
||||
if (image_menu) {
|
||||
image_menu.remove()
|
||||
}
|
||||
if (image_menu) {
|
||||
image_menu.remove()
|
||||
}
|
||||
|
||||
image_menu = document.createElement('div')
|
||||
Object.assign(image_menu.style, {
|
||||
position: 'absolute',
|
||||
top: `${e.clientY}px`,
|
||||
left: `${e.clientX}px`,
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
padding: '5px',
|
||||
borderRadius: '5px',
|
||||
zIndex: 999,
|
||||
})
|
||||
const load_img = document.createElement('button')
|
||||
load_img.textContent = 'Load'
|
||||
load_img.onclick = () => {
|
||||
app.handleFile(img.src)
|
||||
}
|
||||
image_menu = document.createElement('div')
|
||||
Object.assign(image_menu.style, {
|
||||
position: 'absolute',
|
||||
top: `${e.clientY}px`,
|
||||
left: `${e.clientX}px`,
|
||||
background: '#333',
|
||||
color: '#fff',
|
||||
padding: '5px',
|
||||
borderRadius: '5px',
|
||||
zIndex: 999,
|
||||
})
|
||||
const load_img = document.createElement('button')
|
||||
load_img.textContent = 'Load'
|
||||
load_img.onclick = () => {
|
||||
app.handleFile(img.src)
|
||||
}
|
||||
|
||||
image_menu.appendChild(load_img)
|
||||
document.body.appendChild(image_menu)
|
||||
})
|
||||
image_menu.appendChild(load_img)
|
||||
document.body.appendChild(image_menu)
|
||||
})
|
||||
|
||||
but.append(img)
|
||||
imageListContainer.prepend(but)
|
||||
}
|
||||
but.append(img)
|
||||
imageListContainer.prepend(but)
|
||||
}
|
||||
|
||||
loadButton.onclick = async () => {
|
||||
const all_history = await api.getHistory()
|
||||
for (const history of all_history.History) {
|
||||
if (history.outputs) {
|
||||
for (const key of Object.keys(history.outputs)) {
|
||||
console.debug(key)
|
||||
if (history.outputs[key].images) {
|
||||
for (const im of history.outputs[key].images) {
|
||||
console.debug(im)
|
||||
createImageBtn(im)
|
||||
}
|
||||
}
|
||||
}
|
||||
// for (const src of outputs.outputs.images) {
|
||||
// console.debug(src)
|
||||
// makeImage(`${src.subfolder}/${src.filename}`)
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
loadButton.onclick = async () => {
|
||||
const all_history = await api.getHistory()
|
||||
for (const history of all_history.History) {
|
||||
if (history.outputs) {
|
||||
for (const key of Object.keys(history.outputs)) {
|
||||
console.debug(key)
|
||||
if (history.outputs[key].images) {
|
||||
for (const im of history.outputs[key].images) {
|
||||
console.debug(im)
|
||||
createImageBtn(im)
|
||||
}
|
||||
}
|
||||
}
|
||||
// for (const src of outputs.outputs.images) {
|
||||
// console.debug(src)
|
||||
// makeImage(`${src.subfolder}/${src.filename}`)
|
||||
// }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
///////-------
|
||||
///////-------
|
||||
|
||||
// const all_history = await api.getHistory()
|
||||
// for (const history of all_history.History) {
|
||||
// if (history.outputs) {
|
||||
// for (const key of Object.keys(history.outputs)) {
|
||||
// for (const im of history.outputs[key].images) {
|
||||
// makeImage(im)
|
||||
// }
|
||||
// }
|
||||
// // for (const src of outputs.outputs.images) {
|
||||
// // console.debug(src)
|
||||
// // makeImage(`${src.subfolder}/${src.filename}`)
|
||||
// // }
|
||||
// }
|
||||
// }
|
||||
// const all_history = await api.getHistory()
|
||||
// for (const history of all_history.History) {
|
||||
// if (history.outputs) {
|
||||
// for (const key of Object.keys(history.outputs)) {
|
||||
// for (const im of history.outputs[key].images) {
|
||||
// makeImage(im)
|
||||
// }
|
||||
// }
|
||||
// // for (const src of outputs.outputs.images) {
|
||||
// // console.debug(src)
|
||||
// // makeImage(`${src.subfolder}/${src.filename}`)
|
||||
// // }
|
||||
// }
|
||||
// }
|
||||
|
||||
//- Hook into the API
|
||||
api.addEventListener('executed', ({ detail }) => {
|
||||
if (detail?.output?.images) {
|
||||
for (const src of detail.output.images) {
|
||||
console.debug(`Adding ${src} to image feed`)
|
||||
createImageBtn(src)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
//- Hook into the API
|
||||
api.addEventListener('executed', ({ detail }) => {
|
||||
if (detail?.output?.images) {
|
||||
for (const src of detail.output.images) {
|
||||
console.debug(`Adding ${src} to image feed`)
|
||||
createImageBtn(src)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,415 +0,0 @@
|
||||
/// <reference path="../types/typedefs.js" />
|
||||
|
||||
import { app } from '../../scripts/app.js'
|
||||
import { api } from '../../scripts/api.js'
|
||||
|
||||
import * as shared from './comfy_shared.js'
|
||||
|
||||
import {
|
||||
// defineCSSClass,
|
||||
ensureMTBStyles,
|
||||
makeElement,
|
||||
makeSelect,
|
||||
makeSlider,
|
||||
renderSidebar,
|
||||
} from './mtb_ui.js'
|
||||
|
||||
const offset = 0
|
||||
let currentWidth = 200
|
||||
let currentMode = 'input'
|
||||
let subfolder = ''
|
||||
let currentSort = 'None'
|
||||
|
||||
const IMAGE_NODES = ['LoadImage', 'VHS_LoadImagePath']
|
||||
const VIDEO_NODES = ['VHS_LoadVideo']
|
||||
const PROCESSED_PROMPT_IDS = new Set()
|
||||
|
||||
const updateImage = (node, image) => {
|
||||
if (IMAGE_NODES.includes(node.type)) {
|
||||
const w = node.widgets?.find((w) => w.name === 'image')
|
||||
if (w) {
|
||||
w.value = image
|
||||
w.callback()
|
||||
}
|
||||
} else if (VIDEO_NODES.includes(node.type)) {
|
||||
const w = node.widgets?.find((w) => w.name === 'video')
|
||||
if (w) {
|
||||
node.updateParameters({ filename: image }, true)
|
||||
}
|
||||
} else {
|
||||
console.warn('No method to update', node.type)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a result item to a request url.
|
||||
* @param {ResultItem} resultItem
|
||||
* @returns {string} - The request URL.
|
||||
*/
|
||||
const resultItemToQuery = (resultItem) =>
|
||||
[
|
||||
`/mtb/view?filename=${resultItem.filename}`,
|
||||
`width=512`,
|
||||
`type=${resultItem.type}`,
|
||||
`subfolder=${resultItem.subfolder}`,
|
||||
`preview=`,
|
||||
].join('&')
|
||||
|
||||
/**
|
||||
* Retrieves the unique prompt ID from a history task item.
|
||||
* @param {HistoryTaskItem} historyTaskItem
|
||||
* @returns {string} - The prompt ID.
|
||||
*/
|
||||
const getPromptId = (historyTaskItem) => `${historyTaskItem.prompt[1]}`
|
||||
|
||||
/**
|
||||
* Process and return any new/unseen outputs from the most recent history item.
|
||||
* @param {HistoryTaskItem} mostRecentTask - The most recent history task item.
|
||||
* @returns {Object<string, string>} - A map of task outputs URLs.
|
||||
*/
|
||||
const getNewOutputUrls = (mostRecentTask) => {
|
||||
if (!mostRecentTask) return
|
||||
|
||||
const promptId = getPromptId(mostRecentTask)
|
||||
if (PROCESSED_PROMPT_IDS.has(promptId)) return
|
||||
|
||||
const urls = {}
|
||||
for (const nodeOutputs of Object.values(mostRecentTask.outputs)) {
|
||||
const { images, audio, animated } = nodeOutputs
|
||||
if (images) {
|
||||
const imageOutputs = Object.values(nodeOutputs.images)
|
||||
imageOutputs.forEach(
|
||||
(resultItem) =>
|
||||
(urls[resultItem.filename] = resultItemToQuery(resultItem))
|
||||
)
|
||||
}
|
||||
// Can process `animated` and `audio` outputs here.
|
||||
}
|
||||
|
||||
const foundNewOutputs = Object.keys(urls).length > 0
|
||||
if (!foundNewOutputs) return null
|
||||
|
||||
PROCESSED_PROMPT_IDS.add(promptId)
|
||||
return urls
|
||||
}
|
||||
|
||||
/** Fetch history and update the grid with any new ouput images. */
|
||||
const updateOutputsGrid = async () => {
|
||||
try {
|
||||
const history = await api.getHistory(/** maxSize: */ 1)
|
||||
const mostRcentTask = history.History[0]
|
||||
const newUrls = getNewOutputUrls(mostRcentTask)
|
||||
if (newUrls) {
|
||||
const imgGrid = document.querySelector('.mtb_img_grid')
|
||||
getImgsFromUrls(newUrls, imgGrid, { prepend: true })
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching history:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const getImgsFromUrls = (urls, target, options = { prepend: false }) => {
|
||||
const imgs = []
|
||||
if (urls === undefined) {
|
||||
return imgs
|
||||
}
|
||||
const elem = currentMode === 'video' ? 'video' : 'img'
|
||||
|
||||
for (const [key, url] of Object.entries(urls)) {
|
||||
const a = makeElement(elem)
|
||||
a.src = url
|
||||
a.width = currentWidth
|
||||
if (currentMode === 'input') {
|
||||
a.onclick = (_e) => {
|
||||
if (subfolder !== '') {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Subfolder not supported',
|
||||
detail: "The LoadImage node doesn't support subfolders",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
const selected = app.canvas.selected_nodes
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail:
|
||||
'For now the only action when clicking images in the sidebar is to set the image on all selected LoadImage nodes.',
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
}
|
||||
} else if (currentMode === 'output') {
|
||||
a.onclick = (_e) => {
|
||||
// window.MTB?.notify?.("Output import isn't supported yet...", 5000)
|
||||
if (subfolder !== '') {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Subfolder not supported',
|
||||
detail: "The LoadImage node doesn't support subfolders",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'Outputs not supported',
|
||||
detail:
|
||||
'For now only inputs can be clicked to load the image on the active LoadImage node.',
|
||||
life: 5000,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
a.autoplay = true
|
||||
|
||||
a.muted = true
|
||||
a.loop = true
|
||||
a.onclick = (_e) => {
|
||||
const selected = app.canvas.selected_nodes
|
||||
if (selected && Object.keys(selected).length === 0) {
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'warn',
|
||||
summary: 'No node selected!',
|
||||
detail:
|
||||
"For now the only action when clicking videos in the sidebar is to set the video on all selected 'Load Video (Upload)' nodes.",
|
||||
life: 5000,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
for (const [_id, node] of Object.entries(app.canvas.selected_nodes)) {
|
||||
updateImage(node, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
imgs.push(a)
|
||||
}
|
||||
if (target !== undefined) {
|
||||
if (options.prepend) target.prepend(...imgs)
|
||||
else target.append(...imgs)
|
||||
}
|
||||
return imgs
|
||||
}
|
||||
|
||||
const getModes = async () => {
|
||||
const inputs = await shared.runAction('getUserImageFolders')
|
||||
return inputs
|
||||
}
|
||||
const getUrls = async (subfolder) => {
|
||||
const count = (await api.getSetting('mtb.io-sidebar.count')) || 1000
|
||||
console.log('Sidebar count', count)
|
||||
if (currentMode === 'video') {
|
||||
const output = await shared.runAction(
|
||||
'getUserVideos',
|
||||
256,
|
||||
count,
|
||||
offset,
|
||||
currentSort,
|
||||
)
|
||||
return output || {}
|
||||
}
|
||||
const output = await shared.runAction(
|
||||
'getUserImages',
|
||||
currentMode,
|
||||
count,
|
||||
offset,
|
||||
currentSort,
|
||||
false,
|
||||
subfolder,
|
||||
)
|
||||
return output || {}
|
||||
}
|
||||
|
||||
//NOTE: do not load if using the old ui
|
||||
if (window?.__COMFYUI_FRONTEND_VERSION__) {
|
||||
// NOTE: removed this for now since I'm not actually exposing anything a client
|
||||
// cannot already access from "/view"...
|
||||
// let exposed = false
|
||||
|
||||
const sidebar_extension = {
|
||||
name: 'mtb.io-sidebar',
|
||||
// init: async () => {
|
||||
// try {
|
||||
// const res = await api.fetchApi('/mtb/server-info')
|
||||
// const msg = await res.json()
|
||||
// exposed = msg.exposed
|
||||
// } catch (e) {
|
||||
// console.error('Error:', e)
|
||||
// }
|
||||
// },
|
||||
init: () => {
|
||||
let handle
|
||||
const version = window?.__COMFYUI_FRONTEND_VERSION__
|
||||
console.log(`%c ${version}`, 'background: orange; color: white;')
|
||||
|
||||
ensureMTBStyles()
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: 'mtb.io-sidebar.count',
|
||||
category: ['mtb', 'Input & Output Sidebar', 'count'],
|
||||
|
||||
name: 'Number of images to fetch',
|
||||
type: 'number',
|
||||
defaultValue: 1000,
|
||||
|
||||
tooltip:
|
||||
"This setting affects the input/output sidebar to determine how many images to fetch per pagination (pagination is not yet supported so for now it's the static total)",
|
||||
attrs: {
|
||||
style: {
|
||||
// fontFamily: 'monospace',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
app.ui.settings.addSetting({
|
||||
id: 'mtb.io-sidebar.img-size',
|
||||
category: ['mtb', 'Input & Output Sidebar', 'img-size'],
|
||||
|
||||
name: 'Resolution of the images',
|
||||
type: 'number',
|
||||
defaultValue: 512,
|
||||
|
||||
tooltip: "It's recommended to keep it at 512px",
|
||||
attrs: {
|
||||
style: {
|
||||
// fontFamily: 'monospace',
|
||||
},
|
||||
},
|
||||
})
|
||||
app.ui.settings.addSetting({
|
||||
id: 'mtb.io-sidebar.sort',
|
||||
category: ['mtb', 'Input & Output Sidebar', 'sort'],
|
||||
name: 'Default sort mode',
|
||||
type: 'combo',
|
||||
|
||||
onChange: (v) => {
|
||||
// alert(`Sort is now ${v}`)
|
||||
currentSort = v
|
||||
},
|
||||
|
||||
defaultValue: 'Modified',
|
||||
// tooltip: "It's recommended to keep it at 512px",
|
||||
options: [
|
||||
'None',
|
||||
'Modified',
|
||||
'Modified-Reverse',
|
||||
'Name',
|
||||
'Name-Reverse',
|
||||
],
|
||||
})
|
||||
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: 'mtb-inputs-outputs',
|
||||
icon: 'pi pi-images',
|
||||
title: 'Input & Outputs',
|
||||
tooltip: 'MTB: Browse inputs and outputs directories.',
|
||||
type: 'custom',
|
||||
|
||||
// this is run everytime the tab's diplay is toggled on.
|
||||
render: async (el) => {
|
||||
if (handle) {
|
||||
handle.unregister()
|
||||
handle = undefined
|
||||
}
|
||||
|
||||
if (el.parentNode) {
|
||||
el.parentNode.style.overflowY = 'clip'
|
||||
}
|
||||
|
||||
const allModes = await getModes()
|
||||
const input_modes = allModes.input.map((m) => `input - ${m}`)
|
||||
const output_modes = allModes.output.map((m) => `output - ${m}`)
|
||||
const urls = await getUrls()
|
||||
let imgs = {}
|
||||
|
||||
const cont = makeElement('div.mtb_sidebar')
|
||||
|
||||
const imgGrid = makeElement('div.mtb_img_grid')
|
||||
const selector = makeSelect(
|
||||
['input', 'output', 'video', ...output_modes, ...input_modes],
|
||||
currentMode,
|
||||
)
|
||||
|
||||
selector.addEventListener('change', async (e) => {
|
||||
let newMode = e.target.value
|
||||
let changed = false
|
||||
let newSub = ''
|
||||
if (newMode !== 'input' && newMode !== 'output') {
|
||||
if (newMode.startsWith('input - ')) {
|
||||
newSub = newMode.replace('input - ', '')
|
||||
newMode = 'input'
|
||||
} else if (newMode.startsWith('output - ')) {
|
||||
newSub = newMode.replace('output - ', '')
|
||||
newMode = 'output'
|
||||
}
|
||||
}
|
||||
changed = newMode !== currentMode || newSub !== subfolder
|
||||
currentMode = newMode
|
||||
subfolder = newSub
|
||||
if (changed) {
|
||||
imgGrid.innerHTML = ''
|
||||
const urls = await getUrls(subfolder)
|
||||
if (urls) {
|
||||
imgs = getImgsFromUrls(urls, imgGrid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const imgTools = makeElement('div.mtb_tools')
|
||||
const orderSelect = makeSelect(
|
||||
['None', 'Modified', 'Modified-Reverse', 'Name', 'Name-Reverse'],
|
||||
currentSort,
|
||||
)
|
||||
|
||||
orderSelect.addEventListener('change', async (e) => {
|
||||
const newSort = e.target.value
|
||||
const changed = newSort !== currentSort
|
||||
currentSort = newSort
|
||||
if (changed) {
|
||||
imgGrid.innerHTML = ''
|
||||
const urls = await getUrls(subfolder)
|
||||
if (urls) {
|
||||
imgs = getImgsFromUrls(urls, imgGrid)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const sizeSlider = makeSlider(64, 1024, currentWidth, 1)
|
||||
imgTools.appendChild(orderSelect)
|
||||
imgTools.appendChild(sizeSlider)
|
||||
|
||||
imgs = getImgsFromUrls(urls, imgGrid)
|
||||
|
||||
sizeSlider.addEventListener('input', (e) => {
|
||||
currentWidth = e.target.value
|
||||
for (const img of imgs) {
|
||||
img.style.width = `${e.target.value}px`
|
||||
}
|
||||
})
|
||||
handle = renderSidebar(el, cont, [selector, imgGrid, imgTools])
|
||||
app.api.addEventListener('status', async () => {
|
||||
if (currentMode !== 'output') return
|
||||
updateOutputsGrid()
|
||||
})
|
||||
},
|
||||
destroy: () => {
|
||||
if (handle) {
|
||||
handle.unregister()
|
||||
handle = undefined
|
||||
app.api.removeEventListener('status')
|
||||
}
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
app.registerExtension(sidebar_extension)
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
// NOTE: this will be the LT part of mtb API system
|
||||
// I need to properly publish the source and fix a few things before
|
||||
|
||||
// import { app } from '../../scripts/app.js'
|
||||
// // import { api } from '../../scripts/api.js'
|
||||
//
|
||||
// import * as shared from './comfy_shared.js'
|
||||
// import { createOutliner } from './dist/mtb_inspector.js'
|
||||
//
|
||||
// if (window?.__COMFYUI_FRONTEND_VERSION__) {
|
||||
// const version = window?.__COMFYUI_FRONTEND_VERSION__
|
||||
// console.log(`%c ${version}`, 'background: orange; color: white;')
|
||||
//
|
||||
// const panel = app.extensionManager.registerSidebarTab({
|
||||
// id: 'mtb-nodes',
|
||||
// icon: 'pi pi-bolt',
|
||||
// title: 'MTB',
|
||||
// tooltip: 'MTB: API outliner',
|
||||
// type: 'custom',
|
||||
// // this is run everytime the tab's diplay is toggled on.
|
||||
// render: (el) => {
|
||||
// const outliner = createOutliner(el)
|
||||
// const inputs = shared.getAPIInputs()
|
||||
// console.log('INPUTS', inputs)
|
||||
// outliner.$$set({ inputs })
|
||||
// },
|
||||
// })
|
||||
// }
|
||||
-516
@@ -1,516 +0,0 @@
|
||||
/**
|
||||
* Adds a named stylesheet to the document with an optional ability to replace an existing one.
|
||||
*
|
||||
* @param {string} name - The unique name (ID) of the stylesheet.
|
||||
* @param {string} css - The CSS rules as a string.
|
||||
* @param {boolean} [force=false] - Whether to replace the existing stylesheet if it exists.
|
||||
* @returns {void}
|
||||
*/
|
||||
export function addNamedStyleSheet(name, css, force = false) {
|
||||
const existingStyleSheet = document.getElementById(name)
|
||||
|
||||
if (existingStyleSheet && !force) {
|
||||
console.debug(
|
||||
`Stylesheet with name "${name}" already exists. Skipping addition.`,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (existingStyleSheet && force) {
|
||||
console.debug(`Stylesheet with name "${name}" exists. Replacing...`)
|
||||
existingStyleSheet.remove()
|
||||
}
|
||||
|
||||
const styleElement = document.createElement('style')
|
||||
styleElement.id = name
|
||||
styleElement.type = 'text/css'
|
||||
|
||||
styleElement.appendChild(document.createTextNode(css))
|
||||
document.head.appendChild(styleElement)
|
||||
|
||||
console.debug(`Stylesheet with name "${name}" added.`)
|
||||
}
|
||||
|
||||
export const ensureMTBStyles = () => {
|
||||
const S = {
|
||||
fg: 'var(--fg-color)',
|
||||
bgi: 'var(--comfy-input-bg)',
|
||||
bgm: 'var(--comfy-menu-bg)',
|
||||
border: 'var(--comfy-border)',
|
||||
borderHover: 'var(--comfy-border-hover)',
|
||||
box: 'var(--comfy-box)',
|
||||
accent: 'var(--p-button-text-primary-color)',
|
||||
}
|
||||
const common = `
|
||||
.mtb_sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: ${S.bgm};
|
||||
}
|
||||
.mtb_img_grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
overflow: scroll;
|
||||
gap: 1em;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
.mtb_tools {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
}
|
||||
`
|
||||
const inputs = `
|
||||
/* SELECT */
|
||||
.mtb_select {
|
||||
appearance: none;
|
||||
display: grid;
|
||||
grid-template-areas: "select";
|
||||
padding: 10px;
|
||||
background-color: ${S.bgi};
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
font-size: 14px;
|
||||
color: ${S.fg};
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@supports (-moz-appearance:none) {
|
||||
.mtb_select{
|
||||
grid-area: select;
|
||||
background: ${S.bgi} url('data:image/gif;base64,R0lGODlhBgAGAKEDAFVVVX9/f9TU1CgmNyH5BAEKAAMALAAAAAAGAAYAAAIODA4hCDKWxlhNvmCnGwUAOw==') right center no-repeat !important;
|
||||
background-position: calc(100% - 5px) center !important;
|
||||
-moz-appearance:none !important;
|
||||
}
|
||||
|
||||
/* styling the dropdown arrow for browsers that support it */
|
||||
.mtb_select:after {
|
||||
content: "";
|
||||
width: 0.8em;
|
||||
height: 0.5em;
|
||||
background-color: ${S.fg};
|
||||
clip-path: polygon(100% 0%, 0 0%, 50% 100%);
|
||||
}
|
||||
|
||||
.mtb_select:focus {
|
||||
outline: none;
|
||||
border-color: #0056b3;
|
||||
}
|
||||
|
||||
.mtb_select > option {
|
||||
padding: 10px;
|
||||
background-color: ${S.bgi};
|
||||
border:none;
|
||||
color: ${S.fg};
|
||||
}
|
||||
|
||||
.mtb_select > option:hover {
|
||||
background-color: red;
|
||||
color: ${S.fg};
|
||||
}
|
||||
|
||||
/* SLIDER */
|
||||
.mtb_slider[type="range"] {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: ${S.bgm};
|
||||
border-radius: 5px;
|
||||
outline: none;
|
||||
opacity: 0.7;
|
||||
transition: opacity .2s;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
/* slider track */
|
||||
.mtb_slider[type="range"]::-webkit-slider-runnable-track,
|
||||
.mtb_slider[type="range"]::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 10px;
|
||||
background: ${S.bgi};
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
|
||||
/* progress */
|
||||
.mtb_slider[type="range"]::-moz-range-progress {
|
||||
background-color: ${S.accent};
|
||||
height:10px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
/* slider thumb (the handle) */
|
||||
.mtb_slider[type="range"]::-webkit-slider-thumb,
|
||||
.mtb_slider[type="range"]::-moz-range-thumb
|
||||
{
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
border-radius: 50%;
|
||||
background: ${S.fg};
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
filter: drop-shadow(1px 1px 4px black);
|
||||
}
|
||||
|
||||
.mtb_slider[type="range"]:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.mtb_slider[type=range]:-moz-focusring{
|
||||
outline: 1px solid red;
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
.mtb_slider[type="range"]:hover::-webkit-slider-thumb,
|
||||
.mtb_slider[type="range"]:active::-webkit-slider-thumb {
|
||||
background-color: ${S.accent};
|
||||
}
|
||||
`
|
||||
addNamedStyleSheet(
|
||||
'mtb_ui',
|
||||
`
|
||||
${common}
|
||||
${inputs}
|
||||
`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap an element with a div
|
||||
*
|
||||
* @param {Object} [style] - CSS styles to apply to the element.
|
||||
* @returns {HTMLElement} - The created DOM element.
|
||||
*/
|
||||
export const wrapElement = (element, style = {}) => {
|
||||
const container = makeElement('div', style)
|
||||
container.appendChild(element)
|
||||
return container
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a DOM element with optional styles, class, and id.
|
||||
*
|
||||
* @param {string} kind - The tag name of the element. Supports class and id syntax (e.g. 'div.class#id').
|
||||
* @param {Object} [style] - CSS styles to apply to the element.
|
||||
* @returns {HTMLElement} - The created DOM element.
|
||||
*/
|
||||
export const makeElement = (kind, style) => {
|
||||
let [real_kind, className] = kind.split('.')
|
||||
let id
|
||||
|
||||
if (className?.includes('#')) {
|
||||
;[className, id] = className.split('#')
|
||||
}
|
||||
|
||||
const el = document.createElement(real_kind)
|
||||
|
||||
if (style) {
|
||||
Object.assign(el.style, style)
|
||||
}
|
||||
|
||||
if (className) {
|
||||
el.classList.add(...className.split(' ')) // Support multiple classes
|
||||
}
|
||||
|
||||
if (id) {
|
||||
el.id = id
|
||||
}
|
||||
|
||||
return el
|
||||
}
|
||||
/**
|
||||
* Clears all child elements of the given parent element.
|
||||
*
|
||||
* @param {HTMLElement} el - The parent element whose children should be removed.
|
||||
*/
|
||||
export const clearElement = (el) => {
|
||||
while (el.firstChild) {
|
||||
el.removeChild(el.firstChild)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Creates a labeled element (input, select, etc.).
|
||||
*
|
||||
* @param {HTMLElement} el - The element to label.
|
||||
* @param {string} labelText - The label text.
|
||||
* @returns {HTMLDivElement} - A div containing the label and the element.
|
||||
*/
|
||||
export const makeLabeledElement = (el, labelText) => {
|
||||
const wrapper = makeElement('div.mtb_labeled_element', {
|
||||
marginBottom: '1em',
|
||||
})
|
||||
const label = makeElement('label', {
|
||||
display: 'block',
|
||||
marginBottom: '0.5em',
|
||||
})
|
||||
label.textContent = labelText
|
||||
wrapper.appendChild(label)
|
||||
wrapper.appendChild(el)
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a camelCase CSS property to kebab-case.
|
||||
*
|
||||
* @param {string} prop - The camelCase CSS property.
|
||||
* @returns {string} - The kebab-case CSS property.
|
||||
*/
|
||||
const camelToKebab = (prop) =>
|
||||
prop.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`)
|
||||
|
||||
/**
|
||||
* Parses the style string into an object of CSS property-value pairs.
|
||||
*
|
||||
* @param {string} styleString - The CSS rule text (e.g., "color: red; background-color: blue;").
|
||||
* @returns {Object} - An object with camelCase CSS properties.
|
||||
*/
|
||||
const parseStyleString = (styleString) => {
|
||||
const styleObj = {}
|
||||
for (const rule of styleString.split(';')) {
|
||||
const [property, value] = rule.split(':').map((item) => item.trim())
|
||||
if (property && value) {
|
||||
const camelProp = property.replace(/-([a-z])/g, (g) => g[1].toUpperCase())
|
||||
styleObj[camelProp] = value
|
||||
}
|
||||
}
|
||||
return styleObj
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a new CSS class with the provided styles, or skips if the class already exists.
|
||||
*
|
||||
* @param {string} className - The name of the CSS class to define.
|
||||
* @param {Object} classStyles - An object containing camelCase CSS property-value pairs.
|
||||
*/
|
||||
export function defineCSSClass(className, classStyles) {
|
||||
const styleSheets = document.styleSheets
|
||||
let classExists = false
|
||||
let existingStyleString = ''
|
||||
const classExistsInStyleSheet = (styleSheet) => {
|
||||
const rules = styleSheet.rules || styleSheet.cssRules
|
||||
for (const rule of rules) {
|
||||
if (rule.selectorText === `.${className}`) {
|
||||
classExists = true
|
||||
existingStyleString = rule.style.cssText // Capture existing styles
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
for (const styleSheet of styleSheets) {
|
||||
if (classExistsInStyleSheet(styleSheet)) {
|
||||
console.debug(`Class ${className} already exists, merging styles...`)
|
||||
break
|
||||
}
|
||||
}
|
||||
const existingStyles = classExists
|
||||
? parseStyleString(existingStyleString)
|
||||
: {}
|
||||
const mergedStyles = { ...existingStyles, ...classStyles }
|
||||
|
||||
const stylesString = Object.entries(mergedStyles)
|
||||
.map(([key, value]) => `${camelToKebab(key)}: ${value};`)
|
||||
.join(' ')
|
||||
|
||||
if (!classExists) {
|
||||
console.debug(`Defining new class ${className}...`)
|
||||
if (styleSheets[0].insertRule) {
|
||||
styleSheets[0].insertRule(`.${className} { ${stylesString} }`, 0)
|
||||
} else if (styleSheets[0].addRule) {
|
||||
styleSheets[0].addRule(`.${className}`, stylesString, 0)
|
||||
}
|
||||
} else {
|
||||
console.debug(`Updating existing class ${className} with merged styles...`)
|
||||
for (const styleSheet of styleSheets) {
|
||||
const rules = styleSheet.rules || styleSheet.cssRules
|
||||
for (const rule of rules) {
|
||||
if (rule.selectorText === `.${className}`) {
|
||||
rule.style.cssText = stylesString // Update the existing rule
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.debug(
|
||||
`Class ${className} has been defined/updated with styles:`,
|
||||
mergedStyles,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a sidebar and ensures it resizes correctly when the window is resized.
|
||||
*
|
||||
* @param {HTMLElement} el - The element where the sidebar is rendered.
|
||||
* @param {HTMLElement} cont - The content container of the sidebar.
|
||||
* @param {HTMLElement[]} elems - Array of elements to append to the sidebar.
|
||||
* @returns {Object} - A handle with a method to unregister the resize event.
|
||||
*/
|
||||
export const renderSidebar = (el, cont, elems) => {
|
||||
el.appendChild(cont)
|
||||
|
||||
if (!el.parentNode) {
|
||||
return
|
||||
}
|
||||
el.parentNode.style.overflowY = 'clip'
|
||||
cont.style.height = `${el.parentNode.offsetHeight}px`
|
||||
|
||||
const resizeHandler = () => {
|
||||
cont.style.height = `${el.parentNode.offsetHeight}px`
|
||||
}
|
||||
window.addEventListener('resize', resizeHandler)
|
||||
|
||||
for (const elem of elems) {
|
||||
cont.appendChild(elem)
|
||||
}
|
||||
|
||||
return {
|
||||
unregister: () => {
|
||||
window.removeEventListener('resize', resizeHandler)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a <select> dropdown with given options.
|
||||
*
|
||||
* @param {string[]} options - The options for the select element.
|
||||
* @param {string} [current] - The currently selected option (optional).
|
||||
* @returns {HTMLSelectElement} - The created <select> element.
|
||||
*/
|
||||
export const makeSelect = (options, current = undefined) => {
|
||||
const selector = makeElement('select.mtb_select', {
|
||||
width: 'auto',
|
||||
margin: '1em',
|
||||
})
|
||||
|
||||
for (const option of options) {
|
||||
const opt = makeElement('option')
|
||||
opt.value = option
|
||||
opt.innerHTML = option
|
||||
selector.appendChild(opt)
|
||||
}
|
||||
|
||||
if (current !== undefined) {
|
||||
if (options.includes(current)) {
|
||||
selector.value = current
|
||||
} else {
|
||||
console.error(
|
||||
`You tried to select an option that doesn't exist (${current}). Options: ${options}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return selector
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates an <input type="range"> slider element with given parameters.
|
||||
*
|
||||
* @param {number} min - Minimum value of the slider.
|
||||
* @param {number} max - Maximum value of the slider.
|
||||
* @param {number} [value] - Initial value of the slider.
|
||||
* @param {number} [step] - Step value for the slider.
|
||||
* @returns {HTMLInputElement} - The created slider element.
|
||||
*/
|
||||
export const makeSlider = (min, max, value = undefined, step = undefined) => {
|
||||
const slider = makeElement('input.mtb_slider', {
|
||||
width: '100%',
|
||||
})
|
||||
|
||||
slider.type = 'range'
|
||||
slider.min = min || 0
|
||||
slider.max = max || 100
|
||||
slider.value = value || slider.min
|
||||
slider.step = step || 1
|
||||
|
||||
return slider
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a button element.
|
||||
*
|
||||
* @param {string} label - The label for the button.
|
||||
* @param {Object} [style] - Optional styles to apply to the button.
|
||||
* @param {Function} [onClick] - Optional click handler.
|
||||
* @returns {HTMLButtonElement} - The created button element.
|
||||
*/
|
||||
export const makeButton = (label, style = {}, onClick = undefined) => {
|
||||
const button = makeElement('button.mtb_button', style)
|
||||
button.textContent = label
|
||||
|
||||
if (onClick) {
|
||||
button.addEventListener('click', onClick)
|
||||
}
|
||||
|
||||
return button
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a resizable splitter between two elements.
|
||||
*
|
||||
* @param {HTMLElement} el1 - The first element.
|
||||
* @param {HTMLElement} el2 - The second element.
|
||||
* @param {'vertical' | 'horizontal'} direction - Splitter direction (vertical or horizontal).
|
||||
* @param {'absolute' | 'normal'} mode - Splitter mode: 'absolute' for free resizing, 'normal' for layout-based resizing.
|
||||
* @returns {HTMLDivElement} - The container with resizable splitter.
|
||||
*/
|
||||
export const makeSplitter = (
|
||||
el1,
|
||||
el2,
|
||||
direction = 'vertical',
|
||||
mode = 'normal',
|
||||
) => {
|
||||
const container = makeElement('div.mtb_splitter_container', {
|
||||
display: mode === 'absolute' ? 'block' : 'flex',
|
||||
flexDirection: direction === 'vertical' ? 'row' : 'column',
|
||||
position: mode === 'absolute' ? 'relative' : 'static',
|
||||
height: '100%',
|
||||
width: '100%',
|
||||
})
|
||||
|
||||
const handle = makeElement('div.mtb_splitter_handle', {
|
||||
backgroundColor: '#ccc',
|
||||
cursor: direction === 'vertical' ? 'col-resize' : 'row-resize',
|
||||
width: direction === 'vertical' ? '5px' : '100%',
|
||||
height: direction === 'horizontal' ? '5px' : '100%',
|
||||
})
|
||||
|
||||
let isResizing = false
|
||||
|
||||
handle.addEventListener('mousedown', () => {
|
||||
isResizing = true
|
||||
})
|
||||
|
||||
window.addEventListener('mouseup', () => {
|
||||
isResizing = false
|
||||
})
|
||||
|
||||
window.addEventListener('mousemove', (e) => {
|
||||
if (!isResizing) return
|
||||
if (direction === 'vertical') {
|
||||
const newWidth = e.clientX - container.offsetLeft
|
||||
el1.style.width = `${newWidth}px`
|
||||
el2.style.width = `${container.offsetWidth - newWidth}px`
|
||||
} else {
|
||||
const newHeight = e.clientY - container.offsetTop
|
||||
el1.style.height = `${newHeight}px`
|
||||
el2.style.height = `${container.offsetHeight - newHeight}px`
|
||||
}
|
||||
})
|
||||
|
||||
container.appendChild(el1)
|
||||
container.appendChild(handle)
|
||||
container.appendChild(el2)
|
||||
|
||||
return container
|
||||
}
|
||||
+1153
-1331
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -600,9 +600,9 @@ class NotePlus extends LiteGraph.LGraphNode {
|
||||
// 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)
|
||||
// }
|
||||
onRemoved() {
|
||||
infoLogger('Node removed', this.uuid)
|
||||
}
|
||||
getExtraMenuOptions() {
|
||||
const currentMode = this.edit_mode_widget.value
|
||||
const newMode = currentMode === 'html' ? 'markdown' : 'html'
|
||||
|
||||
+1
-1
Submodule wiki updated: fa7fec28a3...4db733ae92
Reference in New Issue
Block a user