Merge pull request #16 from space-nuko/widget-arrange

Widget overhaul
This commit is contained in:
space-nuko
2023-05-04 22:12:25 -04:00
committed by GitHub
55 changed files with 6560 additions and 2096 deletions

View File

@@ -20,7 +20,6 @@ This frontend isn't compatible with regular ComfyUI's workflow format since extr
## Requirements
- `pnpm`
- [Turborepo](https://turbo.build/repo/docs/installing)
- An installation of vanilla [ComfyUI](https://github.com/comfyanonymous/ComfyUI) for the backend
## Installation
@@ -32,6 +31,7 @@ git clone https://github.com/space-nuko/ComfyBox --recursive
```
2. `pnpm install`
3. `pnpm dev`
4. Start ComfyUI as usual with `python main.py --enable-cors-header`
5. Visit `http://localhost:3000` in your browser
4. `pnpm build:css`
5. `pnpm dev`
6. Start ComfyUI as usual with `python main.py --enable-cors-header`
7. Visit `http://localhost:3000` in your browser

View File

@@ -15,6 +15,7 @@
"build:css": "pollen -c gradio/js/theme/src/pollen.config.cjs && mv src/pollen.css node_modules/@gradio/theme/src"
},
"devDependencies": {
"@zerodevx/svelte-toast": "^0.9.3",
"eslint": "^8.37.0",
"eslint-config-prettier": "^8.8.0",
"eslint-plugin-svelte3": "^4.0.0",
@@ -42,6 +43,8 @@
"@gradio/utils": "workspace:*",
"@litegraph-ts/core": "workspace:*",
"@litegraph-ts/nodes-basic": "workspace:*",
"@litegraph-ts/nodes-events": "workspace:*",
"@litegraph-ts/nodes-math": "workspace:*",
"@litegraph-ts/tsconfig": "workspace:*",
"@sveltejs/vite-plugin-svelte": "^2.1.1",
"@tsconfig/svelte": "^4.0.1",

252
pnpm-lock.yaml generated
View File

@@ -37,6 +37,12 @@ importers:
'@litegraph-ts/nodes-basic':
specifier: workspace:*
version: link:litegraph/packages/nodes-basic
'@litegraph-ts/nodes-events':
specifier: workspace:*
version: link:litegraph/packages/nodes-events
'@litegraph-ts/nodes-math':
specifier: workspace:*
version: link:litegraph/packages/nodes-math
'@litegraph-ts/tsconfig':
specifier: workspace:*
version: link:litegraph/packages/tsconfig
@@ -63,7 +69,7 @@ importers:
version: 1.2.1
svelte-preprocess:
specifier: ^5.0.3
version: 5.0.3(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3)
version: 5.0.3(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3)
svelte-select:
specifier: ^5.5.3
version: 5.5.3
@@ -72,7 +78,7 @@ importers:
version: 0.7.13(svelte@3.58.0)
tailwindcss:
specifier: ^3.3.1
version: 3.3.1(postcss@8.4.21)
version: 3.3.1
typed-emitter:
specifier: github:andywer/typed-emitter
version: github.com/andywer/typed-emitter/9a139b6fa0ec6b0db6141b5b756b784e4f7ef4e4
@@ -80,6 +86,9 @@ importers:
specifier: ^1.0.5
version: 1.0.5(vite@4.3.1)
devDependencies:
'@zerodevx/svelte-toast':
specifier: ^0.9.3
version: 0.9.3(svelte@3.58.0)
eslint:
specifier: ^8.37.0
version: 8.37.0
@@ -103,7 +112,7 @@ importers:
version: 3.58.0
svelte-check:
specifier: ^3.2.0
version: 3.2.0(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0)
version: 3.2.0(sass@1.61.0)(svelte@3.58.0)
svelte-dnd-action:
specifier: ^0.9.22
version: 0.9.22(svelte@3.58.0)
@@ -112,7 +121,7 @@ importers:
version: 5.0.3
vite:
specifier: ^4.3.1
version: 4.3.1(@types/node@18.16.0)(sass@1.61.0)
version: 4.3.1(sass@1.61.0)
vite-tsconfig-paths:
specifier: ^4.0.8
version: 4.0.8(typescript@5.0.3)(vite@4.3.1)
@@ -126,7 +135,7 @@ importers:
devDependencies:
vite:
specifier: ^2.9.9
version: 2.9.9(sass@1.61.0)
version: 2.9.9
gradio/js/accordion: {}
@@ -691,7 +700,7 @@ importers:
version: 1.0.0-next.91(@sveltejs/kit@1.15.2)
'@sveltejs/kit':
specifier: ^1.0.0-next.318
version: 1.15.2(svelte@3.58.0)(vite@4.3.1)
version: 1.15.2(svelte@3.58.0)
autoprefixer:
specifier: ^10.4.2
version: 10.4.2(postcss@8.4.21)
@@ -703,13 +712,13 @@ importers:
version: 3.1.1
svelte-check:
specifier: ^2.2.6
version: 2.2.6(postcss-load-config@3.1.1)(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0)
version: 2.2.6(postcss-load-config@3.1.1)(postcss@8.4.21)(svelte@3.58.0)
svelte-preprocess:
specifier: ^4.10.1
version: 4.10.1(postcss-load-config@3.1.1)(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0)(typescript@4.5.4)
version: 4.10.1(postcss-load-config@3.1.1)(postcss@8.4.21)(svelte@3.58.0)(typescript@4.5.4)
tailwindcss:
specifier: ^3.0.12
version: 3.3.1(postcss@8.4.21)
version: 3.3.1
tslib:
specifier: ^2.3.1
version: 2.3.1
@@ -743,7 +752,7 @@ importers:
version: 5.0.3
vite:
specifier: ^4.2.1
version: 4.2.1(sass@1.61.0)
version: 4.2.1
vite-plugin-checker:
specifier: ^0.5.6
version: 0.5.6(eslint@8.37.0)(typescript@5.0.3)(vite@4.2.1)
@@ -762,7 +771,39 @@ importers:
version: 5.0.3
vite:
specifier: ^4.2.1
version: 4.2.1(sass@1.61.0)
version: 4.2.1
litegraph/packages/nodes-events:
dependencies:
'@litegraph-ts/core':
specifier: workspace:*
version: link:../core
devDependencies:
'@litegraph-ts/tsconfig':
specifier: workspace:*
version: link:../tsconfig
typescript:
specifier: ^5.0.3
version: 5.0.3
vite:
specifier: ^4.2.1
version: 4.3.1
litegraph/packages/nodes-math:
dependencies:
'@litegraph-ts/core':
specifier: workspace:*
version: link:../core
devDependencies:
'@litegraph-ts/tsconfig':
specifier: workspace:*
version: link:../tsconfig
typescript:
specifier: ^5.0.3
version: 5.0.3
vite:
specifier: ^4.2.1
version: 4.3.1
litegraph/packages/tsconfig: {}
@@ -1990,11 +2031,11 @@ packages:
peerDependencies:
'@sveltejs/kit': ^1.0.0-next.587
dependencies:
'@sveltejs/kit': 1.15.2(svelte@3.58.0)(vite@4.3.1)
'@sveltejs/kit': 1.15.2(svelte@3.58.0)
import-meta-resolve: 2.2.2
dev: true
/@sveltejs/kit@1.15.2(svelte@3.58.0)(vite@4.3.1):
/@sveltejs/kit@1.15.2(svelte@3.58.0):
resolution: {integrity: sha512-rLNxZrjbrlPf8AWW8GAU4L/Vvu17e9v8EYl7pUip7x72lTft7RcxeP3z7tsrHpMSBBxC9o4XdKzFvz1vMZyXZw==}
engines: {node: ^16.14 || >=18}
hasBin: true
@@ -2003,7 +2044,7 @@ packages:
svelte: ^3.54.0
vite: ^4.0.0
dependencies:
'@sveltejs/vite-plugin-svelte': 2.1.1(svelte@3.58.0)(vite@4.3.1)
'@sveltejs/vite-plugin-svelte': 2.1.1(svelte@3.58.0)
'@types/cookie': 0.5.1
cookie: 0.5.0
devalue: 4.3.0
@@ -2017,7 +2058,24 @@ packages:
svelte: 3.58.0
tiny-glob: 0.2.9
undici: 5.20.0
vite: 4.3.1(@types/node@18.16.0)(sass@1.61.0)
transitivePeerDependencies:
- supports-color
dev: true
/@sveltejs/vite-plugin-svelte@2.1.1(svelte@3.58.0):
resolution: {integrity: sha512-7YeBDt4us0FiIMNsVXxyaP4Hwyn2/v9x3oqStkHU3ZdIc5O22pGwUwH33wUqYo+7Itdmo8zxJ45Qvfm3H7UUjQ==}
engines: {node: ^14.18.0 || >= 16}
peerDependencies:
svelte: ^3.54.0
vite: ^4.0.0
dependencies:
debug: 4.3.4
deepmerge: 4.3.1
kleur: 4.1.5
magic-string: 0.30.0
svelte: 3.58.0
svelte-hmr: 0.15.1(svelte@3.58.0)
vitefu: 0.2.4
transitivePeerDependencies:
- supports-color
dev: true
@@ -2035,10 +2093,11 @@ packages:
magic-string: 0.30.0
svelte: 3.58.0
svelte-hmr: 0.15.1(svelte@3.58.0)
vite: 4.3.1(@types/node@18.16.0)(sass@1.61.0)
vite: 4.3.1(sass@1.61.0)
vitefu: 0.2.4(vite@4.3.1)
transitivePeerDependencies:
- supports-color
dev: false
/@ts-morph/common@0.18.1:
resolution: {integrity: sha512-RVE+zSRICWRsfrkAw5qCAK+4ZH9kwEFv5h0+/YeHTLieWP7F4wWq4JsKFuNWG+fYh/KF+8rAtgdj5zb2mm+DVA==}
@@ -2178,6 +2237,7 @@ packages:
/@types/node@18.16.0:
resolution: {integrity: sha512-BsAaKhB+7X+H4GnSjGhJG9Qi8Tw+inU9nJDwmD5CgOmBLEI6ArdhikpLX7DjbjDRDTbqZzU2LSQNZg8WGPiSZQ==}
dev: true
/@types/node@8.10.66:
resolution: {integrity: sha512-tktOkFUA4kXx2hhhrB8bIFb5TbwzS4uOhKEmwiD+NoiL0qtP2OQ9mFldbgD4dV1djrlBYP6eBuQZiWjuHUpqFw==}
@@ -2281,6 +2341,14 @@ packages:
eslint-visitor-keys: 3.4.0
dev: true
/@zerodevx/svelte-toast@0.9.3(svelte@3.58.0):
resolution: {integrity: sha512-VPKWR4A9y01fyXRscu9HiTj7tV2hFrpRKZvGwMmaPXfHIXR1D9+NNsz0HXcQ7qZ0C5UaHS3n9uNtPtIcAXT7RQ==}
peerDependencies:
svelte: ^3.57.0
dependencies:
svelte: 3.58.0
dev: true
/acorn-jsx@5.3.2(acorn@8.8.2):
resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==}
peerDependencies:
@@ -4292,7 +4360,7 @@ packages:
exit: 0.1.2
graceful-fs: 4.2.11
import-local: 3.1.0
jest-config: 29.5.0(@types/node@18.16.0)
jest-config: 29.5.0
jest-util: 29.5.0
jest-validate: 29.5.0
prompts: 2.4.2
@@ -4303,6 +4371,44 @@ packages:
- ts-node
dev: true
/jest-config@29.5.0:
resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
peerDependencies:
'@types/node': '*'
ts-node: '>=9.0.0'
peerDependenciesMeta:
'@types/node':
optional: true
ts-node:
optional: true
dependencies:
'@babel/core': 7.21.4
'@jest/test-sequencer': 29.5.0
'@jest/types': 29.5.0
babel-jest: 29.5.0(@babel/core@7.21.4)
chalk: 4.1.2
ci-info: 3.8.0
deepmerge: 4.3.1
glob: 7.2.3
graceful-fs: 4.2.11
jest-circus: 29.5.0
jest-environment-node: 29.5.0
jest-get-type: 29.4.3
jest-regex-util: 29.4.3
jest-resolve: 29.5.0
jest-runner: 29.5.0
jest-util: 29.5.0
jest-validate: 29.5.0
micromatch: 4.0.5
parse-json: 5.2.0
pretty-format: 29.5.0
slash: 3.0.0
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
dev: true
/jest-config@29.5.0(@types/node@18.16.0):
resolution: {integrity: sha512-kvDUKBnNJPNBmFFOhDbm59iu1Fii1Q6SxyhXfvylq3UTHbg6o7j/g8k2dZyXWLvfdKB1vAPxNZnMgtKJcmu3kA==}
engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0}
@@ -5882,7 +5988,7 @@ packages:
resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==}
engines: {node: '>= 0.4'}
/svelte-check@2.2.6(postcss-load-config@3.1.1)(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0):
/svelte-check@2.2.6(postcss-load-config@3.1.1)(postcss@8.4.21)(svelte@3.58.0):
resolution: {integrity: sha512-oJux/afbmcZO+N+ADXB88h6XANLie8Y2rh2qBlhgfkpr2c3t/q/T0w2JWrHqagaDL8zeNwO8a8RVFBkrRox8gg==}
hasBin: true
peerDependencies:
@@ -5896,7 +6002,7 @@ packages:
sade: 1.8.1
source-map: 0.7.4
svelte: 3.58.0
svelte-preprocess: 4.10.1(postcss-load-config@3.1.1)(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3)
svelte-preprocess: 4.10.1(postcss-load-config@3.1.1)(postcss@8.4.21)(svelte@3.58.0)(typescript@5.0.3)
typescript: 5.0.3
transitivePeerDependencies:
- '@babel/core'
@@ -5911,7 +6017,7 @@ packages:
- sugarss
dev: true
/svelte-check@3.2.0(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0):
/svelte-check@3.2.0(sass@1.61.0)(svelte@3.58.0):
resolution: {integrity: sha512-6ZnscN8dHEN5Eq5LgIzjj07W9nc9myyBH+diXsUAuiY/3rt0l65/LCIQYlIuoFEjp2F1NhXqZiJwV9omPj9tMw==}
hasBin: true
peerDependencies:
@@ -5924,7 +6030,7 @@ packages:
picocolors: 1.0.0
sade: 1.8.1
svelte: 3.58.0
svelte-preprocess: 5.0.3(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3)
svelte-preprocess: 5.0.3(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3)
typescript: 5.0.3
transitivePeerDependencies:
- '@babel/core'
@@ -5976,7 +6082,7 @@ packages:
tiny-glob: 0.2.9
dev: false
/svelte-preprocess@4.10.1(postcss-load-config@3.1.1)(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0)(typescript@4.5.4):
/svelte-preprocess@4.10.1(postcss-load-config@3.1.1)(postcss@8.4.21)(svelte@3.58.0)(typescript@4.5.4):
resolution: {integrity: sha512-NSNloaylf+o9UeyUR2KvpdxrAyMdHl3U7rMnoP06/sG0iwJvlUM4TpMno13RaNqovh4AAoGsx1jeYcIyuGUXMw==}
engines: {node: '>= 9.11.2'}
requiresBuild: true
@@ -6023,14 +6129,13 @@ packages:
magic-string: 0.25.9
postcss: 8.4.21
postcss-load-config: 3.1.1
sass: 1.61.0
sorcery: 0.10.0
strip-indent: 3.0.0
svelte: 3.58.0
typescript: 4.5.4
dev: true
/svelte-preprocess@4.10.1(postcss-load-config@3.1.1)(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3):
/svelte-preprocess@4.10.1(postcss-load-config@3.1.1)(postcss@8.4.21)(svelte@3.58.0)(typescript@5.0.3):
resolution: {integrity: sha512-NSNloaylf+o9UeyUR2KvpdxrAyMdHl3U7rMnoP06/sG0iwJvlUM4TpMno13RaNqovh4AAoGsx1jeYcIyuGUXMw==}
engines: {node: '>= 9.11.2'}
requiresBuild: true
@@ -6077,14 +6182,13 @@ packages:
magic-string: 0.25.9
postcss: 8.4.21
postcss-load-config: 3.1.1
sass: 1.61.0
sorcery: 0.10.0
strip-indent: 3.0.0
svelte: 3.58.0
typescript: 5.0.3
dev: true
/svelte-preprocess@5.0.3(postcss@8.4.21)(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3):
/svelte-preprocess@5.0.3(sass@1.61.0)(svelte@3.58.0)(typescript@5.0.3):
resolution: {integrity: sha512-GrHF1rusdJVbOZOwgPWtpqmaexkydznKzy5qIC2FabgpFyKN57bjMUUUqPRfbBXK5igiEWn1uO/DXsa2vJ5VHA==}
engines: {node: '>= 14.10.0'}
requiresBuild: true
@@ -6125,7 +6229,6 @@ packages:
'@types/pug': 2.0.6
detect-indent: 6.1.0
magic-string: 0.27.0
postcss: 8.4.21
sass: 1.61.0
sorcery: 0.11.0
strip-indent: 3.0.0
@@ -6190,12 +6293,10 @@ packages:
get-port: 3.2.0
dev: false
/tailwindcss@3.3.1(postcss@8.4.21):
/tailwindcss@3.3.1:
resolution: {integrity: sha512-Vkiouc41d4CEq0ujXl6oiGFQ7bA3WEhUZdTgXAhtKxSy49OmKs8rEfQmupsfF0IGW8fv2iQkp1EVUuapCFrZ9g==}
engines: {node: '>=12.13.0'}
hasBin: true
peerDependencies:
postcss: ^8.0.9
dependencies:
arg: 5.0.2
chokidar: 3.5.3
@@ -6924,7 +7025,7 @@ packages:
strip-ansi: 6.0.1
tiny-invariant: 1.3.1
typescript: 5.0.3
vite: 4.2.1(sass@1.61.0)
vite: 4.2.1
vscode-languageclient: 7.0.0
vscode-languageserver: 7.0.0
vscode-languageserver-textdocument: 1.0.8
@@ -6947,7 +7048,7 @@ packages:
kolorist: 1.8.0
magic-string: 0.29.0
ts-morph: 17.0.1
vite: 4.2.1(sass@1.61.0)
vite: 4.2.1
transitivePeerDependencies:
- '@types/node'
- rollup
@@ -6961,7 +7062,7 @@ packages:
dependencies:
picocolors: 1.0.0
picomatch: 2.3.1
vite: 4.3.1(@types/node@18.16.0)(sass@1.61.0)
vite: 4.3.1(sass@1.61.0)
dev: false
/vite-tsconfig-paths@4.0.8(typescript@5.0.3)(vite@4.3.1):
@@ -6975,13 +7076,13 @@ packages:
debug: 4.3.4
globrex: 0.1.2
tsconfck: 2.1.1(typescript@5.0.3)
vite: 4.3.1(@types/node@18.16.0)(sass@1.61.0)
vite: 4.3.1(sass@1.61.0)
transitivePeerDependencies:
- supports-color
- typescript
dev: true
/vite@2.9.9(sass@1.61.0):
/vite@2.9.9:
resolution: {integrity: sha512-ffaam+NgHfbEmfw/Vuh6BHKKlI/XIAhxE5QSS7gFLIngxg171mg1P3a4LSRME0z2ZU1ScxoKzphkipcYwSD5Ew==}
engines: {node: '>=12.2.0'}
hasBin: true
@@ -7001,12 +7102,11 @@ packages:
postcss: 8.4.21
resolve: 1.22.2
rollup: 2.79.1
sass: 1.61.0
optionalDependencies:
fsevents: 2.3.2
dev: true
/vite@4.2.1(sass@1.61.0):
/vite@4.2.1:
resolution: {integrity: sha512-7MKhqdy0ISo4wnvwtqZkjke6XN4taqQ2TBaTccLIpOKv7Vp2h4Y+NpmWCnGDeSvvn45KxvWgGyb0MkHvY1vgbg==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
@@ -7035,10 +7135,41 @@ packages:
postcss: 8.4.21
resolve: 1.22.2
rollup: 3.21.0
sass: 1.61.0
optionalDependencies:
fsevents: 2.3.2
/vite@4.3.1:
resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies:
'@types/node': '>= 14'
less: '*'
sass: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
dependencies:
esbuild: 0.17.18
postcss: 8.4.21
rollup: 3.21.0
optionalDependencies:
fsevents: 2.3.2
dev: true
/vite@4.3.1(@types/node@18.16.0)(sass@1.61.0):
resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==}
engines: {node: ^14.18.0 || >=16.0.0}
@@ -7071,6 +7202,48 @@ packages:
sass: 1.61.0
optionalDependencies:
fsevents: 2.3.2
dev: true
/vite@4.3.1(sass@1.61.0):
resolution: {integrity: sha512-EPmfPLAI79Z/RofuMvkIS0Yr091T2ReUoXQqc5ppBX/sjFRhHKiPPF/R46cTdoci/XgeQpB23diiJxq5w30vdg==}
engines: {node: ^14.18.0 || >=16.0.0}
hasBin: true
peerDependencies:
'@types/node': '>= 14'
less: '*'
sass: '*'
stylus: '*'
sugarss: '*'
terser: ^5.4.0
peerDependenciesMeta:
'@types/node':
optional: true
less:
optional: true
sass:
optional: true
stylus:
optional: true
sugarss:
optional: true
terser:
optional: true
dependencies:
esbuild: 0.17.18
postcss: 8.4.21
rollup: 3.21.0
sass: 1.61.0
optionalDependencies:
fsevents: 2.3.2
/vitefu@0.2.4:
resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==}
peerDependencies:
vite: ^3.0.0 || ^4.0.0
peerDependenciesMeta:
vite:
optional: true
dev: true
/vitefu@0.2.4(vite@4.3.1):
resolution: {integrity: sha512-fanAXjSaf9xXtOOeno8wZXIhgia+CZury481LsDaV++lSvcU2R9Ch2bPh3PYFyoHW+w9LqAeYRISVQjUIew14g==}
@@ -7080,7 +7253,8 @@ packages:
vite:
optional: true
dependencies:
vite: 4.3.1(@types/node@18.16.0)(sass@1.61.0)
vite: 4.3.1(sass@1.61.0)
dev: false
/vitest@0.25.8(sass@1.61.0):
resolution: {integrity: sha512-X75TApG2wZTJn299E/TIYevr4E9/nBo1sUtZzn0Ci5oK8qnpZAZyhwg0qCeMSakGIWtc6oRwcQFyFfW14aOFWg==}

View File

@@ -1,6 +1,4 @@
packages:
- 'gradio/js/*'
- 'gradio/client/js'
- 'litegraph/packages/core'
- 'litegraph/packages/nodes-basic'
- 'litegraph/packages/tsconfig'
- 'litegraph/packages/*'

View File

@@ -1,377 +1,15 @@
<script lang="ts">
import ComfyApp from "$lib/components/ComfyApp.svelte"
import { default as ComfyAppState } from "$lib/components/ComfyApp"
import "@litegraph-ts/core/css/litegraph.css";
import "./scss/global.scss";
import { onMount } from 'svelte';
const app = new ComfyAppState();
</script>
<ComfyApp/>
<ComfyApp {app}/>
<style>
:root {
--primary-50: #fff7ed;
--primary-100: #ffedd5;
--primary-200: #fed7aa;
--primary-300: #fdba74;
--primary-400: #fb923c;
--primary-500: #f97316;
--primary-600: #ea580c;
--primary-700: #c2410c;
--primary-800: #9a3412;
--primary-900: #7c2d12;
--primary-950: #6c2e12;
--secondary-50: #eff6ff;
--secondary-100: #dbeafe;
--secondary-200: #bfdbfe;
--secondary-300: #93c5fd;
--secondary-400: #60a5fa;
--secondary-500: #3b82f6;
--secondary-600: #2563eb;
--secondary-700: #1d4ed8;
--secondary-800: #1e40af;
--secondary-900: #1e3a8a;
--secondary-950: #1d3660;
--neutral-50: #f9fafb;
--neutral-100: #f3f4f6;
--neutral-200: #e5e7eb;
--neutral-300: #d1d5db;
--neutral-400: #9ca3af;
--neutral-500: #6b7280;
--neutral-600: #4b5563;
--neutral-700: #374151;
--neutral-800: #1f2937;
--neutral-900: #111827;
--neutral-950: #0b0f19;
--spacing-xxs: 1px;
--spacing-xs: 2px;
--spacing-sm: 4px;
--spacing-md: 6px;
--spacing-lg: 8px;
--spacing-xl: 10px;
--spacing-xxl: 16px;
--radius-xxs: 1px;
--radius-xs: 2px;
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-xxl: 22px;
--text-xxs: 9px;
--text-xs: 10px;
--text-sm: 12px;
--text-md: 14px;
--text-lg: 16px;
--text-xl: 22px;
--text-xxl: 26px;
--color-accent: var(--primary-500);
--color-accent-soft: var(--primary-50);
--background-fill-primary: white;
--background-fill-secondary: var(--neutral-50);
--border-color-accent: var(--primary-300);
--border-color-primary: var(--neutral-200);
--text-color-code-background-fill: var(--neutral-200);
--text-color-code-border: var(--border-color-primary);
--link-text-color: var(--secondary-600);
--link-text-color-active: var(--secondary-600);
--link-text-color-hover: var(--secondary-700);
--link-text-color-visited: var(--secondary-500);
--body-text-color-subdued: var(--neutral-400);
--body-background-fill: var(--background-fill-primary);
--body-text-color: var(--neutral-800);
--body-text-size: var(--text-md);
--body-text-weight: 400;
--embed-radius: var(--radius-lg);
--shadow-drop: rgba(0, 0, 0, 0.05) 0px 1px 2px 0px;
--shadow-drop-lg: 0 1px 3px 0 rgb(0 0 0 / 0.1),
0 1px 2px -1px rgb(0 0 0 / 0.1);
--shadow-inset: rgba(0, 0, 0, 0.05) 0px 2px 4px 0px inset;
--shadow-spread: 3px;
--block-background-fill: var(--background-fill-primary);
--block-border-color: var(--border-color-primary);
--block-border-width: 1px;
--block-info-text-color: var(--body-text-color-subdued);
--block-info-text-size: var(--text-sm);
--block-info-text-weight: 400;
--block-label-background-fill: var(--background-fill-primary);
--block-label-border-color: var(--border-color-primary);
--block-label-border-width: 1px;
--block-label-text-color: var(--neutral-500);
--block-label-icon-color: var(--block-label-text-color);
--block-label-margin: 0;
--block-label-padding: var(--spacing-sm) var(--spacing-lg);
--block-label-radius: calc(var(--radius-lg) - 1px) 0
calc(var(--radius-lg) - 1px) 0;
--block-label-right-radius: 0 calc(var(--radius-lg) - 1px) 0
calc(var(--radius-lg) - 1px);
--block-label-text-size: var(--text-sm);
--block-label-text-weight: 400;
--block-padding: var(--spacing-xl) calc(var(--spacing-xl) + 2px);
--block-radius: var(--radius-lg);
--block-shadow: var(--shadow-drop);
--block-title-background-fill: none;
--block-title-border-color: none;
--block-title-border-width: 0px;
--block-title-text-color: var(--neutral-500);
--block-title-padding: 0;
--block-title-radius: none;
--block-title-text-size: var(--text-md);
--block-title-text-weight: 400;
--container-radius: var(--radius-lg);
--form-gap-width: 1px;
--layout-gap: var(--spacing-xxl);
--panel-background-fill: var(--background-fill-secondary);
--panel-border-color: var(--border-color-primary);
--panel-border-width: 0;
--section-header-text-size: var(--text-md);
--section-header-text-weight: 400;
--checkbox-background-color: var(--background-fill-primary);
--checkbox-background-color-focus: var(--background-fill-primary);
--checkbox-background-color-hover: var(--background-fill-primary);
--checkbox-background-color-selected: var(--secondary-600);
--checkbox-border-color: var(--neutral-300);
--checkbox-border-color-focus: var(--secondary-500);
--checkbox-border-color-hover: var(--neutral-300);
--checkbox-border-color-selected: var(--secondary-600);
--checkbox-border-radius: var(--radius-sm);
--checkbox-border-width: var(--input-border-width);
--checkbox-label-background-fill: linear-gradient(
to top,
var(--neutral-50),
white
);
--checkbox-label-background-fill-hover: linear-gradient(
to top,
var(--neutral-100),
white
);
--checkbox-label-background-fill-selected: var(
--checkbox-label-background-fill
);
--checkbox-label-border-color: var(--border-color-primary);
--checkbox-label-border-color-hover: var(--border-color-primary);
--checkbox-label-border-width: var(--input-border-width);
--checkbox-label-gap: var(--spacing-lg);
--checkbox-label-padding: var(--spacing-md) calc(2 * var(--spacing-md));
--checkbox-label-shadow: var(--shadow-drop);
--checkbox-label-text-size: var(--text-md);
--checkbox-label-text-weight: 400;
--checkbox-shadow: var(--input-shadow);
--checkbox-label-text-color: var(--body-text-color);
--checkbox-label-text-color-selected: var(--checkbox-label-text-color);
--error-background-fill: linear-gradient(
to right,
#fee2e2,
var(--background-fill-secondary)
);
--error-border-color: #fecaca;
--error-border-width: 1px;
--error-text-color: #ef4444;
--prose-header-text-weight: 600;
--input-background-fill: white;
--input-background-fill-focus: var(--secondary-500);
--input-background-fill-hover: var(--input-background-fill);
--input-border-color: var(--border-color-primary);
--input-border-color-focus: var(--secondary-300);
--input-border-color-hover: var(--border-color-primary);
--input-border-width: 1px;
--input-padding: var(--spacing-xl);
--input-placeholder-color: var(--neutral-400);
--input-radius: var(--radius-lg);
--input-shadow: 0 0 0 var(--shadow-spread) transparent, var(--shadow-inset);
--input-shadow-focus: 0 0 0 var(--shadow-spread) var(--secondary-50),
var(--shadow-inset);
--input-text-size: var(--text-md);
--input-text-weight: 400;
--loader-color: var(--color-accent);
--prose-text-size: var(--text-md);
--prose-text-weight: 400;
--stat-background-fill: linear-gradient(
to right,
var(--primary-400),
var(--primary-200)
);
--table-border-color: var(--neutral-300);
--table-even-background-fill: white;
--table-odd-background-fill: var(--neutral-50);
--table-radius: var(--radius-lg);
--table-row-focus: var(--color-accent-soft);
--button-border-width: var(--input-border-width);
--button-cancel-background-fill: linear-gradient(
to bottom right,
#fee2e2,
#fecaca
);
--button-cancel-background-fill-hover: linear-gradient(
to bottom right,
#fee2e2,
#fee2e2
);
--button-cancel-border-color: #fecaca;
--button-cancel-border-color-hover: var(--button-cancel-border-color);
--button-cancel-text-color: #dc2626;
--button-cancel-text-color-hover: var(--button-cancel-text-color);
--button-large-padding: var(--spacing-lg) calc(2 * var(--spacing-lg));
--button-large-radius: var(--radius-lg);
--button-large-text-size: var(--text-lg);
--button-large-text-weight: 600;
--button-primary-background-fill: linear-gradient(
to bottom right,
var(--primary-100),
var(--primary-300)
);
--button-primary-background-fill-hover: linear-gradient(
to bottom right,
var(--primary-100),
var(--primary-200)
);
--button-primary-border-color: var(--primary-200);
--button-primary-border-color-hover: var(--button-primary-border-color);
--button-primary-text-color: var(--primary-600);
--button-primary-text-color-hover: var(--button-primary-text-color);
--button-secondary-background-fill: linear-gradient(
to bottom right,
var(--neutral-100),
var(--neutral-200)
);
--button-secondary-background-fill-hover: linear-gradient(
to bottom right,
var(--neutral-100),
var(--neutral-100)
);
--button-secondary-border-color: var(--neutral-200);
--button-secondary-border-color-hover: var(--button-secondary-border-color);
--button-secondary-text-color: var(--neutral-700);
--button-secondary-text-color-hover: var(--button-secondary-text-color);
--button-shadow: var(--shadow-drop);
--button-shadow-active: var(--shadow-inset);
--button-shadow-hover: var(--shadow-drop-lg);
--button-small-padding: var(--spacing-sm) calc(2 * var(--spacing-sm));
--button-small-radius: var(--radius-lg);
--button-small-text-size: var(--text-md);
--button-small-text-weight: 400;
--button-transition: none;
}
.dark {
--color-accent-soft: var(--neutral-900);
--background-fill-primary: var(--neutral-950);
--background-fill-secondary: var(--neutral-900);
--border-color-accent: var(--neutral-600);
--border-color-primary: var(--neutral-700);
--text-color-code-background-fill: var(--neutral-800);
--link-text-color-active: var(--secondary-500);
--link-text-color: var(--secondary-500);
--link-text-color-hover: var(--secondary-400);
--link-text-color-visited: var(--secondary-600);
--body-text-color-subdued: var(--neutral-400);
--body-background-fill: var(--background-fill-primary);
--body-text-color: var(--neutral-100);
--shadow-spread: 1px;
--block-background-fill: var(--neutral-800);
--block-border-color: var(--border-color-primary);
--block-border-width: 1px;
--block-info-text-color: var(--body-text-color-subdued);
--block-label-background-fill: var(--background-fill-secondary);
--block-label-border-color: var(--border-color-primary);
--block-label-border-width: 1px;
--block-label-text-color: var(--neutral-200);
--block-shadow: none;
--block-title-background-fill: none;
--block-title-border-color: none;
--block-title-border-width: 0px;
--block-title-text-color: var(--neutral-200);
--panel-background-fill: var(--background-fill-secondary);
--panel-border-color: var(--border-color-primary);
--checkbox-background-color: var(--neutral-800);
--checkbox-background-color-focus: var(--checkbox-background-color);
--checkbox-background-color-hover: var(--checkbox-background-color);
--checkbox-background-color-selected: var(--secondary-600);
--checkbox-border-color: var(--neutral-700);
--checkbox-border-color-focus: var(--secondary-500);
--checkbox-border-color-hover: var(--neutral-600);
--checkbox-border-color-selected: var(--secondary-600);
--checkbox-label-background-fill: linear-gradient(
to top,
var(--neutral-900),
var(--neutral-800)
);
--checkbox-label-background-fill-hover: linear-gradient(
to top,
var(--neutral-900),
var(--neutral-800)
);
--checkbox-label-background-fill-selected: var(
--checkbox-label-background-fill
);
--checkbox-label-border-color: var(--border-color-primary);
--checkbox-label-border-color-hover: var(--border-color-primary);
--checkbox-label-text-color: var(--body-text-color);
--checkbox-label-text-color-selected: var(--checkbox-label-text-color);
--error-background-fill: var(--background-fill-primary);
--error-border-color: var(--border-color-primary);
--error-border-width: var(--error-border-width);
--error-text-color: #ef4444;
--input-background-fill: var(--neutral-800);
--input-background-fill-focus: var(--secondary-600);
--input-background-fill-hover: var(--input-background-fill);
--input-border-color: var(--border-color-primary);
--input-border-color-focus: var(--neutral-700);
--input-border-color-hover: var(--border-color-primary);
--input-placeholder-color: var(--neutral-500);
--input-shadow: var(--input-shadow);
--input-shadow-focus: 0 0 0 var(--shadow-spread) var(--neutral-700),
var(--shadow-inset);
--loader-color: var(--loader-color);
--stat-background-fill: linear-gradient(
to right,
var(--primary-400),
var(--primary-600)
);
--table-border-color: var(--neutral-700);
--table-even-background-fill: var(--neutral-950);
--table-odd-background-fill: var(--neutral-900);
--table-row-focus: var(--color-accent-soft);
--button-cancel-background-fill: linear-gradient(
to bottom right,
#dc2626,
#b91c1c
);
--button-cancel-background-fill-hover: linear-gradient(
to bottom right,
#dc2626,
#dc2626
);
--button-cancel-border-color: #dc2626;
--button-cancel-border-color-hover: var(--button-cancel-border-color);
--button-cancel-text-color: white;
--button-cancel-text-color-hover: var(--button-cancel-text-color);
--button-primary-background-fill: linear-gradient(
to bottom right,
var(--primary-600),
var(--primary-700)
);
--button-primary-background-fill-hover: linear-gradient(
to bottom right,
var(--primary-600),
var(--primary-600)
);
--button-primary-border-color: var(--primary-600);
--button-primary-border-color-hover: var(--button-primary-border-color);
--button-primary-text-color: white;
--button-primary-text-color-hover: var(--button-primary-text-color);
--button-secondary-background-fill: linear-gradient(
to bottom right,
var(--neutral-600),
var(--neutral-700)
);
--button-secondary-background-fill-hover: linear-gradient(
to bottom right,
var(--neutral-600),
var(--neutral-600)
);
--button-secondary-border-color: var(--neutral-600);
--button-secondary-border-color-hover: var(--button-secondary-border-color);
--button-secondary-text-color: white;
--button-secondary-text-color-hover: var(--button-secondary-text-color);
}
</style>

View File

@@ -4,8 +4,6 @@
import { Button } from "@gradio/button";
import ComfyApp, { type SerializedAppState } from "$lib/components/ComfyApp";
import { Checkbox } from "@gradio/form"
import widgetState from "$lib/stores/widgetState";
import nodeState from "$lib/stores/nodeState";
import uiState from "$lib/stores/uiState";
import { ImageViewer } from "$lib/ImageViewer";
import { download } from "$lib/utils"

124
src/lib/ComfyGraph.ts Normal file
View File

@@ -0,0 +1,124 @@
import { LConnectionKind, LGraph, LGraphNode, type INodeSlot, type SlotIndex, LiteGraph, getStaticProperty, type LGraphAddNodeOptions } from "@litegraph-ts/core";
import GraphSync from "./GraphSync";
import EventEmitter from "events";
import type TypedEmitter from "typed-emitter";
import layoutState from "./stores/layoutState";
import uiState from "./stores/uiState";
import { get } from "svelte/store";
import type ComfyGraphNode from "./nodes/ComfyGraphNode";
import type IComfyInputSlot from "./IComfyInputSlot";
import type { ComfyBackendNode } from "./nodes/ComfyBackendNode";
import type { ComfyWidgetNode } from "./nodes";
type ComfyGraphEvents = {
configured: (graph: LGraph) => void
nodeAdded: (node: LGraphNode) => void
nodeRemoved: (node: LGraphNode) => void
nodeConnectionChanged: (kind: LConnectionKind, node: LGraphNode, slot: SlotIndex, targetNode: LGraphNode, targetSlot: SlotIndex) => void
cleared: () => void
beforeChange: (graph: LGraph, param: any) => void
afterChange: (graph: LGraph, param: any) => void
afterExecute: () => void
}
export default class ComfyGraph extends LGraph {
graphSync: GraphSync;
eventBus: TypedEmitter<ComfyGraphEvents> = new EventEmitter() as TypedEmitter<ComfyGraphEvents>;
constructor() {
super();
this.graphSync = new GraphSync(this)
}
override onConfigure() {
console.debug("Configured");
this.eventBus.emit("configured", this);
}
override onBeforeChange(graph: LGraph, info: any) {
console.debug("BeforeChange", info);
this.eventBus.emit("beforeChange", graph, info);
}
override onAfterChange(graph: LGraph, info: any) {
console.debug("AfterChange", info);
this.eventBus.emit("afterChange", graph, info);
}
override onAfterExecute() {
this.eventBus.emit("afterExecute");
}
override onNodeAdded(node: LGraphNode, options: LGraphAddNodeOptions) {
layoutState.nodeAdded(node)
this.graphSync.onNodeAdded(node);
if ("outputProperties" in node) {
const widgetNode = node as ComfyWidgetNode;
for (const propName of widgetNode.outputProperties) {
widgetNode.addPropertyAsOutput(propName.name, propName.type)
}
}
// Check if the class declared a default widget layout
if ("defaultWidgets" in node && !("svelteComponentType" in node)) {
const comfyNode = node as ComfyGraphNode;
const widgets = comfyNode.defaultWidgets;
if (widgets) {
if (widgets.inputs) {
for (const pair of Object.entries(comfyNode.defaultWidgets.inputs)) {
const [index, spec] = pair
const input = comfyNode.inputs[index] as IComfyInputSlot;
input.defaultWidgetNode = spec.defaultWidgetNode;
if (spec.config)
input.config = spec.config
}
}
}
}
if (get(uiState).autoAddUI) {
if (!("svelteComponentType" in node) && !options.addedByDeserialize) {
console.debug("[ComfyGraph] AutoAdd UI")
const comfyNode = node as ComfyGraphNode;
const widgetNodesAdded = []
for (let index = 0; index < comfyNode.inputs.length; index++) {
const input = comfyNode.inputs[index];
if ("config" in input) {
const comfyInput = input as IComfyInputSlot;
if (comfyInput.defaultWidgetNode) {
const widgetNode = LiteGraph.createNode(comfyInput.defaultWidgetNode)
const inputPos = comfyNode.getConnectionPos(true, index);
this.add(widgetNode)
widgetNode.connect(0, comfyNode, index);
widgetNode.collapse();
widgetNode.pos = [inputPos[0] - 140, inputPos[1] + LiteGraph.NODE_SLOT_HEIGHT / 2];
widgetNodesAdded.push(widgetNode)
}
}
}
const dragItems = widgetNodesAdded.map(wn => get(layoutState).allItemsByNode[wn.id]?.dragItem).filter(di => di)
console.debug("[ComfyGraph] Group new widgets", dragItems)
layoutState.groupItems(dragItems, { title: node.title })
}
}
console.debug("Added", node);
this.eventBus.emit("nodeAdded", node);
}
override onNodeRemoved(node: LGraphNode) {
layoutState.nodeRemoved(node);
this.graphSync.onNodeRemoved(node);
console.debug("Removed", node);
this.eventBus.emit("nodeRemoved", node);
}
override onNodeConnectionChange(kind: LConnectionKind, node: LGraphNode, slot: SlotIndex, targetNode: LGraphNode, targetSlot: SlotIndex) {
console.debug("ConnectionChange", node);
this.eventBus.emit("nodeConnectionChanged", kind, node, slot, targetNode, targetSlot);
}
}

View File

@@ -3,6 +3,11 @@ import type ComfyApp from "./components/ComfyApp";
import queueState from "./stores/queueState";
import { get } from "svelte/store";
export type SerializedGraphCanvasState = {
offset: Vector2,
scale: number
}
export default class ComfyGraphCanvas extends LGraphCanvas {
app: ComfyApp
@@ -20,6 +25,23 @@ export default class ComfyGraphCanvas extends LGraphCanvas {
this.app = app;
}
serialize(): SerializedGraphCanvasState {
return {
offset: this.ds.offset,
scale: this.ds.scale
}
}
deserialize(data: SerializedGraphCanvasState) {
this.ds.offset = data.offset;
this.ds.scale = data.scale;
}
recenter() {
this.ds.reset();
this.setDirty(true, true)
}
override drawNodeShape(
node: LGraphNode,
ctx: CanvasRenderingContext2D,

View File

@@ -1,19 +1,14 @@
import type { LGraph } from "@litegraph-ts/core";
import widgetState, { type WidgetStateStore, type WidgetUIState, type WidgetUIStateStore } from "./stores/widgetState";
import nodeState, { type NodeStateStore, type NodeUIState, type NodeUIStateStore } from "./stores/nodeState";
import type { LGraph, LGraphNode } from "@litegraph-ts/core";
import type ComfyApp from "./components/ComfyApp";
import type { Unsubscriber } from "svelte/store";
import type { Unsubscriber, Writable } from "svelte/store";
import type { ComfyWidgetNode } from "./nodes";
import type ComfyGraph from "./ComfyGraph";
type WidgetSubStore = {
store: WidgetUIStateStore,
unsubscribe: Unsubscriber
}
type NodeSubStore = {
store: NodeUIStateStore,
unsubscribe: Unsubscriber
}
/*
* Responsible for watching for and synchronizing state changes from the
* frontend to the litegraph instance.
@@ -30,80 +25,53 @@ type NodeSubStore = {
*/
export default class GraphSync {
graph: LGraph;
private _unsubscribe: Unsubscriber;
private _finalizer: FinalizationRegistry<number>;
// nodeId -> widgetSubStores[]
private stores: Record<string, WidgetSubStore[]> = {}
// nodeId -> widgetSubStore
private stores: Record<string, WidgetSubStore> = {}
constructor(app: ComfyApp) {
this.graph = app.lGraph;
this._unsubscribeWidget = widgetState.subscribe(this.onAllWidgetStateChanged.bind(this));
this._unsubscribeNode = nodeState.subscribe(this.onAllNodeStateChanged.bind(this));
this._finalizer = new FinalizationRegistry((id: number) => {
console.log(`${this} has been garbage collected`);
this._unsubscribeWidget();
this._unsubscribeNode();
});
constructor(graph: ComfyGraph) {
this.graph = graph;
}
/*
* Fired when the entire widget graph changes.
*/
private onAllWidgetStateChanged(state: WidgetStateStore) {
onNodeAdded(node: LGraphNode) {
// TODO assumes only a single graph's widget state.
for (let nodeId in state) {
if (!this.stores[nodeId]) {
this.addStores(state, nodeId);
}
}
for (let nodeId in this.stores) {
if (!state[nodeId]) {
this.removeStores(nodeId);
}
}
}
private onAllNodeStateChanged(state: NodeStateStore) {
// TODO assumes only a single graph's widget state.
for (let nodeId in state) {
state[nodeId].node.name = state[nodeId].name;
if ("svelteComponentType" in node) {
this.addStore(node as ComfyWidgetNode);
}
this.graph.setDirtyCanvas(true, true);
}
private addStores(state: WidgetStateStore, nodeId: string) {
if (this.stores[nodeId]) {
console.warn("Stores already exist!", nodeId, this.stores[nodeId])
onNodeRemoved(node: LGraphNode) {
if ("svelteComponentType" in node) {
this.removeStore(node as ComfyWidgetNode);
}
this.stores[nodeId] = []
for (const wuis of state[nodeId]) {
const unsub = wuis.value.subscribe((v) => this.onWidgetStateChanged(wuis, v))
this.stores[nodeId].push({ store: wuis.value, unsubscribe: unsub });
}
console.debug("NEWSTORES", this.stores[nodeId])
this.graph.setDirtyCanvas(true, true);
}
private removeStores(nodeId: string) {
console.debug("DELSTORES", this.stores[nodeId])
for (const ss of this.stores[nodeId]) {
ss.unsubscribe();
private addStore(node: ComfyWidgetNode) {
if (this.stores[node.id]) {
console.warn("[GraphSync] Stores already exist!", node.id, this.stores[node.id])
}
delete this.stores[nodeId]
const unsub = node.value.subscribe((v) => this.onWidgetStateChanged(node, v))
this.stores[node.id] = ({ store: node.value, unsubscribe: unsub });
console.debug("[GraphSync] NEWSTORE", this.stores[node.id])
}
private removeStore(node: ComfyWidgetNode) {
console.debug("[GraphSync] DELSTORE", this.stores[node.id])
this.stores[node.id].unsubscribe()
delete this.stores[node.id]
}
/*
* Fired when a single widget's value changes.
*/
private onWidgetStateChanged(wuis: WidgetUIState, value: any) {
wuis.widget.value = value;
private onWidgetStateChanged(node: ComfyWidgetNode, value: any) {
this.graph.setDirtyCanvas(true, true);
}
}

View File

@@ -0,0 +1,20 @@
import type { INodeInputSlot } from "@litegraph-ts/core";
import type { ComfyWidgetNode } from "./nodes";
// TODO generalize
export type ComfyInputConfig = {
min?: number,
max?: number,
step?: number,
precision?: number,
defaultValue?: any,
values?: any[],
multiline?: boolean
}
export default interface IComfyInputSlot extends INodeInputSlot {
serialize: boolean,
defaultWidgetNode: new (name?: string) => ComfyWidgetNode
widgetNodeType?: string,
config: ComfyInputConfig, // stores range min/max/step, etc.
}

View File

@@ -231,7 +231,7 @@ export default class ComfyAPI extends EventTarget {
};
} catch (error) {
console.error(error);
return { Running: [], Pending: [] };
return { Running: [], Pending: [], error };
}
}
@@ -245,7 +245,7 @@ export default class ComfyAPI extends EventTarget {
return { History: Object.values(await res.json()) };
} catch (error) {
console.error(error);
return { History: [] };
return { History: [], error };
}
}

View File

@@ -0,0 +1,269 @@
<script lang="ts">
import { Block, BlockTitle } from "@gradio/atoms";
import uiState from "$lib/stores/uiState";
import WidgetContainer from "./WidgetContainer.svelte"
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import {fade} from 'svelte/transition';
// notice - fade in works fine but don't add svelte's fade-out (known issue)
import {cubicIn} from 'svelte/easing';
import { flip } from 'svelte/animate';
import layoutState, { type ContainerLayout, type WidgetLayout, type IDragItem } from "$lib/stores/layoutState";
import { startDrag, stopDrag } from "$lib/utils"
export let container: ContainerLayout | null = null;
export let zIndex: number = 0;
export let classes: string[] = [];
export let showHandles: boolean = false;
let children: IDragItem[] | null = null;
const flipDurationMs = 100;
$: if (container) {
children = $layoutState.allItems[container.id].children;
}
else {
children = null;
}
function handleConsider(evt: any) {
children = layoutState.updateChildren(container, evt.detail.items)
// console.log(dragItems);
};
function handleFinalize(evt: any) {
children = layoutState.updateChildren(container, evt.detail.items)
// Ensure dragging is stopped on drag finish
};
</script>
{#if container && children}
<div class="container {container.attrs.direction} {container.attrs.classes} {classes.join(' ')}"
class:selected={$uiState.uiEditMode !== "disabled" && $layoutState.currentSelection.includes(container.id)}
class:root-container={zIndex === 0}
class:is-executing={container.isNodeExecuting}
class:container-edit-outline={$uiState.uiEditMode === "widgets" && zIndex > 1}>
<Block>
{#if container.attrs.showTitle}
<label for={String(container.id)} class={$uiState.uiEditMode === "widgets" ? "edit-title-label" : ""}>
<BlockTitle>
{#if $uiState.uiEditMode === "widgets"}
<input class="edit-title" bind:value={container.attrs.title} type="text" minlength="1" />
{:else}
{container.attrs.title}
{/if}
</BlockTitle>
</label>
{/if}
<div class="v-pane"
class:empty={children.length === 0}
class:edit={$uiState.uiEditMode === "widgets" && zIndex > 1}
use:dndzone="{{
items: children,
flipDurationMs,
centreDraggedOnCursor: true,
morphDisabled: true,
dropFromOthersDisabled: zIndex === 0,
dragDisabled: zIndex === 0 || $layoutState.currentSelection.length > 2 || $uiState.uiEditMode === "disabled"
}}"
on:consider="{handleConsider}"
on:finalize="{handleFinalize}"
>
{#each children.filter(item => item.id !== SHADOW_PLACEHOLDER_ITEM_ID) as item(item.id)}
<div class="animation-wrapper"
animate:flip={{duration:flipDurationMs}}>
<WidgetContainer dragItem={item} zIndex={zIndex+1} />
{#if item[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='drag-item-shadow'/>
{/if}
</div>
{/each}
</div>
{#if showHandles}
<div class="handle handle-container" style="z-index: {zIndex+100}" data-drag-item-id={container.id} on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
{/if}
</Block>
</div>
{/if}
<style lang="scss">
.v-pane {
height: 100%;
width: 100%;
overflow: visible;
display: flex;
.edit {
min-width: 200px;
}
&.empty {
border-width: 3px;
border-color: var(--color-grey-400);
border-radius: var(--block-radius);
background: var(--color-grey-300);
min-height: 100px;
border-style: dashed;
}
}
.handle {
cursor: grab;
z-index: 99999;
position: absolute;
right: 0;
top: 0;
width: 100%;
height: 100%;
}
.handle-container:hover {
background-color: #d8ade680;
}
.drag-item-shadow {
position: absolute;
top: 0; left:0; right: 0; bottom: 0;
visibility: visible;
border: 1px dashed grey;
background: lightblue;
opacity: 0.5;
margin: 0;
}
.container {
display: flex;
:global(.block) {
height: fit-content;
}
&.horizontal {
flex-wrap: wrap;
gap: var(--layout-gap);
width: var(--size-full);
> :global(.block > .v-pane) {
flex-direction: row;
}
> :global(*), > :global(.form > *) {
flex: 1 1 0%;
flex-wrap: wrap;
min-width: min(160px, 100%);
}
}
&.vertical {
position: relative;
> :global(.block > .v-pane) {
flex-direction: column;
}
> :global(*), > :global(.form > *), .v-pane {
width: var(--size-full);
}
}
}
.container.selected > :global(.block) {
background: var(--color-yellow-300);
}
.is-executing {
border: 3px dashed var(--color-green-600) !important;
margin: 0.2em;
padding: 0.2em;
}
.animation-wrapper {
position: relative;
&:not(.edit) {
flex-grow: 1;
}
}
.handle {
cursor: grab;
z-index: 99999;
position: absolute;
right: 0;
top: 0;
width: 100%;
height: 100%;
}
.handle-widget:hover {
background-color: #add8e680;
}
.handle-container:hover {
background-color: #d8ade680;
}
.drag-item-shadow {
position: absolute;
top: 0; left:0; right: 0; bottom: 0;
visibility: visible;
border: 1px dashed grey;
background: lightblue;
opacity: 0.5;
margin: 0;
}
.node-type {
font-size: smaller;
color: var(--neutral-400);
}
.edit-title-label {
z-index: 10000;
position: relative;
}
.edit-title {
z-index: 10000;
display: block;
position: relative;
outline: none !important;
box-shadow: var(--input-shadow);
border: var(--input-border-width) solid var(--input-border-color);
border-radius: var(--input-radius);
background: var(--input-background-fill);
padding: var(--input-padding);
width: 100%;
color: var(--body-text-color);
font-weight: var(--input-text-weight);
font-size: var(--input-text-size);
line-height: var(--line-sm);
}
.edit-title:focus {
box-shadow: var(--input-shadow-focus);
border-color: var(--input-border-color-focus);
}
.edit-title::placeholder {
color: var(--input-placeholder-color);
}
.container-edit-outline > :global(.block) {
border-color: var(--color-pink-500);
border-width: 2px;
border-style: dashed !important;
margin: 0.2em;
padding: 1.4em;
}
.widget-edit-outline {
border: 2px dashed var(--color-blue-400);
margin: 0.2em;
padding: 0.2em;
}
.root-container > :global(.block) {
padding: 0px;
}
</style>

View File

@@ -6,26 +6,34 @@
import ComfyUIPane from "./ComfyUIPane.svelte";
import ComfyApp, { type SerializedAppState } from "./ComfyApp";
import { Checkbox } from "@gradio/form"
import widgetState from "$lib/stores/widgetState";
import nodeState from "$lib/stores/nodeState";
import uiState from "$lib/stores/uiState";
import layoutState from "$lib/stores/layoutState";
import { ImageViewer } from "$lib/ImageViewer";
import { download } from "$lib/utils"
import { LGraph, LGraphNode } from "@litegraph-ts/core";
import LightboxModal from "./LightboxModal.svelte";
import { Block } from "@gradio/atoms";
import ComfyQueue from "./ComfyQueue.svelte";
import type { ComfyAPIStatus } from "$lib/api";
import { SvelteToast, toast } from '@zerodevx/svelte-toast'
import { LGraph } from "@litegraph-ts/core";
import LightboxModal from "./LightboxModal.svelte";
import ComfyQueue from "./ComfyQueue.svelte";
import queueState from "$lib/stores/queueState";
let app: ComfyApp = undefined;
export let app: ComfyApp = undefined;
let imageViewer: ImageViewer;
let uiPane: ComfyUIPane = undefined;
let queue: ComfyQueue = undefined;
let mainElem: HTMLDivElement;
let uiPane: ComfyUIPane = undefined;
let containerElem: HTMLDivElement;
let resizeTimeout: typeof Timer = -1;
let resizeTimeout: NodeJS.Timeout | null;
let hasShownUIHelpToast: boolean = false;
let debugLayout: boolean = false;
const toastOptions = {
intro: { duration: 200 },
theme: {
'--toastBarHeight': 0
}
}
function refreshView(event?: Event) {
clearTimeout(resizeTimeout);
@@ -37,8 +45,11 @@
app.queuePrompt(0, 1);
}
$: if (app) app.lCanvas.allow_dragnodes = !$uiState.nodesLocked;
$: if (app) app.lCanvas.allow_interaction = !$uiState.graphLocked;
$: if (app?.lCanvas) app.lCanvas.allow_dragnodes = !$uiState.nodesLocked;
$: if (app?.lCanvas) app.lCanvas.allow_interaction = !$uiState.graphLocked;
$: if ($uiState.uiEditMode)
$layoutState.currentSelection = []
let graphSize = null;
@@ -64,69 +75,50 @@
}
}
let graphResizeTimer: typeof Timer = -1;
function serializeAppState(): SerializedAppState {
const graph = app.lGraph;
const serializedGraph = graph.serialize()
const serializedPaneOrder = uiPane.serialize()
return {
createdBy: "ComfyBox",
version: 1,
workflow: serializedGraph,
panes: serializedPaneOrder
}
}
function doAutosave(graph: LGraph): void {
const savedWorkflow = serializeAppState();
localStorage.setItem("workflow", JSON.stringify(savedWorkflow))
}
function doRestore(workflow: SerializedAppState) {
uiPane.restore(workflow.panes);
}
function doSave(): void {
if (!app?.lGraph)
return;
const date = new Date();
const formattedDate = date.toISOString().replace(/:/g, '-').replace(/\.\d{3}/g, '').replace('T', '_').replace("Z", "");
download(`workflow-${formattedDate}.json`, JSON.stringify(serializeAppState()), "application/json")
app.saveStateToLocalStorage();
toast.push("Saved to local storage.")
//
// const date = new Date();
// const formattedDate = date.toISOString().replace(/:/g, '-').replace(/\.\d{3}/g, '').replace('T', '_').replace("Z", "");
//
// download(`workflow-${formattedDate}.json`, JSON.stringify(app.serialize()), "application/json")
}
onMount(async () => {
app = new ComfyApp();
function doReset(): void {
var confirmed = confirm("Are you sure you want to clear the current workflow?");
if (confirmed) {
app.reset();
}
}
// TODO dedup
app.eventBus.on("nodeAdded", nodeState.nodeAdded);
app.eventBus.on("nodeRemoved", nodeState.nodeRemoved);
app.eventBus.on("configured", nodeState.configureFinished);
app.eventBus.on("cleared", nodeState.clear);
function doRecenter(): void {
app.lCanvas.recenter();
}
app.eventBus.on("nodeAdded", widgetState.nodeAdded);
app.eventBus.on("nodeRemoved", widgetState.nodeRemoved);
app.eventBus.on("configured", widgetState.configureFinished);
app.eventBus.on("cleared", widgetState.clear);
app.eventBus.on("autosave", doAutosave);
app.eventBus.on("restored", doRestore);
$: if ($uiState.uiEditMode !== "disabled" && !hasShownUIHelpToast) {
hasShownUIHelpToast = true;
toast.push("Right-click to open context menu.")
}
app.api.addEventListener("status", (ev: CustomEvent) => {
queueState.statusUpdated(ev.detail as ComfyAPIStatus);
});
if (debugLayout) {
layoutState.subscribe(s => {
console.warn("UPDATESTATE", s)
})
}
await app.setup();
(window as any).app = app;
(window as any).appPane = uiPane;
refreshView();
app.api.addEventListener("status", (ev: CustomEvent) => {
queueState.statusUpdated(ev.detail as ComfyAPIStatus);
});
$: if (app.rootEl && !imageViewer) {
imageViewer = new ImageViewer(app.rootEl);
}
$: if (containerElem) {
let wrappers = containerElem.querySelectorAll<HTMLDivElement>(".pane-wrapper")
for (const wrapper of wrappers) {
const paneNode = wrapper.parentNode as HTMLElement; // get the node inside the <Pane/>
@@ -134,10 +126,22 @@
app.resizeCanvas()
}
}
}
onMount(async () => {
await app.setup();
(window as any).app = app;
(window as any).appPane = uiPane;
refreshView();
})
async function doRefreshCombos() {
await app.refreshComboInNodes()
}
</script>
<div id="main" bind:this={mainElem}>
<div id="main">
<div id="dropzone" class="dropzone"></div>
<div id="container" bind:this={containerElem}>
<Splitpanes theme="comfy" on:resize={refreshView}>
@@ -173,13 +177,29 @@
<Button variant="secondary" on:click={doSave}>
Save
</Button>
<Button variant="secondary" on:click={doReset}>
Reset
</Button>
<Button variant="secondary" on:click={doRecenter}>
Recenter
</Button>
<Button variant="secondary" on:click={doRefreshCombos}>
🔄
</Button>
<Checkbox label="Lock Nodes" bind:value={$uiState.nodesLocked}/>
<Checkbox label="Disable Interaction" bind:value={$uiState.graphLocked}/>
<Checkbox label="Enable UI Editing" bind:value={$uiState.unlocked}/>
<Checkbox label="Auto-Add UI" bind:value={$uiState.autoAddUI}/>
<label for="enable-ui-editing">Enable UI Editing</label>
<select id="enable-ui-editing" name="enable-ui-editing" bind:value={$uiState.uiEditMode}>
<option value="disabled">Disabled</option>
<option value="widgets">Widgets</option>
</select>
</div>
<LightboxModal />
</div>
<SvelteToast options={toastOptions} />
<style lang="scss">
#container {
height: calc(100vh - 60px);

View File

@@ -1,22 +1,31 @@
import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink } from "@litegraph-ts/core";
import { LiteGraph, LGraph, LGraphCanvas, LGraphNode, type LGraphNodeConstructor, type LGraphNodeExecutable, type SerializedLGraph, type SerializedLGraphGroup, type SerializedLGraphNode, type SerializedLLink, NodeMode, type Vector2, BuiltInSlotType } from "@litegraph-ts/core";
import type { LConnectionKind, INodeSlot } from "@litegraph-ts/core";
import ComfyAPI from "$lib/api"
import { ComfyWidgets } from "$lib/widgets"
import defaultGraph from "$lib/defaultGraph"
import { getPngMetadata, importA1111 } from "$lib/pnginfo";
import EventEmitter from "events";
import type TypedEmitter from "typed-emitter";
// Import nodes
import * as basic from "@litegraph-ts/nodes-basic"
import "@litegraph-ts/nodes-basic"
import "@litegraph-ts/nodes-events"
import "@litegraph-ts/nodes-math"
import * as nodes from "$lib/nodes/index"
import ComfyGraphCanvas from "$lib/ComfyGraphCanvas";
import ComfyGraphCanvas, { type SerializedGraphCanvasState } from "$lib/ComfyGraphCanvas";
import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import type { WidgetStateStore, WidgetUIState } from "$lib/stores/widgetState";
import * as widgets from "$lib/widgets/index"
import type ComfyWidget from "$lib/widgets/ComfyWidget";
import queueState from "$lib/stores/queueState";
import GraphSync from "$lib/GraphSync";
import type { SvelteComponentDev } from "svelte/internal";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import type { SerializedLayoutState } from "$lib/stores/layoutState";
import layoutState from "$lib/stores/layoutState";
import { toast } from '@zerodevx/svelte-toast'
import ComfyGraph from "$lib/ComfyGraph";
import { ComfyBackendNode } from "$lib/nodes/ComfyBackendNode";
import { get } from "svelte/store";
export const COMFYBOX_SERIAL_VERSION = 1;
LiteGraph.catch_exceptions = false;
@@ -27,33 +36,25 @@ if (typeof window !== "undefined") {
type QueueItem = { num: number, batchCount: number }
export type SerializedPanes = {
panels: { nodeId: number }[][]
}
export type SerializedAppState = {
createdBy: "ComfyBox",
version: number,
panes: SerializedPanes,
workflow: SerializedLGraph
workflow: SerializedLGraph,
layout: SerializedLayoutState,
canvas: SerializedGraphCanvasState
}
type ComfyAppEvents = {
configured: (graph: LGraph) => void
nodeAdded: (node: LGraphNode) => void
nodeRemoved: (node: LGraphNode) => void
nodeConnectionChanged: (kind: LConnectionKind, node: LGraphNode, slot: INodeSlot, targetNode: LGraphNode, targetSlot: INodeSlot) => void
cleared: () => void
beforeChange: (graph: LGraph, param: any) => void
afterChange: (graph: LGraph, param: any) => void
autosave: (graph: LGraph) => void
restored: (workflow: SerializedAppState) => void
/** [link origin, link index] | value */
export type SerializedPromptInput = [string, number] | any
export type SerializedPromptInputs = {
inputs: Record<string, SerializedPromptInput>,
class_type: string
}
interface ComfyGraphNodeExecutable extends LGraphNodeExecutable {
comfyClass: string
isVirtualNode?: boolean;
applyToGraph(workflow: SerializedLGraph<SerializedLGraphNode<LGraphNode>, SerializedLLink, SerializedLGraphGroup>): void;
export type SerializedPrompt = {
workflow: SerializedLGraph,
output: Record<string, SerializedPromptInputs>
}
export type Progress = {
@@ -66,12 +67,10 @@ export default class ComfyApp {
rootEl: HTMLDivElement | null = null;
canvasEl: HTMLCanvasElement | null = null;
canvasCtx: CanvasRenderingContext2D | null = null;
lGraph: LGraph | null = null;
lCanvas: LGraphCanvas | null = null;
lGraph: ComfyGraph | null = null;
lCanvas: ComfyGraphCanvas | null = null;
dropZone: HTMLElement | null = null;
nodeOutputs: Record<string, any> = {};
eventBus: TypedEmitter<ComfyAppEvents> = new EventEmitter() as TypedEmitter<ComfyAppEvents>;
graphSync: GraphSync;
dragOverNode: LGraphNode | null = null;
shiftDown: boolean = false;
@@ -79,30 +78,28 @@ export default class ComfyApp {
private queueItems: QueueItem[] = [];
private processingQueue: boolean = false;
private alreadySetup = false;
constructor() {
this.api = new ComfyAPI();
}
async setup(): Promise<void> {
if (this.alreadySetup) {
console.error("Already setup")
return;
}
this.rootEl = document.getElementById("main") as HTMLDivElement;
this.canvasEl = document.getElementById("graph-canvas") as HTMLCanvasElement;
this.lGraph = new LGraph();
this.lGraph = new ComfyGraph();
this.lCanvas = new ComfyGraphCanvas(this, this.canvasEl);
this.canvasCtx = this.canvasEl.getContext("2d");
this.graphSync = new GraphSync(this);
this.addGraphLifecycleHooks();
LiteGraph.release_link_on_empty_shows_menu = true;
LiteGraph.alt_drag_do_clone_nodes = true;
LiteGraph.ignore_all_widget_events = true;
this.lGraph.start();
// await this.#invokeExtensionsAsync("init");
this.registerNodeTypeOverrides();
this.registerWidgetTypeOverrides();
await this.registerNodes();
// Load previous workflow
@@ -110,9 +107,8 @@ export default class ComfyApp {
try {
const json = localStorage.getItem("workflow");
if (json) {
const workflow = JSON.parse(json) as SerializedAppState;
this.loadGraphData(workflow["workflow"]);
this.eventBus.emit("restored", workflow);
const state = JSON.parse(json) as SerializedAppState;
this.deserialize(state)
restored = true;
}
} catch (err) {
@@ -121,17 +117,18 @@ export default class ComfyApp {
// We failed to restore a workflow so load the default
if (!restored) {
this.loadGraphData();
this.initDefaultGraph();
}
// Save current workflow automatically
setInterval(this.requestAutosave.bind(this), 15000);
// setInterval(this.saveStateToLocalStorage.bind(this), 1000);
this.addApiUpdateHandlers();
this.addDropHandler();
this.addPasteHandler();
this.addKeyboardHandler();
this.setupColorScheme()
// await this.#invokeExtensionsAsync("setup");
@@ -139,6 +136,11 @@ export default class ComfyApp {
this.resizeCanvas();
window.addEventListener("resize", this.resizeCanvas.bind(this));
this.lGraph.start();
this.lGraph.eventBus.on("afterExecute", () => this.lCanvas.draw(true))
this.alreadySetup = true;
return Promise.resolve();
}
@@ -150,82 +152,20 @@ export default class ComfyApp {
this.lCanvas.draw(true, true);
}
private graphOnConfigure() {
console.debug("Configured");
this.eventBus.emit("configured", this.lGraph);
saveStateToLocalStorage() {
const savedWorkflow = this.serialize();
const json = JSON.stringify(savedWorkflow);
localStorage.setItem("workflow", json)
}
private graphOnBeforeChange(graph: LGraph, info: any) {
console.debug("BeforeChange", info);
this.eventBus.emit("beforeChange", graph, info);
}
private graphOnAfterChange(graph: LGraph, info: any) {
console.debug("AfterChange", info);
this.eventBus.emit("afterChange", graph, info);
}
private graphOnNodeAdded(node: LGraphNode) {
console.debug("Added", node);
this.eventBus.emit("nodeAdded", node);
}
private graphOnNodeRemoved(node: LGraphNode) {
console.debug("Removed", node);
this.eventBus.emit("nodeRemoved", node);
}
private graphOnNodeConnectionChange(kind: LConnectionKind, node: LGraphNode, slot: INodeSlot, targetNode: LGraphNode, targetSlot: INodeSlot) {
console.debug("ConnectionChange", node);
this.eventBus.emit("nodeConnectionChanged", kind, node, slot, targetNode, targetSlot);
}
private canvasOnClear() {
console.debug("CanvasClear");
this.eventBus.emit("cleared");
}
private requestAutosave() {
this.eventBus.emit("autosave", this.lGraph);
}
private addGraphLifecycleHooks() {
this.lGraph.onConfigure = this.graphOnConfigure.bind(this);
this.lGraph.onBeforeChange = this.graphOnBeforeChange.bind(this);
this.lGraph.onAfterChange = this.graphOnAfterChange.bind(this);
this.lGraph.onNodeAdded = this.graphOnNodeAdded.bind(this);
this.lGraph.onNodeRemoved = this.graphOnNodeRemoved.bind(this);
this.lGraph.onNodeConnectionChange = this.graphOnNodeConnectionChange.bind(this);
this.lCanvas.onClear = this.canvasOnClear.bind(this);
}
static node_type_overrides: Record<string, typeof ComfyGraphNode> = {}
static widget_type_overrides: Record<string, Function> = {}
private registerNodeTypeOverrides() {
ComfyApp.node_type_overrides["SaveImage"] = nodes.ComfySaveImageNode;
ComfyApp.node_type_overrides["PreviewImage"] = nodes.ComfyPreviewImageNode;
}
private registerWidgetTypeOverrides() {
ComfyApp.widget_type_overrides["comfy/gallery"] = widgets.ComfyGalleryWidget_Svelte;
}
static node_type_overrides: Record<string, typeof ComfyBackendNode> = {}
static widget_type_overrides: Record<string, typeof SvelteComponentDev> = {}
private async registerNodes() {
const app = this;
// Load node definitions from the backend
const defs = await this.api.getNodeDefs();
// await this.#invokeExtensionsAsync("addCustomNodeDefs", defs);
// Generate list of known widgets
const widgets = ComfyWidgets;
// const widgets = Object.assign(
// {},
// ComfyWidgets,
// ...(await this.#invokeExtensionsAsync("getCustomWidgets")).filter(Boolean)
// );
// Register a node for each definition
for (const nodeId in defs) {
@@ -234,56 +174,11 @@ export default class ComfyApp {
const typeOverride = ComfyApp.node_type_overrides[nodeId]
if (typeOverride)
console.debug("Attaching custom type to received node:", nodeId, typeOverride)
const baseClass: typeof LGraphNode = typeOverride || LGraphNode;
const baseClass: typeof ComfyBackendNode = typeOverride || ComfyBackendNode;
const ctor = class extends baseClass {
constructor(title?: string) {
super(title);
this.type = nodeId; // XXX: workaround dependency in LGraphNode.addInput()
(this as any).comfyClass = nodeId;
var inputs = nodeData["input"]["required"];
if (nodeData["input"]["optional"] != undefined) {
inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"])
}
const config = { minWidth: 1, minHeight: 1 };
for (const inputName in inputs) {
const inputData = inputs[inputName];
const type = inputData[0];
if (inputData[1]?.forceInput) {
this.addInput(inputName, type);
} else {
if (Array.isArray(type)) {
// Enums
Object.assign(config, widgets.COMBO(this, inputName, inputData, app) || {});
} else if (`${type}:${inputName}` in widgets) {
// Support custom widgets by Type:Name
Object.assign(config, widgets[`${type}:${inputName}`](this, inputName, inputData, app) || {});
} else if (type in widgets) {
// Standard type widgets
Object.assign(config, widgets[type](this, inputName, inputData, app) || {});
} else {
// Node connection inputs
this.addInput(inputName, type);
}
}
}
for (const o in nodeData["output"]) {
const output = nodeData["output"][o];
const outputName = nodeData["output_name"][o] || output;
this.addOutput(outputName, output);
}
const s = this.computeSize();
s[0] = Math.max(config.minWidth, s[0] * 1.5);
s[1] = Math.max(config.minHeight, s[1]);
this.size = s;
this.serialize_widgets = true;
// app.#invokeExtensionsAsync("nodeCreated", this);
return this;
super(title, nodeId, nodeData);
}
}
@@ -294,15 +189,9 @@ export default class ComfyApp {
desc: `ComfyNode: ${nodeId}`
}
// this.#addNodeContextMenuHandler(node);
// this.#addDrawBackgroundHandler(node, app);
// await this.#invokeExtensionsAsync("beforeRegisterNodeDef", node, nodeData);
LiteGraph.registerNodeType(node);
node.category = nodeData.category;
}
// await this.#invokeExtensionsAsync("registerCustomNodes");
}
private showDropZone() {
@@ -351,24 +240,24 @@ export default class ComfyApp {
* Adds a handler on paste that extracts and loads workflows from pasted JSON data
*/
private addPasteHandler() {
document.addEventListener("paste", (e) => {
let data = (e.clipboardData || (window as any).clipboardData).getData("text/plain");
let workflow;
try {
data = data.slice(data.indexOf("{"));
workflow = JSON.parse(data);
} catch (err) {
try {
data = data.slice(data.indexOf("workflow\n"));
data = data.slice(data.indexOf("{"));
workflow = JSON.parse(data);
} catch (error) { }
}
// document.addEventListener("paste", (e) => {
// let data = (e.clipboardData || (window as any).clipboardData).getData("text/plain");
// let workflow;
// try {
// data = data.slice(data.indexOf("{"));
// workflow = JSON.parse(data);
// } catch (err) {
// try {
// data = data.slice(data.indexOf("workflow\n"));
// data = data.slice(data.indexOf("{"));
// workflow = JSON.parse(data);
// } catch (error) { }
// }
if (workflow && workflow.version && workflow.nodes && workflow.extra) {
this.loadGraphData(workflow);
}
});
// if (workflow && workflow.version && workflow.nodes && workflow.extra) {
// this.loadGraphData(workflow);
// }
// });
}
/**
@@ -422,17 +311,72 @@ export default class ComfyApp {
});
}
private setupColorScheme() {
const setColor = (type: any, color: string) => {
this.lCanvas.link_type_colors[type] = color
this.lCanvas.default_connection_color_byType[type] = color
}
// Distinguish frontend/backend connections
const BACKEND_TYPES = ["CLIP", "CLIP_VISION", "CLIP_VISION_OUTPUT", "CONDITIONING", "CONTROL_NET", "IMAGE", "LATENT", "MASK", "MODEL", "STYLE_MODEL", "VAE"]
for (const type of BACKEND_TYPES) {
setColor(type, "orange")
}
setColor("OUTPUT", "rebeccapurple")
setColor(BuiltInSlotType.EVENT, "lightseagreen")
setColor(BuiltInSlotType.ACTION, "lightseagreen")
}
serialize(): SerializedAppState {
const graph = this.lGraph;
const serializedGraph = graph.serialize()
const serializedLayout = layoutState.serialize()
const serializedCanvas = this.lCanvas.serialize();
return {
createdBy: "ComfyBox",
version: COMFYBOX_SERIAL_VERSION,
workflow: serializedGraph,
layout: serializedLayout,
canvas: serializedCanvas
}
}
deserialize(data: SerializedAppState) {
if (data.version !== COMFYBOX_SERIAL_VERSION) {
throw `Invalid ComfyBox saved data format: ${data.version}`
}
// Ensure loadGraphData does not trigger any state changes in layoutState
// (isConfiguring is set to true here)
// lGraph.configure will add new nodes, triggering onNodeAdded, but we
// want to restore the layoutState ourselves
layoutState.onStartConfigure();
this.loadGraphData(data.workflow)
// Now restore the layout
// Subsequent added nodes will add the UI data to layoutState
layoutState.deserialize(data.layout, this.lGraph)
// Restore canvas offset/zoom
this.lCanvas.deserialize(data.canvas)
}
initDefaultGraph() {
const state = structuredClone(defaultGraph)
this.deserialize(state)
}
/**
* Populates the graph with the specified workflow data
* @param {*} graphData A serialized graph object
*/
loadGraphData(graphData?: SerializedLGraph) {
loadGraphData(graphData: SerializedLGraph) {
this.clean();
if (!graphData) {
graphData = structuredClone(defaultGraph.workflow)
}
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
for (let n of graphData.nodes) {
if (n.type == "T2IAdapterLoader") n.type = "ControlNetLoader";
@@ -445,84 +389,118 @@ export default class ComfyApp {
size[0] = Math.max(node.size[0], size[0]);
size[1] = Math.max(node.size[1], size[1]);
node.size = size;
if (node.widgets) {
// If you break something in the backend and want to patch workflows in the frontend
// This is the place to do this
for (let widget of node.widgets) {
if (node.type == "KSampler" || node.type == "KSamplerAdvanced") {
if (widget.name == "sampler_name") {
if (widget.value.constructor === String && widget.value.startsWith("sample_")) {
widget.value = widget.value.slice(7);
}
}
}
}
}
// this.#invokeExtensions("loadedGraphNode", node);
}
}
reset() {
this.clean();
const blankGraph: SerializedLGraph = {
last_node_id: 0,
last_link_id: 0,
nodes: [],
links: [],
groups: [],
config: {},
extra: {},
version: 0
}
layoutState.onStartConfigure();
this.lGraph.configure(blankGraph)
layoutState.initDefaultLayout();
}
/**
* Converts the current graph workflow for sending to the API
* @returns The workflow and node links
*/
async graphToPrompt() {
async graphToPrompt(): Promise<SerializedPrompt> {
// Run frontend-only logic
this.lGraph.runStep(1)
const workflow = this.lGraph.serialize();
const output = {};
// Process nodes in order of execution
for (const node of this.lGraph.computeExecutionOrder<ComfyGraphNodeExecutable>(false, null)) {
const n = workflow.nodes.find((n) => n.id === node.id);
for (const node_ of this.lGraph.computeExecutionOrder<ComfyGraphNode>(false, null)) {
const n = workflow.nodes.find((n) => n.id === node_.id);
if (node.isVirtualNode || !node.comfyClass) {
console.debug("Not serializing node: ", node.type)
// Don't serialize frontend only nodes but let them make changes
if (node.applyToGraph) {
node.applyToGraph(workflow);
}
if (!node_.isBackendNode) {
console.debug("Not serializing node: ", node_.type)
continue;
}
if (node.mode === 2) {
const node = node_ as ComfyBackendNode;
if (node.mode === NodeMode.NEVER) {
// Don't serialize muted nodes
continue;
}
const inputs = {};
const widgets = node.widgets;
// Store all widget values
if (widgets) {
for (let i = 0; i < widgets.length; i++) {
const widget = widgets[i];
let isVirtual = false;
if ("isVirtual" in widget)
isVirtual = (widget as ComfyWidget<any, any>).isVirtual;
if ((!widget.options || widget.options.serialize !== false) && !isVirtual) {
let value = widget.serializeValue ? await widget.serializeValue(n, i) : widget.value;
inputs[widget.name] = value
// Store all link values
if (node.inputs) {
for (let i = 0; i < node.inputs.length; i++) {
const inp = node.inputs[i];
const inputLink = node.getInputLink(i)
const inputNode = node.getInputNode(i)
if (!inputLink || !inputNode) {
if ("config" in inp) {
const defaultValue = (inp as IComfyInputSlot).config?.defaultValue
if (defaultValue !== null && defaultValue !== undefined)
inputs[inp.name] = defaultValue
}
continue;
}
let serialize = true;
if ("config" in inp)
serialize = (inp as IComfyInputSlot).serialize
let isBackendNode = node.isBackendNode;
let isInputBackendNode = false;
if ("isBackendNode" in inputNode)
isInputBackendNode = (inputNode as ComfyGraphNode).isBackendNode;
// The reasoning behind this check:
// We only want to serialize inputs to nodes with backend equivalents.
// And in ComfyBox, the nodes in litegraph *never* have widgets, instead they're all inputs.
// All values are passed by separate frontend-only nodes,
// either UI-bound or something like ConstantInteger.
// So we know that any value passed into a backend node *must* come from
// a frontend node.
// The rest (links between backend nodes) will be serialized after this bit runs.
if (serialize && isBackendNode && !isInputBackendNode) {
inputs[inp.name] = inputLink.data
}
}
}
// Store all node links
// Store all links between nodes
for (let i = 0; i < node.inputs.length; i++) {
let parent: ComfyGraphNodeExecutable = node.getInputNode(i) as ComfyGraphNodeExecutable;
let parent: ComfyGraphNode = node.getInputNode(i) as ComfyGraphNode;
if (parent) {
const seen = {}
let link = node.getInputLink(i);
while (parent && parent.isVirtualNode) {
while (parent && !parent.isBackendNode) {
link = parent.getInputLink(link.origin_slot);
if (link) {
parent = parent.getInputNode(link.origin_slot) as ComfyGraphNodeExecutable;
if (link && !seen[link.id]) {
seen[link.id] = true
parent = parent.getInputNode(link.origin_slot) as ComfyGraphNode;
} else {
parent = null;
}
}
if (link) {
inputs[node.inputs[i].name] = [String(link.origin_id), link.origin_slot];
if (link && parent && parent.isBackendNode) {
const input = node.inputs[i]
// TODO can null be a legitimate value in some cases?
// Nodes like CLIPLoader will never have a value in the frontend, hence "null".
if (!(input.name in inputs))
inputs[input.name] = [String(link.origin_id), link.origin_slot];
}
}
}
@@ -569,20 +547,20 @@ export default class ComfyApp {
await this.api.queuePrompt(num, p);
} catch (error) {
// this.ui.dialog.show(error.response || error.toString());
console.error(error.response || error.toString())
const mes = error.response || error.toString()
toast.push(`Error queuing prompt:\n${mes}`, {
theme: {
'--toastBackground': 'var(--color-red-500)',
}
})
console.error("Error queuing prompt", mes, num, p)
break;
}
for (const n of p.workflow.nodes) {
const node = this.lGraph.getNodeById(n.id);
if (node.widgets) {
for (const widget of node.widgets) {
// Allow widgets to run callbacks after a prompt has been queued
// e.g. random seed after every gen
if ("afterQueued" in widget) {
(widget as ComfyWidget<any, any>).afterQueued();
}
}
if ("afterQueued" in node) {
(node as ComfyGraphNode).afterQueued(p);
}
}
@@ -639,14 +617,26 @@ export default class ComfyApp {
const def = defs[node.type];
for (const widgetNum in node.widgets) {
const widget = node.widgets[widgetNum]
for (let index = 0; index < node.inputs.length; index++) {
const input = node.inputs[index];
if ("config" in input) {
const comfyInput = input as IComfyInputSlot;
if (widget.type == "combo" && def["input"]["required"][widget.name] !== undefined) {
widget.options.values = def["input"]["required"][widget.name][0];
console.warn("RefreshCombo", comfyInput.defaultWidgetNode, comfyInput)
if (!widget.options.values.includes(widget.value)) {
widget.value = widget.options.values[0];
if (comfyInput.defaultWidgetNode == nodes.ComfyComboNode && def["input"]["required"][comfyInput.name] !== undefined) {
comfyInput.config.values = def["input"]["required"][comfyInput.name][0];
console.warn("RefreshCombo", comfyInput.config.values, def["input"]["required"][comfyInput.name])
const inputNode = node.getInputNode(index)
if (inputNode && "doAutoConfig" in inputNode) {
const comfyInputNode = inputNode as nodes.ComfyWidgetNode;
comfyInputNode.doAutoConfig(comfyInput)
if (!comfyInput.config.values.includes(get(comfyInputNode.value))) {
comfyInputNode.setValue(comfyInput.config.defaultValue || comfyInput.config.values[0])
}
}
}
}
}

View File

@@ -1,178 +0,0 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { get } from "svelte/store"
import { Block, BlockTitle } from "@gradio/atoms";
import { Move } from 'radix-icons-svelte';
import widgetState, { type WidgetDrawState, type WidgetUIState } from "$lib/stores/widgetState";
import queueState from "$lib/stores/queueState";
import nodeState from "$lib/stores/nodeState";
import uiState from "$lib/stores/uiState";
import { dndzone, SHADOW_ITEM_MARKER_PROPERTY_NAME } from 'svelte-dnd-action';
import {fade} from 'svelte/transition';
// notice - fade in works fine but don't add svelte's fade-out (known issue)
import {cubicIn} from 'svelte/easing';
import { flip } from 'svelte/animate';
import ComfyApp from "./ComfyApp";
import type { LGraphNode } from "@litegraph-ts/core";
import type { DragItem } from "./ComfyUIPane";
import { getComponentForWidgetState } from "$lib/utils"
export let dragItems: DragItem[] = [];
let dragDisabled = true;
let unlockUI = false;
const flipDurationMs = 200;
$: dragDisabled = !$uiState.unlocked;
const handleConsider = evt => {
dragItems = evt.detail.items;
// console.log(dragItems);
};
const handleFinalize = evt => {
dragItems = evt.detail.items;
// Ensure dragging is stopped on drag finish
dragDisabled = true;
};
const startDrag = () => {
if (!$uiState.unlocked)
return
dragDisabled = false;
};
const stopDrag = () => {
if (!$uiState.unlocked)
return
dragDisabled = true;
};
const unsubscribe = widgetState.subscribe(state => {
dragItems = dragItems.filter(item => item.node.id in state);
});
onDestroy(unsubscribe);
$: if ($queueState) {
for (let dragItem of dragItems) {
dragItem.isNodeExecuting = $queueState.runningNodeId === dragItem.node.id;
}
dragItems = dragItems;
}
function updateNodeName(node: LGraphNode, value: string) {
nodeState.nodeStateChanged(node);
}
</script>
<div class="v-pane"
use:dndzone="{{ items: dragItems, dragDisabled, flipDurationMs }}"
on:consider="{handleConsider}"
on:finalize="{handleFinalize}"
>
{#each dragItems as dragItem(dragItem.id)}
{@const node = dragItem.node}
{@const id = node.id}
<div class="animation-wrapper" class:is-executing={dragItem.isNodeExecuting} animate:flip={{duration:flipDurationMs}}>
<Block>
<label for={String(id)} class={$uiState.unlocked ? "edit-title-label" : ""}>
<BlockTitle>
{#if $uiState.unlocked}
<input class="edit-title" bind:value={dragItem.node.title} type="text" minlength="1" on:input="{(v) => { updateNodeName(node, v) }}"/>
{:else}
{node.title}
{/if}
{#if node.title !== node.type}
<span class="node-type">({node.type})</span>
{/if}
</BlockTitle>
</label>
{#each $widgetState[id] as item}
<svelte:component this={getComponentForWidgetState(item)} {item} />
{#if dragItem[SHADOW_ITEM_MARKER_PROPERTY_NAME]}
<div in:fade={{duration:200, easing: cubicIn}} class='drag-item-shadow'/>
{/if}
{/each}
{#if $uiState.unlocked}
<div class="handle" on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
{/if}
</Block>
</div>
{/each}
</div>
<style>
.v-pane {
border: 1px solid grey;
float: left;
height: 100%;
overflow: auto;
position: relative;
width: 33%;
}
.is-executing :global(.block) {
border: 5px dashed var(--color-green-600) !important;
}
.handle {
cursor: grab;
z-index: 99999;
position: absolute;
right: 0;
top: 0;
width: 100%;
height: 100%;
}
.handle:hover {
background-color: #add8e680;
}
.drag-item-shadow {
position: absolute;
top: 0; left:0; right: 0; bottom: 0;
visibility: visible;
border: 1px dashed grey;
background: lightblue;
opacity: 0.5;
margin: 0;
}
.node-type {
font-size: smaller;
color: var(--neutral-400);
}
.edit-title-label {
position: relative;
z-index: 100000;
}
.edit-title {
z-index: 100000;
display: block;
position: relative;
outline: none !important;
box-shadow: var(--input-shadow);
border: var(--input-border-width) solid var(--input-border-color);
border-radius: var(--input-radius);
background: var(--input-background-fill);
padding: var(--input-padding);
width: 100%;
color: var(--body-text-color);
font-weight: var(--input-text-weight);
font-size: var(--input-text-size);
line-height: var(--line-sm);
}
.edit-title:focus {
box-shadow: var(--input-shadow-focus);
border-color: var(--input-border-color-focus);
}
.edit-title::placeholder {
color: var(--input-placeholder-color);
}
</style>

View File

@@ -1,104 +1,118 @@
<script lang="ts">
import { tick } from 'svelte'
import { get } from "svelte/store";
import { LGraphNode, LGraph } from "@litegraph-ts/core";
import type { IWidget } from "@litegraph-ts/core";
import ComfyApp from "./ComfyApp";
import type { SerializedPanes } from "./ComfyApp"
import ComfyPane from "./ComfyPane.svelte";
import widgetState from "$lib/stores/widgetState";
import type { DragItem } from "./ComfyUIPane";
import WidgetContainer from "./WidgetContainer.svelte";
import layoutState, { type ContainerLayout, type DragItem, type IDragItem } from "$lib/stores/layoutState";
import uiState from "$lib/stores/uiState";
import Menu from './menu/Menu.svelte';
import MenuOption from './menu/MenuOption.svelte';
import MenuDivider from './menu/MenuDivider.svelte';
import Icon from './menu/Icon.svelte'
export let app: ComfyApp;
let root: IDragItem | null;
let dragConfigured: boolean = false;
export let dragItems: DragItem[][] = []
export let totalId = 0;
function findLeastPopulatedPaneIndex(): number {
let minWidgetCount = 2 ** 64;
let minIndex = 0;
let state = get(widgetState);
for (let i = 0; i < dragItems.length; i++) {
let widgetCount = 0;
for (let j = 0; j < dragItems[i].length; j++) {
const nodeID = dragItems[i][j].node.id;
widgetCount += state[nodeID].length;
}
if (widgetCount < minWidgetCount) {
minWidgetCount = widgetCount
minIndex = i;
}
}
return minIndex
}
function addUIForNewNode(node: LGraphNode, paneIndex?: number) {
if (!paneIndex)
paneIndex = findLeastPopulatedPaneIndex();
dragItems[paneIndex].push({ id: totalId++, node: node });
}
$: if(app && !dragConfigured) {
dragConfigured = true;
app.eventBus.on("nodeAdded", addUIForNewNode);
}
/*
* Serialize UI panel order so it can be restored when workflow is loaded
*/
export function serialize(): any {
let panels = []
for (let i = 0; i < dragItems.length; i++) {
panels[i] = [];
for (let j = 0; j < dragItems[i].length; j++) {
panels[i].push({ nodeId: dragItems[i][j].node.id });
}
}
return {
panels
}
// TODO
}
export function restore(panels: SerializedPanes) {
let nodeIdToDragItem: Record<number, DragItem> = {};
for (let i = 0; i < dragItems.length; i++) {
for (const dragItem of dragItems[i]) {
nodeIdToDragItem[dragItem.node.id] = dragItem
}
// TODO
}
function groupWidgets(horizontal: boolean) {
const items = layoutState.getCurrentSelection()
$layoutState.currentSelection = []
layoutState.groupItems(items, { direction: horizontal ? "horizontal" : "vertical" })
}
let canUngroup = false;
let isDeleteGroup = false;
$: canUngroup = $layoutState.currentSelection.length === 1
&& layoutState.getCurrentSelection()[0].type === "container"
$: if (canUngroup) {
const dragItem = layoutState.getCurrentSelection()[0];
const entry = $layoutState.allItems[dragItem.id];
isDeleteGroup = entry.children.length === 0
}
else {
isDeleteGroup = false
}
function ungroup() {
const item = layoutState.getCurrentSelection()[0]
if (item.type !== "container")
return;
$layoutState.currentSelection = []
layoutState.ungroup(item as ContainerLayout)
}
let menuPos = { x: 0, y: 0 };
let showMenu = false;
$: $layoutState.isMenuOpen = showMenu;
$: if ($layoutState.root) {
root = $layoutState.root
} else {
root = null;
}
async function onRightClick(e) {
if ($uiState.uiEditMode === "disabled")
return;
e.preventDefault();
if (showMenu) {
showMenu = false;
await new Promise(res => setTimeout(res, 100));
}
for (let i = 0; i < panels.panels.length; i++) {
dragItems[i].length = 0;
for (const panel of panels.panels[i]) {
const dragItem = nodeIdToDragItem[panel.nodeId];
if (dragItem) {
delete nodeIdToDragItem[panel.nodeId];
dragItems[i].push(dragItem)
}
}
}
menuPos = { x: e.clientX, y: e.clientY };
showMenu = true;
}
// Put everything left over into other columns
if (Object.keys(nodeIdToDragItem).length > 0) {
console.warn("Extra panels without ordering found", nodeIdToDragItem, panels)
for (const nodeId in nodeIdToDragItem) {
const dragItem = nodeIdToDragItem[nodeId];
const paneIndex = findLeastPopulatedPaneIndex();
dragItems[paneIndex].push(dragItem);
}
}
function closeMenu() {
showMenu = false;
}
</script>
<div id="comfy-ui-panes" >
<ComfyPane bind:dragItems={dragItems[0]} />
<ComfyPane bind:dragItems={dragItems[1]} />
<ComfyPane bind:dragItems={dragItems[2]} />
<div id="comfy-ui-panes" on:contextmenu={onRightClick}>
<WidgetContainer bind:dragItem={root} classes={["root-container"]} />
</div>
<style>
{#if showMenu}
<Menu {...menuPos} on:click={closeMenu} on:clickoutside={closeMenu}>
<MenuOption
isDisabled={$layoutState.currentSelection.length === 0}
on:click={() => groupWidgets(false)}
text="Group" />
<MenuOption
isDisabled={$layoutState.currentSelection.length === 0}
on:click={() => groupWidgets(true)}
text="Group Horizontally" />
<MenuOption
isDisabled={!canUngroup}
on:click={ungroup}
text={isDeleteGroup ? "Delete Group" : "Ungroup"} />
</Menu>
{/if}
<style lang="scss">
#comfy-ui-panes {
width: 100%;
height: 100%;
overflow: auto;
}
</style>

View File

@@ -1,7 +0,0 @@
import type { LGraphNode } from "@litegraph-ts/core"
export type DragItem = {
id: number,
node: LGraphNode,
isNodeExecuting?: boolean
}

View File

@@ -0,0 +1,93 @@
<script lang="ts">
import queueState from "$lib/stores/queueState";
import uiState from "$lib/stores/uiState";
import layoutState, { type ContainerLayout, type WidgetLayout, type IDragItem } from "$lib/stores/layoutState";
import { startDrag, stopDrag } from "$lib/utils"
import BlockContainer from "./BlockContainer.svelte"
export let dragItem: IDragItem | null = null;
export let zIndex: number = 0;
export let classes: string[] = [];
let container: ContainerLayout | null = null;
let widget: WidgetLayout | null = null;
let showHandles: boolean = false;
$: if (!dragItem || !$layoutState.allItems[dragItem.id]) {
dragItem = null;
container = null;
widget = null;
}
else if (dragItem.type === "container") {
container = dragItem as ContainerLayout;
widget = null;
}
else if (dragItem.type === "widget") {
widget = dragItem as WidgetLayout;
container = null;
}
$: showHandles = $uiState.uiEditMode === "widgets" // TODO
&& zIndex > 1
&& !$layoutState.isMenuOpen
$: if ($queueState && widget && widget.node) {
dragItem.isNodeExecuting = $queueState.runningNodeId === widget.node.id;
}
</script>
{#if container}
<BlockContainer {container} {classes} {zIndex} {showHandles} />
{:else if widget && widget.node}
<div class="widget" class:widget-edit-outline={$uiState.uiEditMode === "widgets" && zIndex > 1}
class:selected={$uiState.uiEditMode !== "disabled" && $layoutState.currentSelection.includes(widget.id)}
class:is-executing={$queueState.runningNodeId && $queueState.runningNodeId == widget.node.id}
>
<svelte:component this={widget.node.svelteComponentType} {widget} />
</div>
{#if showHandles}
<div class="handle handle-widget" style="z-index: {zIndex+100}" data-drag-item-id={widget.id} on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
{/if}
{/if}
<style lang="scss">
.widget.selected {
background: var(--color-yellow-200);
}
.container.selected {
background: var(--color-yellow-400);
}
.is-executing {
border: 3px dashed var(--color-green-600) !important;
margin: 0.2em;
padding: 0.2em;
}
.handle {
cursor: grab;
z-index: 99999;
position: absolute;
right: 0;
top: 0;
width: 100%;
height: 100%;
}
.handle-widget:hover {
background-color: #add8e680;
}
.node-type {
font-size: smaller;
color: var(--neutral-400);
}
.widget-edit-outline {
border: 2px dashed var(--color-blue-400);
margin: 0.2em;
padding: 0.2em;
}
</style>

View File

@@ -0,0 +1,47 @@
<script>
import Menu from './Menu.svelte';
import MenuOption from './MenuOption.svelte';
import MenuDivider from './MenuDivider.svelte';
import { tick } from 'svelte'
import Icon from './Icon.svelte'
let pos = { x: 0, y: 0 };
let showMenu = false;
async function onRightClick(e) {
if (showMenu) {
showMenu = false;
await new Promise(res => setTimeout(res, 100));
}
pos = { x: e.clientX, y: e.clientY };
showMenu = true;
}
function closeMenu() {
showMenu = false;
}
</script>
{#if showMenu}
<Menu {...pos} on:click={closeMenu} on:clickoutside={closeMenu}>
<MenuOption
on:click={console.log}
text="Do nothing" />
<MenuOption
on:click={console.log}
text="Do nothing, but twice" />
<MenuDivider />
<MenuOption
isDisabled={true}
on:click={console.log}
text="Whoops, disabled!" />
<MenuOption on:click={console.log}>
<Icon />
<span>Look! An icon!</span>
</MenuOption>
</Menu>
{/if}
<svelte:body on:contextmenu|preventDefault={onRightClick} />

View File

@@ -0,0 +1,3 @@
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path opacity="0.5" fill-rule="evenodd" clip-rule="evenodd" d="M11.2584 13.039C10.3201 13.647 9.2013 14 8 14C4.68629 14 2 11.3137 2 8C2 6.66837 2.4338 5.43806 3.1678 4.44268L11.2584 13.039ZM12.7332 11.6878L4.60537 3.05194C5.57075 2.38838 6.74002 2 8 2C11.3137 2 14 4.68629 14 8C14 9.39043 13.527 10.6704 12.7332 11.6878ZM16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 548 B

View File

@@ -0,0 +1,45 @@
<script>
import { setContext, createEventDispatcher } from 'svelte';
import { fade } from 'svelte/transition';
import { key } from './menu.ts';
export let x;
export let y;
// whenever x and y is changed, restrict box to be within bounds
$: (() => {
if (!menuEl) return;
const rect = menuEl.getBoundingClientRect();
x = Math.min(window.innerWidth - rect.width, x);
if (y > window.innerHeight - rect.height) y -= rect.height;
})(x, y);
const dispatch = createEventDispatcher();
setContext(key, {
dispatchClick: () => dispatch('click')
});
let menuEl;
function onPageClick(e) {
if (e.target === menuEl || menuEl.contains(e.target)) return;
dispatch('clickoutside');
}
</script>
<svelte:body on:click={onPageClick} />
<div class="menu" bind:this={menuEl} style="top: {y}px; left: {x}px;">
<slot />
</div>
<style>
.menu {
z-index: var(--layer-top);
position: absolute;
display: grid;
border: 1px solid #0003;
box-shadow: 2px 2px 5px 0px #0002;
background: white;
}
</style>

View File

@@ -0,0 +1,9 @@
<hr />
<style>
hr {
border-top: 1px solid #0003;
width: 100%;
margin: 2px 0;
}
</style>

View File

@@ -0,0 +1,50 @@
<script>
import { onMount, getContext } from 'svelte';
import { key } from './menu.ts';
export let isDisabled = false;
export let text = '';
import { createEventDispatcher } from 'svelte';
const dispatch = createEventDispatcher();
const { dispatchClick } = getContext(key);
const handleClick = e => {
if (isDisabled) return;
dispatch('click');
dispatchClick();
}
</script>
<div
class:disabled={isDisabled}
on:click={handleClick}
>
{#if text}
{text}
{:else}
<slot />
{/if}
</div>
<style>
div {
padding: 4px 15px;
cursor: default;
font-size: 14px;
display: flex;
align-items: center;
grid-gap: 5px;
}
div:hover {
background: #0002;
}
div.disabled {
color: #0006;
}
div.disabled:hover {
background: white;
}
</style>

View File

@@ -0,0 +1,3 @@
const key = {};
export { key };

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,132 @@
import { LiteGraph, type ContextMenuItem, type LGraphNode, type Vector2, LConnectionKind, LLink, LGraphCanvas, type SlotType, TitleMode, type SlotLayout, BuiltInSlotType, type ITextWidget, type SerializedLGraphNode } from "@litegraph-ts/core";
import ComfyGraphNode from "./ComfyGraphNode";
import { Watch } from "@litegraph-ts/nodes-basic";
import type { SerializedPrompt } from "$lib/components/ComfyApp";
export interface ComfyAfterQueuedAction extends Record<any, any> {
prompt: SerializedPrompt
}
export class ComfyAfterQueuedAction extends ComfyGraphNode {
override properties: ComfyCopyActionProperties = {
prompt: null
}
static slotLayout: SlotLayout = {
outputs: [
{ name: "afterQueued", type: BuiltInSlotType.EVENT },
{ name: "prompt", type: "*" }
],
}
override onPropertyChanged(property: string, value: any, prevValue?: any) {
if (property === "value") {
this.setOutputData(0, this.properties.prompt)
}
}
override onExecute() {
this.setOutputData(0, this.properties.prompt)
}
override afterQueued(p: SerializedPrompt) {
this.setProperty("value", p)
this.triggerSlot(0, "bang")
}
override onSerialize(o: SerializedLGraphNode) {
super.onSerialize(o)
o.properties = { prompt: null }
}
}
LiteGraph.registerNodeType({
class: ComfyAfterQueuedAction,
title: "Comfy.AfterQueuedAction",
desc: "Triggers a 'bang' event when a prompt is queued.",
type: "actions/after_queued"
})
export interface ComfyCopyActionProperties extends Record<any, any> {
value: any
}
export class ComfyCopyAction extends ComfyGraphNode {
override properties: ComfyCopyActionProperties = {
value: null
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "in", type: "*" },
{ name: "copy", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "out", type: "*" }
],
}
displayWidget: ITextWidget;
constructor(title?: string) {
super(title);
this.displayWidget = this.addWidget<ITextWidget>(
"text",
"Value",
"",
"value"
);
this.displayWidget.disabled = true;
}
override onExecute() {
this.setProperty("value", this.getInputData(0))
}
override onAction(action: any, param: any) {
this.setProperty("value", this.getInputData(0))
this.setOutputData(0, this.properties.value)
console.log("setData", this.properties.value)
};
}
LiteGraph.registerNodeType({
class: ComfyCopyAction,
title: "Comfy.CopyAction",
desc: "Copies its input to its output when an event is received",
type: "actions/copy"
})
export interface ComfySwapActionProperties extends Record<any, any> {
}
export class ComfySwapAction extends ComfyGraphNode {
override properties: ComfySwapActionProperties = {
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "A", type: "*" },
{ name: "B", type: "*" },
{ name: "swap", type: BuiltInSlotType.ACTION }
],
outputs: [
{ name: "B", type: "*" },
{ name: "A", type: "*" }
],
}
override onAction(action: any, param: any) {
const a = this.getInputData(0)
const b = this.getInputData(1)
this.setOutputData(0, a)
this.setOutputData(1, b)
};
}
LiteGraph.registerNodeType({
class: ComfySwapAction,
title: "Comfy.SwapAction",
desc: "Swaps two inputs when triggered",
type: "actions/swap"
})

View File

@@ -0,0 +1,92 @@
import LGraphCanvas from "@litegraph-ts/core/src/LGraphCanvas";
import ComfyGraphNode from "./ComfyGraphNode";
import ComfyWidgets from "$lib/widgets"
import type { ComfyWidgetNode } from "./ComfyWidgetNodes";
/*
* Base class for any node with configuration sent by the backend.
*/
export class ComfyBackendNode extends ComfyGraphNode {
comfyClass: string;
constructor(title: string, comfyClass: string, nodeData: any) {
super(title)
this.type = comfyClass; // XXX: workaround dependency in LGraphNode.addInput()
this.comfyClass = comfyClass;
this.isBackendNode = true;
const color = LGraphCanvas.node_colors["yellow"];
this.color = color.color
this.bgColor = color.bgColor
this.setup(nodeData)
// ComfyUI has no obvious way to identify if a node will return outputs back to the frontend based on its properties.
// It just returns a hash like { "ui": { "images": results } } internally.
// So this will need to be hardcoded for now.
if (["PreviewImage", "SaveImage"].indexOf(comfyClass) !== -1) {
this.addOutput("output", "OUTPUT");
}
}
private setup(nodeData: any) {
var inputs = nodeData["input"]["required"];
if (nodeData["input"]["optional"] != undefined) {
inputs = Object.assign({}, nodeData["input"]["required"], nodeData["input"]["optional"])
}
const config = { minWidth: 1, minHeight: 1 };
for (const inputName in inputs) {
const inputData = inputs[inputName];
const type = inputData[0];
if (inputData[1]?.forceInput) {
this.addInput(inputName, type);
} else {
if (Array.isArray(type)) {
// Enums
Object.assign(config, ComfyWidgets.COMBO(this, inputName, inputData) || {});
} else if (`${type}:${inputName}` in ComfyWidgets) {
// Support custom ComfyWidgets by Type:Name
Object.assign(config, ComfyWidgets[`${type}:${inputName}`](this, inputName, inputData) || {});
} else if (type in ComfyWidgets) {
// Standard type ComfyWidgets
Object.assign(config, ComfyWidgets[type](this, inputName, inputData) || {});
} else {
// Node connection inputs (backend)
this.addInput(inputName, type);
}
}
}
for (const o in nodeData["output"]) {
const output = nodeData["output"][o];
const outputName = nodeData["output_name"][o] || output;
this.addOutput(outputName, output);
}
const s = this.computeSize();
s[0] = Math.max(config.minWidth, s[0] * 1.5);
s[1] = Math.max(config.minHeight, s[1]);
this.size = s;
this.serialize_widgets = false;
// app.#invokeExtensionsAsync("nodeCreated", this);
}
override onExecuted(outputData: any) {
console.warn("onExecuted outputs", outputData)
for (let index = 0; index < this.outputs.length; index++) {
const output = this.outputs[index]
if (output.type === "OUTPUT") {
this.setOutputData(index, outputData)
for (const node of this.getOutputNodes(index)) {
if ("receiveOutput" in node) {
const widgetNode = node as ComfyWidgetNode;
widgetNode.receiveOutput();
}
}
}
}
}
}

View File

@@ -1,9 +1,56 @@
import type { ComfyInputConfig } from "$lib/IComfyInputSlot";
import type { SerializedPrompt } from "$lib/components/ComfyApp";
import type ComfyWidget from "$lib/components/widgets/ComfyWidget";
import { LGraphNode } from "@litegraph-ts/core";
import { LGraph, LGraphNode, LiteGraph, type SerializedLGraphNode } from "@litegraph-ts/core";
import type { SvelteComponentDev } from "svelte/internal";
import type { ComfyWidgetNode } from "./ComfyWidgetNodes";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
export type DefaultWidgetSpec = {
defaultWidgetNode: new (name?: string) => ComfyWidgetNode,
config?: ComfyInputConfig
}
export type DefaultWidgetLayout = {
inputs?: Record<number, DefaultWidgetSpec>,
}
export default class ComfyGraphNode extends LGraphNode {
isVirtualNode: boolean = false;
isBackendNode?: boolean;
afterQueued?(): void;
afterQueued?(prompt: SerializedPrompt): void;
onExecuted?(output: any): void;
defaultWidgets?: DefaultWidgetLayout
override onSerialize(o: SerializedLGraphNode) {
for (let index = 0; index < this.inputs.length; index++) {
const input = this.inputs[index]
const serInput = o.inputs[index]
if ("defaultWidgetNode" in input) {
const comfyInput = input as IComfyInputSlot
const widgetNode = comfyInput.defaultWidgetNode
const ty = Object.values(LiteGraph.registered_node_types)
.find(v => v.class === widgetNode)
if (ty)
(serInput as any).widgetNodeType = ty.type;
(serInput as any).defaultWidgetNode = null
}
}
}
override onConfigure(o: SerializedLGraphNode) {
for (let index = 0; index < this.inputs.length; index++) {
const input = this.inputs[index]
const serInput = o.inputs[index]
if ("widgetNodeType" in serInput) {
const comfyInput = input as IComfyInputSlot
const ty: string = serInput.widgetNodeType as any
const widgetNode = Object.values(LiteGraph.registered_node_types)
.find(v => v.type === ty)
if (widgetNode)
comfyInput.defaultWidgetNode = widgetNode.class as any
}
}
}
}

View File

@@ -1,43 +0,0 @@
import ComfyGalleryWidget, { type ComfyGalleryEntry } from "$lib/widgets/ComfyGalleryWidget";
import ComfyGraphNode from "./ComfyGraphNode";
export type ComfyImageResult = {
filename: string,
subfolder: string,
type: "output" | "temp"
}
export type ComfyImageExecOutput = {
images: ComfyImageResult[]
}
/*
* Node with a single extra image output widget
*/
class ComfyImageNode extends ComfyGraphNode {
private _imageResults: Array<ComfyImageResult> = [];
private _galleryWidget: ComfyGalleryWidget;
constructor(title?: any) {
super(title)
this._galleryWidget = new ComfyGalleryWidget("Images", [], this);
this.addCustomWidget(this._galleryWidget);
}
override onExecuted(output: ComfyImageExecOutput) {
this._imageResults = Array.from(output.images); // TODO append?
const galleryItems = this._imageResults.map(r => {
// TODO
const url = "http://localhost:8188/view?"
const params = new URLSearchParams(r)
let entry: ComfyGalleryEntry = [url + params, null]
return entry
});
this._galleryWidget.addImages(galleryItems);
}
}
export class ComfySaveImageNode extends ComfyImageNode {
}
export class ComfyPreviewImageNode extends ComfyImageNode {
}

View File

@@ -18,9 +18,6 @@ export default class ComfyReroute extends ComfyGraphNode {
}
}
// This node is purely frontend and does not impact the resulting prompt so should not be serialized
override isVirtualNode: boolean = true;
override titleMode: TitleMode = TitleMode.NO_TITLE;
override collapsable: boolean = false;

View File

@@ -0,0 +1,120 @@
import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import ComfyGraphNode from "./ComfyGraphNode";
export interface ComfySelectorProperties extends Record<any, any> {
value: any
}
export default class ComfySelector extends ComfyGraphNode {
override properties: ComfySelectorProperties = {
value: null
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "select", type: "number" },
{ name: "A", type: "*" },
{ name: "B", type: "*" },
{ name: "C", type: "*" },
{ name: "D", type: "*" },
],
outputs: [
{ name: "out", type: "*" }
],
}
private selected: number = 0;
constructor(title?: string) {
super(title);
}
override onDrawBackground(ctx: CanvasRenderingContext2D) {
if (this.flags.collapsed) {
return;
}
ctx.fillStyle = "#AFB";
var y = (this.selected + 1) * LiteGraph.NODE_SLOT_HEIGHT + 6;
ctx.beginPath();
ctx.moveTo(50, y);
ctx.lineTo(50, y + LiteGraph.NODE_SLOT_HEIGHT);
ctx.lineTo(34, y + LiteGraph.NODE_SLOT_HEIGHT * 0.5);
ctx.fill();
};
override onExecute() {
var sel = this.getInputData(0);
if (sel == null || sel.constructor !== Number)
sel = 0;
this.selected = sel = Math.round(sel) % (this.inputs.length - 1);
var v = this.getInputData(sel + 1);
if (v !== undefined) {
this.setOutputData(0, v);
}
}
}
LiteGraph.registerNodeType({
class: ComfySelector,
title: "Comfy.Selector",
desc: "Selects an output from two or more inputs",
type: "utils/selector"
})
export interface ComfySelectorTwoProperties extends Record<any, any> {
value: any
}
export class ComfySelectorTwo extends ComfyGraphNode {
override properties: ComfySelectorTwoProperties = {
value: null
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "select", type: "boolean" },
{ name: "true", type: "*" },
{ name: "false", type: "*" },
],
outputs: [
{ name: "out", type: "*" }
],
}
private selected: number = 0;
constructor(title?: string) {
super(title);
}
override onDrawBackground(ctx: CanvasRenderingContext2D) {
if (this.flags.collapsed) {
return;
}
ctx.fillStyle = "#AFB";
var y = (this.selected + 1) * LiteGraph.NODE_SLOT_HEIGHT + 6;
ctx.beginPath();
ctx.moveTo(50, y);
ctx.lineTo(50, y + LiteGraph.NODE_SLOT_HEIGHT);
ctx.lineTo(34, y + LiteGraph.NODE_SLOT_HEIGHT * 0.5);
ctx.fill();
};
override onExecute() {
var sel = this.getInputData(0);
if (sel == null || sel.constructor !== Boolean)
sel = 0;
this.selected = sel ? 0 : 1;
var v = this.getInputData(this.selected + 1);
if (v !== undefined) {
this.setOutputData(0, v);
}
}
}
LiteGraph.registerNodeType({
class: ComfySelectorTwo,
title: "Comfy.Selector2",
desc: "Selects an output from two inputs with a boolean",
type: "utils/selector2"
})

View File

@@ -0,0 +1,107 @@
import { BuiltInSlotType, LiteGraph, type SlotLayout } from "@litegraph-ts/core";
import ComfyGraphNode, { type DefaultWidgetLayout } from "./ComfyGraphNode";
import { clamp } from "$lib/utils";
import ComboWidget from "$lib/widgets/ComboWidget.svelte";
import { ComfyComboNode } from "./ComfyWidgetNodes";
export interface ComfyValueControlProperties extends Record<any, any> {
value: any,
action: "fixed" | "increment" | "decrement" | "randomize",
min: number,
max: number,
step: number
}
const INT_MAX = 1125899906842624;
export default class ComfyValueControl extends ComfyGraphNode {
override properties: ComfyValueControlProperties = {
value: null,
action: "fixed",
min: -INT_MAX,
max: INT_MAX,
step: 1
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "number" },
{ name: "trigger", type: BuiltInSlotType.ACTION },
{ name: "action", type: "string" },
{ name: "min", type: "number" },
{ name: "max", type: "number" },
{ name: "step", type: "number" }
],
outputs: [
{ name: "value", type: "*" }
],
}
override defaultWidgets: DefaultWidgetLayout = {
inputs: {
2: {
defaultWidgetNode: ComfyComboNode,
config: {
defaultValue: "randomize",
values: ["fixed", "increment", "decrement", "randomize"]
}
}
}
}
constructor(title?: string) {
super(title);
}
override onExecute() {
this.setProperty("action", this.getInputData(2) || "fixed")
this.setProperty("min", this.getInputData(3))
this.setProperty("max", this.getInputData(4))
this.setProperty("step", this.getInputData(5) || 1)
}
override onAction(action: any, param: any) {
var v = this.getInputData(0)
if (typeof v !== "number")
return
let min = this.properties.min
let max = this.properties.max
if (min == null) min = -INT_MAX
if (max == null) max = INT_MAX
// limit to something that javascript can handle
min = Math.max(-INT_MAX, this.properties.min);
max = Math.min(INT_MAX, this.properties.max);
let range = (max - min) / (this.properties.step);
//adjust values based on valueControl Behaviour
switch (this.properties.action) {
case "fixed":
break;
case "increment":
v += this.properties.step;
break;
case "decrement":
v -= this.properties.step;
break;
case "randomize":
v = Math.floor(Math.random() * range) * (this.properties.step) + min;
default:
break;
}
v = clamp(v, min, max)
this.setProperty("value", v)
this.setOutputData(0, v)
console.debug("ValueControl", v, this.properties)
};
}
LiteGraph.registerNodeType({
class: ComfyValueControl,
title: "Comfy.ValueControl",
desc: "Adjusts an incoming value based on behavior",
type: "utils/value_control"
})

View File

@@ -0,0 +1,518 @@
import { LiteGraph, type ContextMenuItem, type LGraphNode, type Vector2, LConnectionKind, LLink, LGraphCanvas, type SlotType, TitleMode, type SlotLayout, LGraph, type INodeInputSlot, type ITextWidget, type INodeOutputSlot, type SerializedLGraphNode, BuiltInSlotType } from "@litegraph-ts/core";
import ComfyGraphNode from "./ComfyGraphNode";
import ComboWidget from "$lib/widgets/ComboWidget.svelte";
import RangeWidget from "$lib/widgets/RangeWidget.svelte";
import TextWidget from "$lib/widgets/TextWidget.svelte";
import GalleryWidget from "$lib/widgets/GalleryWidget.svelte";
import ButtonWidget from "$lib/widgets/ButtonWidget.svelte";
import type { SvelteComponentDev } from "svelte/internal";
import { Watch } from "@litegraph-ts/nodes-basic";
import type IComfyInputSlot from "$lib/IComfyInputSlot";
import { writable, type Unsubscriber, type Writable, get } from "svelte/store";
import { clamp } from "$lib/utils"
import layoutState from "$lib/stores/layoutState";
import type { FileData as GradioFileData } from "@gradio/upload";
import queueState from "$lib/stores/queueState";
export interface ComfyWidgetProperties extends Record<string, any> {
defaultValue: any
}
/*
* A node that is tied to a UI widget in the frontend. When the frontend's
* widget is changed, the value of the first output in the node is updated
* in the litegraph instance.
*/
export abstract class ComfyWidgetNode<T = any> extends ComfyGraphNode {
abstract properties: ComfyWidgetProperties;
value: Writable<T>
propsChanged: Writable<number> = writable(0) // dummy to indicate if props changed
unsubscribe: Unsubscriber;
/** Svelte class for the frontend logic */
abstract svelteComponentType: typeof SvelteComponentDev
/** If false, user manually set min/max/step, and should not be autoinherited from connected input */
autoConfig: boolean = true;
copyFromInputLink: boolean = true;
/** Names of properties to add as inputs */
// shownInputProperties: string[] = []
/** Names of properties to add as outputs */
private shownOutputProperties: Record<string, { type: string, index: number }> = {}
outputProperties: { name: string, type: string }[] = []
override isBackendNode = false;
override serialize_widgets = true;
outputIndex: number = 0;
inputIndex: number = 0;
changedIndex: number = 1;
displayWidget: ITextWidget;
override size: Vector2 = [60, 40];
constructor(name: string, value: T) {
const color = LGraphCanvas.node_colors["blue"]
super(name)
this.value = writable(value)
this.color ||= color.color
this.bgColor ||= color.bgColor
this.displayWidget = this.addWidget<ITextWidget>(
"text",
"Value",
""
);
this.displayWidget.disabled = true; // prevent editing
this.unsubscribe = this.value.subscribe(this.onValueUpdated.bind(this))
}
addPropertyAsOutput(propertyName: string, type: string) {
if (this.shownOutputProperties[propertyName])
return;
if (!(propertyName in this.properties)) {
throw `No property named ${propertyName} found!`
}
this.shownOutputProperties[propertyName] = { type, index: this.outputs.length }
this.addOutput(propertyName, type)
}
formatValue(value: any): string {
return Watch.toString(value)
}
private onValueUpdated(value: any) {
console.debug("[Widget] valueUpdated", this, value)
this.displayWidget.value = this.formatValue(value)
if (this.outputs.length >= this.outputIndex) {
this.setOutputData(this.outputIndex, get(this.value))
}
if (this.outputs.length >= this.changedIndex) {
const changedOutput = this.outputs[this.changedIndex]
if (changedOutput.type === BuiltInSlotType.EVENT)
this.triggerSlot(this.changedIndex, "changed")
}
}
setValue(value: any) {
this.value.set(value)
}
override onPropertyChanged(property: string, value: any, prevValue?: any) {
const data = this.shownOutputProperties[property]
if (data)
this.setOutputData(data.index, value)
}
/*
* Logic to run if this widget can be treated as output (slider, combo, text)
*/
override onExecute() {
if (this.copyFromInputLink) {
if (this.inputs.length >= this.inputIndex) {
const data = this.getInputData(this.inputIndex)
if (data) { // TODO can "null" be a legitimate value here?
console.log(data)
this.setValue(data)
const input = this.getInputLink(this.inputIndex)
input.data = null;
}
}
}
if (this.outputs.length >= this.outputIndex) {
this.setOutputData(this.outputIndex, get(this.value))
}
for (const propName in this.shownOutputProperties) {
const data = this.shownOutputProperties[propName]
this.setOutputData(data.index, this.properties[propName])
}
}
/** Called when a backend node sends a ComfyUI output over a link */
receiveOutput() {
}
onConnectOutput(
outputIndex: number,
inputType: INodeInputSlot["type"],
input: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number
): boolean {
if (this.autoConfig && "config" in input) {
this.doAutoConfig(input as IComfyInputSlot)
}
return true;
}
doAutoConfig(input: IComfyInputSlot) {
// Copy properties from default config in input slot
const comfyInput = input as IComfyInputSlot;
for (const key in comfyInput.config)
this.setProperty(key, comfyInput.config[key])
if ("defaultValue" in this.properties)
this.setValue(this.properties.defaultValue)
const widget = layoutState.findLayoutForNode(this.id)
if (widget && input.name !== "") {
widget.attrs.title = input.name;
}
console.debug("Property copy", input, this.properties)
this.setValue(get(this.value))
this.propsChanged.set(get(this.propsChanged) + 1)
}
onConnectionsChange(
type: LConnectionKind,
slotIndex: number,
isConnected: boolean,
link: LLink,
ioSlot: (INodeOutputSlot | INodeInputSlot)
): void {
this.clampConfig();
}
clampConfig() {
let changed = false;
for (const link of this.getOutputLinks(0)) {
if (link) { // can be undefined if the link is removed
const node = this.graph._nodes_by_id[link.target_id]
if (node) {
const input = node.inputs[link.target_slot]
if (input && "config" in input) {
this.clampOneConfig(input as IComfyInputSlot)
changed = true;
}
}
}
}
// Force reactivity change so the frontend can be updated with the new props
this.propsChanged.set(get(this.propsChanged) + 1)
}
clampOneConfig(input: IComfyInputSlot) { }
override onSerialize(o: SerializedLGraphNode) {
super.onSerialize(o);
(o as any).comfyValue = get(this.value);
(o as any).shownOutputProperties = this.shownOutputProperties
}
override onConfigure(o: SerializedLGraphNode) {
this.value.set((o as any).comfyValue);
this.shownOutputProperties = (o as any).shownOutputProperties;
}
}
export interface ComfySliderProperties extends ComfyWidgetProperties {
min: number,
max: number,
step: number,
precision: number
}
export class ComfySliderNode extends ComfyWidgetNode<number> {
override properties: ComfySliderProperties = {
defaultValue: 0,
min: 0,
max: 10,
step: 1,
precision: 1
}
override svelteComponentType = RangeWidget
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "number" }
],
outputs: [
{ name: "value", type: "number" },
{ name: "changed", type: BuiltInSlotType.EVENT },
]
}
override outputProperties = [
{ name: "min", type: "number" },
{ name: "max", type: "number" },
{ name: "step", type: "number" },
{ name: "precision", type: "number" },
]
constructor(name?: string) {
super(name, 0)
}
override setValue(value: any) {
if (typeof value !== "number")
return;
super.setValue(clamp(value, this.properties.min, this.properties.max))
}
override clampOneConfig(input: IComfyInputSlot) {
// this.setProperty("min", clamp(this.properties.min, input.config.min, input.config.max))
// this.setProperty("max", clamp(this.properties.max, input.config.max, input.config.min))
// this.setProperty("step", Math.min(this.properties.step, input.config.step))
this.setValue(this.properties.defaultValue)
}
}
LiteGraph.registerNodeType({
class: ComfySliderNode,
title: "UI.Slider",
desc: "Slider outputting a number value",
type: "ui/slider"
})
export interface ComfyComboProperties extends ComfyWidgetProperties {
values: string[]
}
export class ComfyComboNode extends ComfyWidgetNode<string> {
override properties: ComfyComboProperties = {
defaultValue: "A",
values: ["A", "B", "C", "D"]
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "string" }
],
outputs: [
{ name: "value", type: "string" },
{ name: "changed", type: BuiltInSlotType.EVENT }
]
}
override svelteComponentType = ComboWidget
constructor(name?: string) {
super(name, "A")
}
onConnectOutput(
outputIndex: number,
inputType: INodeInputSlot["type"],
input: INodeInputSlot,
inputNode: LGraphNode,
inputIndex: number
): boolean {
if (!super.onConnectOutput(outputIndex, inputType, input, inputNode, inputIndex))
return false;
const thisProps = this.properties;
if (!("config" in input))
return true;
const comfyInput = input as IComfyInputSlot;
const otherProps = comfyInput.config;
// Ensure combo options match
if (!(otherProps.values instanceof Array))
return false;
if (thisProps.values.find((v, i) => otherProps.values.indexOf(v) === -1))
return false;
return true;
}
override setValue(value: any) {
if (typeof value !== "string" || this.properties.values.indexOf(value) === -1)
return;
super.setValue(value)
}
override clampOneConfig(input: IComfyInputSlot) {
if (input.config.values.indexOf(this.properties.value) === -1) {
if (input.config.values.length === 0)
this.setValue("")
else
this.setValue(input.config.defaultValue || input.config.values[0])
}
}
}
LiteGraph.registerNodeType({
class: ComfyComboNode,
title: "UI.Combo",
desc: "Combo box outputting a string value",
type: "ui/combo"
})
export interface ComfyTextProperties extends ComfyWidgetProperties {
multiline: boolean;
}
export class ComfyTextNode extends ComfyWidgetNode<string> {
override properties: ComfyTextProperties = {
defaultValue: "",
multiline: false
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "value", type: "string" }
],
outputs: [
{ name: "value", type: "string" },
{ name: "changed", type: BuiltInSlotType.EVENT }
]
}
override svelteComponentType = TextWidget
constructor(name?: string) {
super(name, "")
}
override setValue(value: any) {
super.setValue(`${value}`)
}
}
LiteGraph.registerNodeType({
class: ComfyTextNode,
title: "UI.Text",
desc: "Textbox outputting a string value",
type: "ui/text"
})
/** Raw output as received from ComfyUI's backend */
export type GalleryOutput = {
images: GalleryOutputEntry[]
}
/** Raw output entry as received from ComfyUI's backend */
export type GalleryOutputEntry = {
filename: string,
subfolder: string,
type: string
}
export interface ComfyGalleryProperties extends ComfyWidgetProperties {
}
export class ComfyGalleryNode extends ComfyWidgetNode<GradioFileData[]> {
override properties: ComfyGalleryProperties = {
defaultValue: []
}
static slotLayout: SlotLayout = {
inputs: [
{ name: "images", type: "OUTPUT" }
]
}
override svelteComponentType = GalleryWidget
override copyFromInputLink = false;
constructor(name?: string) {
super(name, [])
}
override afterQueued() {
let queue = get(queueState)
if (!(typeof queue.queueRemaining === "number" && queue.queueRemaining > 1)) {
this.setValue([])
}
}
override formatValue(value: GradioFileData[] | null): string {
return `Images: ${value?.length || 0}`
}
private convertItems(output: GalleryOutput): GradioFileData[] {
return output.images.map(r => {
// TODO configure backend URL
const url = "http://localhost:8188/view?"
const params = new URLSearchParams(r)
return {
name: null,
data: url + params
}
});
}
override setValue(value: any) {
if (Array.isArray(value)) {
super.setValue(value)
}
else {
super.setValue([])
}
}
receiveOutput() {
const link = this.getInputLink(0)
if (link.data && "images" in link.data) {
const data = link.data as GalleryOutput
console.debug("[ComfyGalleryNode] Received output!", data)
const galleryItems: GradioFileData[] = this.convertItems(link.data)
const currentValue = get(this.value)
this.setValue(currentValue.concat(galleryItems))
}
}
}
LiteGraph.registerNodeType({
class: ComfyGalleryNode,
title: "UI.Gallery",
desc: "Gallery that shows most recent outputs",
type: "ui/gallery"
})
export interface ComfyButtonProperties extends ComfyWidgetProperties {
message: string
}
export class ComfyButtonNode extends ComfyWidgetNode<boolean> {
override properties: ComfyButtonProperties = {
defaultValue: false,
message: "bang"
}
static slotLayout: SlotLayout = {
outputs: [
{ name: "clicked", type: BuiltInSlotType.EVENT },
{ name: "isClicked", type: "boolean" },
]
}
override outputIndex = 1;
override svelteComponentType = ButtonWidget;
override setValue(value: any) {
super.setValue(Boolean(value))
}
onClick() {
this.setValue(true)
this.triggerSlot(0, this.properties.message);
this.setValue(false)
}
constructor(name?: string) {
super(name, false)
}
}
LiteGraph.registerNodeType({
class: ComfyButtonNode,
title: "UI.Button",
desc: "Button that triggers an event when clicked",
type: "ui/button"
})

View File

@@ -1,2 +1,5 @@
export { default as ComfyReroute } from "./ComfyReroute"
export { ComfySaveImageNode, ComfyPreviewImageNode } from "./ComfyImageNodes"
export { ComfyWidgetNode, ComfySliderNode, ComfyComboNode, ComfyTextNode } from "./ComfyWidgetNodes"
export { ComfyCopyAction, ComfySwapAction } from "./ComfyActionNodes"
export { default as ComfyValueControl } from "./ComfyValueControl"
export { default as ComfySelector } from "./ComfySelector"

View File

@@ -0,0 +1,513 @@
import { get, writable } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store';
import type ComfyApp from "$lib/components/ComfyApp"
import type { LGraphNode, IWidget, LGraph } from "@litegraph-ts/core"
import { dndzone, SHADOW_PLACEHOLDER_ITEM_ID } from 'svelte-dnd-action';
import type { ComfyWidgetNode } from '$lib/nodes';
type DragItemEntry = {
dragItem: IDragItem,
children: IDragItem[] | null,
parent: IDragItem | null
}
export type LayoutState = {
root: IDragItem | null,
allItems: Record<DragItemID, DragItemEntry>,
allItemsByNode: Record<number, DragItemEntry>,
currentId: number,
currentSelection: DragItemID[],
isConfiguring: boolean,
isMenuOpen: boolean
}
export type AttributesSpec = {
name: string,
type: string,
editable: boolean
}
export type AttributesCategorySpec = {
categoryName: string,
specs: AttributesSpec[]
}
export type AttributesSpecList = AttributesCategorySpec[]
const ALL_ATTRIBUTES: AttributesSpecList = [
{
categoryName: "appearance",
specs: [
{
name: "title",
type: "string",
editable: true,
},
{
name: "showTitle",
type: "boolean",
editable: true,
},
{
name: "direction",
type: "string",
editable: true,
},
{
name: "classes",
type: "string",
editable: true,
},
]
}
];
export { ALL_ATTRIBUTES };
export type Attributes = {
direction: "horizontal" | "vertical",
title: string,
showTitle: boolean,
classes: string
}
export interface IDragItem {
type: string,
id: DragItemID,
isNodeExecuting?: boolean,
attrs: Attributes
}
export interface ContainerLayout extends IDragItem {
type: "container",
}
export interface WidgetLayout extends IDragItem {
type: "widget",
node: ComfyWidgetNode
}
type DragItemID = string;
type LayoutStateOps = {
addContainer: (parent: ContainerLayout | null, attrs: Partial<Attributes>, index: number) => ContainerLayout,
addWidget: (parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial<Attributes>, index: number) => WidgetLayout,
findDefaultContainerForInsertion: () => ContainerLayout | null,
updateChildren: (parent: IDragItem, children: IDragItem[]) => IDragItem[],
nodeAdded: (node: LGraphNode) => void,
nodeRemoved: (node: LGraphNode) => void,
groupItems: (dragItems: IDragItem[], attrs?: Partial<Attributes>) => ContainerLayout,
ungroup: (container: ContainerLayout) => void,
getCurrentSelection: () => IDragItem[],
findLayoutForNode: (nodeId: number) => IDragItem | null;
serialize: () => SerializedLayoutState,
deserialize: (data: SerializedLayoutState, graph: LGraph) => void,
initDefaultLayout: () => void,
onStartConfigure: () => void
}
export type WritableLayoutStateStore = Writable<LayoutState> & LayoutStateOps;
const store: Writable<LayoutState> = writable({
root: null,
allItems: {},
allItemsByNode: {},
currentId: 0,
currentSelection: [],
isMenuOpen: false,
isConfiguring: true
})
function findDefaultContainerForInsertion(): ContainerLayout | null {
const state = get(store);
if (state.root === null) {
// Should never happen
throw "Root container was null!";
}
if (state.root.type === "container") {
const container = state.root as ContainerLayout;
const children: IDragItem[] = state.allItems[container.id]?.children || []
const found = children.find((di) => di.type === "container")
if (found && found.type === "container")
return found as ContainerLayout;
return container;
}
return null
}
function addContainer(parent: ContainerLayout | null, attrs: Partial<Attributes> = {}, index: number = -1): ContainerLayout {
const state = get(store);
const dragItem: ContainerLayout = {
type: "container",
id: `${state.currentId++}`,
attrs: {
title: "Container",
showTitle: true,
direction: "vertical",
classes: "",
...attrs
}
}
const entry: DragItemEntry = { dragItem, children: [], parent: null };
state.allItems[dragItem.id] = entry;
if (parent) {
moveItem(dragItem, parent)
}
console.debug("[layoutState] addContainer", state)
store.set(state)
return dragItem;
}
function addWidget(parent: ContainerLayout, node: ComfyWidgetNode, attrs: Partial<Attributes> = {}, index: number = -1): WidgetLayout {
const state = get(store);
const widgetName = "Widget"
const dragItem: WidgetLayout = {
type: "widget",
id: `${state.currentId++}`,
node: node,
attrs: {
title: widgetName,
showTitle: true,
direction: "horizontal",
classes: "",
...attrs
}
}
const parentEntry = state.allItems[parent.id]
const entry: DragItemEntry = { dragItem, children: [], parent: null };
state.allItems[dragItem.id] = entry;
state.allItemsByNode[node.id] = entry;
console.debug("[layoutState] addWidget", state)
moveItem(dragItem, parent)
return dragItem;
}
function updateChildren(parent: IDragItem, newChildren?: IDragItem[]): IDragItem[] {
const state = get(store);
if (newChildren)
state.allItems[parent.id].children = newChildren;
for (const child of state.allItems[parent.id].children) {
if (child.id === SHADOW_PLACEHOLDER_ITEM_ID)
continue;
state.allItems[child.id].parent = parent;
}
store.set(state)
return state.allItems[parent.id].children
}
function nodeAdded(node: LGraphNode) {
const state = get(store)
if (state.isConfiguring)
return;
const parent = findDefaultContainerForInsertion();
// Two cases where we want to add nodes:
// 1. User adds a new UI node, so we should instantiate its widget in the frontend.
// 2. User adds a node with inputs that can be filled by frontend widgets.
// Depending on config, this means we should instantiate default UI nodes connected to those inputs.
console.debug(node)
if ("svelteComponentType" in node) {
addWidget(parent, node as ComfyWidgetNode);
}
// Add default node panel with all widgets autoinstantiated
// if (node.widgets && node.widgets.length > 0) {
// const container = addContainer(parent.id, { title: node.title, direction: "vertical", associatedNode: node.id });
// for (const widget of node.widgets) {
// addWidget(container.id, node, widget, { associatedNode: node.id });
// }
// }
}
function removeEntry(state: LayoutState, id: DragItemID) {
const entry = state.allItems[id]
if (entry.children && entry.children.length > 0) {
console.error(entry)
throw `Tried removing entry ${id} but it still had children!`
}
const parent = entry.parent;
if (parent) {
const parentEntry = state.allItems[parent.id];
parentEntry.children = parentEntry.children.filter(item => item.id !== id)
}
if (entry.dragItem.type === "widget") {
const widget = entry.dragItem as WidgetLayout;
delete state.allItemsByNode[widget.node.id]
}
delete state.allItems[id]
}
function nodeRemoved(node: LGraphNode) {
const state = get(store)
console.debug("[layoutState] nodeRemoved", node)
let del = Object.entries(state.allItems).filter(pair =>
pair[1].dragItem.type === "widget"
&& (pair[1].dragItem as WidgetLayout).node.id === node.id)
for (const pair of del) {
const [id, dragItem] = pair;
removeEntry(state, id)
}
store.set(state)
}
function moveItem(target: IDragItem, to: ContainerLayout, index: number = -1) {
const state = get(store)
const entry = state.allItems[target.id]
if (entry.parent && entry.parent.id === to.id)
return;
if (entry.parent) {
const parentEntry = state.allItems[entry.parent.id];
const index = parentEntry.children.findIndex(c => c.id === target.id)
if (index !== -1) {
parentEntry.children.splice(index, 1)
}
else {
console.error(parentEntry)
console.error(target)
throw "Child not found in parent!"
}
}
const toEntry = state.allItems[to.id];
if (index !== -1)
toEntry.children.splice(index, 0, target)
else
toEntry.children.push(target)
state.allItems[target.id].parent = toEntry.dragItem;
console.debug("[layoutState] Move child", target, toEntry, index)
store.set(state)
}
function getCurrentSelection(): IDragItem[] {
const state = get(store)
return state.currentSelection.map(id => state.allItems[id].dragItem)
}
function groupItems(dragItems: IDragItem[], attrs: Partial<Attributes> = {}): ContainerLayout {
if (dragItems.length === 0)
return;
const state = get(store)
const parent = state.allItems[dragItems[0].id].parent || findDefaultContainerForInsertion();
if (parent === null || parent.type !== "container")
return;
let index = undefined;
if (parent) {
const indexFound = state.allItems[parent.id].children.findIndex(c => c.id === dragItems[0].id)
if (indexFound !== -1)
index = indexFound
}
const container = addContainer(parent as ContainerLayout, attrs, index)
for (const item of dragItems) {
moveItem(item, container)
}
console.debug("[layoutState] Grouped", container, parent, state.allItems[container.id].children, index)
store.set(state)
return container
}
function ungroup(container: ContainerLayout) {
const state = get(store)
const parent = state.allItems[container.id].parent;
if (!parent || parent.type !== "container") {
console.warn("No parent to ungroup into!", container)
return;
}
let index = undefined;
const parentChildren = state.allItems[parent.id].children;
const indexFound = parentChildren.findIndex(c => c.id === container.id)
if (indexFound !== -1)
index = indexFound
const containerEntry = state.allItems[container.id]
console.debug("[layoutState] About to ungroup", containerEntry, parent, parentChildren, index)
const children = [...containerEntry.children]
for (const item of children) {
moveItem(item, parent as ContainerLayout, index)
}
removeEntry(state, container.id)
console.debug("[layoutState] Ungrouped", containerEntry, parent, parentChildren, index)
store.set(state)
}
function findLayoutForNode(nodeId: number): WidgetLayout | null {
const state = get(store)
const found = Object.entries(state.allItems).find(pair =>
pair[1].dragItem.type === "widget"
&& (pair[1].dragItem as WidgetLayout).node.id === nodeId)
if (found)
return found[1].dragItem as WidgetLayout
return null;
}
function initDefaultLayout() {
store.set({
root: null,
allItems: {},
currentId: 0,
currentSelection: [],
isMenuOpen: false,
isConfiguring: false
})
const root = addContainer(null, { direction: "horizontal", showTitle: false });
const left = addContainer(root, { direction: "vertical", showTitle: false });
const right = addContainer(root, { direction: "vertical", showTitle: false });
const state = get(store)
state.root = root;
store.set(state)
console.debug("[layoutState] initDefault", state)
}
export type SerializedLayoutState = {
root: DragItemID | null,
allItems: Record<DragItemID, SerializedDragEntry>,
currentId: number,
}
export type SerializedDragEntry = {
dragItem: SerializedDragItem,
children: DragItemID[],
parent: DragItemID | null
}
export type SerializedDragItem = {
type: string,
id: DragItemID,
nodeId: number | null,
attrs: Attributes
}
function serialize(): SerializedLayoutState {
const state = get(store)
const allItems: Record<DragItemID, SerializedDragEntry> = {}
for (const pair of Object.entries(state.allItems)) {
const [id, entry] = pair;
allItems[id] = {
dragItem: {
type: entry.dragItem.type,
id: entry.dragItem.id,
nodeId: (entry.dragItem as any).node?.id,
attrs: entry.dragItem.attrs
},
children: entry.children.map((di) => di.id),
parent: entry.parent?.id
}
}
return {
root: state.root?.id,
allItems,
currentId: state.currentId,
}
}
function deserialize(data: SerializedLayoutState, graph: LGraph) {
const allItems: Record<DragItemID, DragItemEntry> = {}
const allItemsByNode: Record<number, DragItemEntry> = {}
for (const pair of Object.entries(data.allItems)) {
const [id, entry] = pair;
const dragItem: IDragItem = {
type: entry.dragItem.type,
id: entry.dragItem.id,
attrs: entry.dragItem.attrs
};
const dragEntry: DragItemEntry = {
dragItem,
children: [],
parent: null
}
allItems[id] = dragEntry
if (dragItem.type === "widget") {
const widget = dragItem as WidgetLayout;
widget.node = graph.getNodeById(entry.dragItem.nodeId) as ComfyWidgetNode
allItemsByNode[entry.dragItem.nodeId] = dragEntry
}
}
// reconnect parent/child tree
for (const pair of Object.entries(data.allItems)) {
const [id, entry] = pair;
for (const childId of entry.children) {
allItems[id].children.push(allItems[childId].dragItem)
}
if (entry.parent) {
allItems[id].parent = allItems[entry.parent].dragItem;
}
}
let root: IDragItem = null;
if (data.root)
root = allItems[data.root].dragItem
const state: LayoutState = {
root,
allItems,
allItemsByNode,
currentId: data.currentId,
currentSelection: [],
isMenuOpen: false,
isConfiguring: false
}
console.debug("[layoutState] deserialize", data, state)
store.set(state)
}
function onStartConfigure() {
store.update(s => {
s.isConfiguring = true;
return s
})
}
const layoutStateStore: WritableLayoutStateStore =
{
...store,
addContainer,
addWidget,
findDefaultContainerForInsertion,
updateChildren,
nodeAdded,
nodeRemoved,
getCurrentSelection,
groupItems,
findLayoutForNode,
ungroup,
initDefaultLayout,
onStartConfigure,
serialize,
deserialize
}
export default layoutStateStore;

View File

@@ -1,71 +0,0 @@
import { writable, get } from 'svelte/store';
import type { LGraph, LGraphNode } from "@litegraph-ts/core";
import type { Readable, Writable } from 'svelte/store';
import type ComfyGraphNode from '$lib/nodes/ComfyGraphNode';
/** store for one node's state */
export type NodeUIStateStore = Writable<any>
export type NodeUIState = {
name: string,
node: LGraphNode
}
type NodeID = number;
type NodeStateOps = {
nodeAdded: (node: LGraphNode) => void,
nodeRemoved: (node: LGraphNode) => void,
configureFinished: (graph: LGraph) => void,
nodeStateChanged: (node: LGraphNode) => void,
clear: () => void,
}
export type NodeStateStore = Record<NodeID, NodeUIState>;
type WritableNodeStateStore = Writable<NodeStateStore> & NodeStateOps;
const store: Writable<NodeStateStore> = writable({})
function clear() {
store.set({})
}
function nodeAdded(node: LGraphNode) {
let state = get(store)
state[node.id] = { node: node, name: node.name }
store.set(state);
}
function nodeRemoved(node: LGraphNode) {
const state = get(store)
delete state[node.id]
store.set(state)
}
function nodeStateChanged(node: LGraphNode) {
const state = get(store)
const nodeState = state[node.id]
nodeState.name = node.name
store.set(state);
}
function configureFinished(graph: LGraph) {
let state = get(store);
for (const node of graph.computeExecutionOrder(false, null)) {
state[node.id].name = name;
}
store.set(state)
}
const nodeStateStore: WritableNodeStateStore =
{
...store,
nodeAdded,
nodeRemoved,
nodeStateChanged,
configureFinished,
clear
}
export default nodeStateStore;

View File

@@ -2,15 +2,25 @@ import { writable } from 'svelte/store';
import type { Readable, Writable } from 'svelte/store';
import type ComfyApp from "$lib/components/ComfyApp"
export type UIEditMode = "disabled" | "widgets" | "containers" | "layout";
export type UIState = {
app: ComfyApp,
nodesLocked: boolean,
graphLocked: boolean,
unlocked: boolean,
autoAddUI: boolean,
uiEditMode: UIEditMode
}
export type WritableUIStateStore = Writable<UIState>;
const store: WritableUIStateStore = writable({ unlocked: false, graphLocked: true, nodesLocked:false })
const store: WritableUIStateStore = writable(
{
app: null,
graphLocked: false,
nodesLocked: false,
autoAddUI: true,
uiEditMode: "disabled",
})
const uiStateStore: WritableUIStateStore =
{

View File

@@ -1,132 +0,0 @@
import { writable, get } from 'svelte/store';
import type { LGraph, LGraphNode, IWidget } from "@litegraph-ts/core";
import type { Readable, Writable } from 'svelte/store';
import type ComfyGraphNode from '$lib/nodes/ComfyGraphNode';
import type ComfyWidget from '$lib/widgets/ComfyWidget';
/** store for one widget's state */
export type WidgetUIStateStore = Writable<any>
export type WidgetUIState = {
/** position in the node's list of widgets */
index: number,
/** parent node containing the widget */
node: LGraphNode,
/** actual widget instance */
widget: IWidget,
/** widget value as a store, to react to changes */
value: WidgetUIStateStore,
/**
* true if this widget was added purely from the frontend. what this means:
* - this widget's state will not be saved to the workflow
* - the widget was added on startup by some subclass of ComfyGraphNode
*/
isVirtual: boolean
}
export type WidgetDrawState = {
isNodeExecuting: boolean
}
type NodeID = number;
type WidgetStateOps = {
nodeAdded: (node: LGraphNode) => void,
nodeRemoved: (node: LGraphNode) => void,
configureFinished: (graph: LGraph) => void,
widgetStateChanged: (nodeId: number, widget: IWidget<any, any>) => void,
findWidgetByName: (nodeId: number, name: string) => WidgetUIState | null,
clear: () => void,
}
export type WidgetStateStore = Record<NodeID, WidgetUIState[]>;
type WritableWidgetStateStore = Writable<WidgetStateStore> & WidgetStateOps;
const store: Writable<WidgetStateStore> = writable({})
function clear() {
store.set({})
}
function nodeAdded(node: LGraphNode) {
let state = get(store)
if (node.widgets) {
for (const [index, widget] of node.widgets.entries()) {
if (!state[node.id])
state[node.id] = []
let isVirtual = false;
if ("isVirtual" in widget)
isVirtual = (widget as ComfyWidget<any, any>).isVirtual;
state[node.id].push({ index, node, widget, value: writable(widget.value), isVirtual: isVirtual })
}
}
console.debug("NODEADDED", state)
store.set(state);
}
function nodeRemoved(node: LGraphNode) {
const state = get(store)
delete state[node.id]
store.set(state)
}
function widgetStateChanged(nodeId: number, widget: IWidget<any, any>) {
const state = get(store)
const entries = state[nodeId]
if (entries) {
let widgetState = entries.find(e => e.widget === widget);
if (widgetState) {
widgetState.value.set(widget.value);
store.set(state);
}
else {
console.error("Widget state changed and node was found, but widget was not found in state!", widget, widget.node, entries)
}
}
else {
console.error("Widget state changed but node was not found in state!", widget, widget.node)
}
}
function configureFinished(graph: LGraph) {
let state = get(store);
for (const node of graph.computeExecutionOrder(false, null)) {
if (node.widgets_values) {
for (const [i, value] of node.widgets_values.entries()) {
if (i < state[node.id].length && !state[node.id][i].isVirtual) {
state[node.id][i].value.set(value);
}
else {
console.log("Skip virtual widget", node.id, node.type, state[node.id][i].widget)
}
}
}
}
store.set(state)
}
function findWidgetByName(nodeId: number, name: string): WidgetUIState | null {
let state = get(store);
if (!(nodeId in state))
return null;
return state[nodeId].find((v) => v.widget.name === name);
}
const widgetStateStore: WritableWidgetStateStore =
{
...store,
nodeAdded,
nodeRemoved,
widgetStateChanged,
configureFinished,
findWidgetByName,
clear
}
export default widgetStateStore;

View File

@@ -2,7 +2,13 @@ import ComfyApp from "./components/ComfyApp";
import ComboWidget from "$lib/widgets/ComboWidget.svelte";
import RangeWidget from "$lib/widgets/RangeWidget.svelte";
import TextWidget from "$lib/widgets/TextWidget.svelte";
import widgetState, { type WidgetDrawState, type WidgetUIState } from "$lib/stores/widgetState";
import { get } from "svelte/store"
import layoutState from "$lib/stores/layoutState"
import type { SvelteComponentDev } from "svelte/internal";
export function clamp(n: number, min: number, max: number): number {
return Math.min(Math.max(n, min), max)
}
export function download(filename: string, text: string, type: string = "text/plain") {
const blob = new Blob([text], { type: type });
@@ -18,24 +24,31 @@ export function download(filename: string, text: string, type: string = "text/pl
}, 0);
}
export function getComponentForWidgetState(item: WidgetUIState): any {
let ctor: any = null;
export function startDrag(evt: MouseEvent) {
const dragItemId: string = evt.target.dataset["dragItemId"];
const ls = get(layoutState)
// custom widgets with TypeScript sources
let override = ComfyApp.widget_type_overrides[item.widget.type]
if (override) {
return override;
if (evt.button !== 0) {
if (ls.currentSelection.length <= 1 && !ls.isMenuOpen)
ls.currentSelection = [dragItemId]
return;
}
// litegraph.ts built-in widgets
switch (item.widget.type) {
case "combo":
return ComboWidget;
case "number":
return RangeWidget;
case "text":
return TextWidget;
}
const item = ls.allItems[dragItemId].dragItem
return null;
}
if (evt.ctrlKey) {
const index = ls.currentSelection.indexOf(item.id)
if (index === -1)
ls.currentSelection.push(item.id);
else
ls.currentSelection.splice(index, 1);
ls.currentSelection = ls.currentSelection;
}
else {
ls.currentSelection = [item.id]
}
layoutState.set(ls)
};
export function stopDrag(evt: MouseEvent) {
};

View File

@@ -1,147 +1,80 @@
import type { IWidget, LGraphNode } from "@litegraph-js/core";
import type ComfyApp from "$lib/components/ComfyApp";
import ComfyValueControlWidget from "./widgets/ComfyValueControlWidget";
import type { ComfyInputConfig } from "./IComfyInputSlot";
import type IComfyInputSlot from "./IComfyInputSlot";
import { BuiltInSlotShape, LiteGraph } from "@litegraph-ts/core";
import { ComfyComboNode, ComfySliderNode, ComfyTextNode } from "./nodes";
export interface WidgetData {
widget: IWidget,
minWidth?: number,
minHeight?: number
}
type WidgetFactory = (node: LGraphNode, inputName: string, inputData: any) => IComfyInputSlot;
type WidgetFactory = (node: LGraphNode, inputName: string, inputData: any, app: ComfyApp) => WidgetData;
type NumberConfig = { min: number, max: number, step: number, precision: number }
type NumberDefaults = { val: number, config: NumberConfig }
function getNumberDefaults(inputData: any, defaultStep: number): NumberDefaults {
let defaultVal = inputData[1]["default"];
function getNumberDefaults(inputData: any, defaultStep: number): ComfyInputConfig {
let defaultValue = inputData[1]["default"];
let { min, max, step } = inputData[1];
if (defaultVal == undefined) defaultVal = 0;
if (defaultValue == undefined) defaultValue = 0;
if (min == undefined) min = 0;
if (max == undefined) max = 2048;
if (step == undefined) step = defaultStep;
return { val: defaultVal, config: { min, max, step: step, precision: 0 } };
return { min, max, step: step, precision: 0, defaultValue };
}
function addComfyInput(node: LGraphNode, inputName: string, extraInfo: Partial<IComfyInputSlot> = {}): IComfyInputSlot {
const input = node.addInput(inputName) as IComfyInputSlot
for (const [k, v] of Object.entries(extraInfo))
input[k] = v
const FLOAT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): WidgetData => {
const { val, config } = getNumberDefaults(inputData, 0.5);
return { widget: node.addWidget("number", inputName, val, () => { }, config) };
if (input.defaultWidgetNode) {
const ty = Object.values(LiteGraph.registered_node_types)
.find(v => v.class === input.defaultWidgetNode)
if (ty)
input.widgetNodeType = ty.type
}
input.serialize = true;
return input;
}
const FLOAT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 0.5);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfySliderNode })
}
const INT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): WidgetData => {
const { val, config } = getNumberDefaults(inputData, 1);
return {
widget: node.addWidget(
"number",
inputName,
val,
function(v) {
const s = this.options.step;
this.value = Math.round(v / s) * s;
},
config
),
};
const INT: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const config = getNumberDefaults(inputData, 1);
return addComfyInput(node, inputName, { type: "number", config, defaultWidgetNode: ComfySliderNode })
};
function seedWidget(node, inputName, inputData, app) {
const seed = INT(node, inputName, inputData, app);
const seedControl = new ComfyValueControlWidget("control_after_generate", "randomize", node, seed.widget);
node.addCustomWidget(seedControl);
// seed.widget.linkedWidgets = [seedControl];
return seed;
}
const STRING: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any, app: ComfyApp): WidgetData => {
const defaultVal = inputData[1].default || "";
const STRING: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const defaultValue = inputData[1].default || "";
const multiline = !!inputData[1].multiline;
// if (multiline) {
// return addMultilineWidget(node, inputName, { defaultVal, ...inputData[1] }, app);
// } else {
return { widget: node.addWidget("text", inputName, defaultVal, () => { }, { multiline }) };
// }
return addComfyInput(node, inputName, { type: "string", config: { defaultValue, multiline }, defaultWidgetNode: ComfyTextNode })
};
const COMBO: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): WidgetData => {
const COMBO: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
const type = inputData[0];
let defaultValue = type[0];
if (inputData[1] && inputData[1].default) {
defaultValue = inputData[1].default;
}
return { widget: node.addWidget("combo", inputName, defaultValue, () => { }, { values: type }) };
return addComfyInput(node, inputName, { type: "string", config: { values: type, defaultValue }, defaultWidgetNode: ComfyComboNode })
}
const IMAGEUPLOAD: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any, app): WidgetData => {
const imageWidget = node.widgets.find((w) => w.name === "image");
let uploadWidget: IWidget;
// async function uploadFile(file: File, updateNode: boolean) {
// try {
// // Wrap file in formdata so it includes filename
// const body = new FormData();
// body.append("image", file);
// const resp = await fetch("/upload/image", {
// method: "POST",
// body,
// });
// if (resp.status === 200) {
// const data = await resp.json();
// // Add the file as an option and update the widget value
// if (!imageWidget.options.values.includes(data.name)) {
// imageWidget.options.values.push(data.name);
// }
// if (updateNode) {
// // showImage(data.name);
// imageWidget.value = data.name;
// }
// } else {
// alert(resp.status + " - " + resp.statusText);
// }
// } catch (error) {
// alert(error);
// }
// }
// const fileInput = document.createElement("input");
// Object.assign(fileInput, {
// type: "file",
// accept: "image/jpeg,image/png",
// style: "display: none",
// onchange: async () => {
// if (fileInput.files.length) {
// await uploadFile(fileInput.files[0], true);
// }
// },
// });
// document.body.append(fileInput);
// Create the button widget for selecting the files
uploadWidget = node.addWidget("button", "choose file to upload", "image", () => {
// fileInput.click();
});
uploadWidget.options = { serialize: false };
return { widget: uploadWidget };
const IMAGEUPLOAD: WidgetFactory = (node: LGraphNode, inputName: string, inputData: any): IComfyInputSlot => {
return addComfyInput(node, inputName, { type: "number", config: {} })
}
export type WidgetRepository = Record<string, WidgetFactory>
export const ComfyWidgets: WidgetRepository = {
"INT:seed": seedWidget,
"INT:noise_seed": seedWidget,
const ComfyWidgets: WidgetRepository = {
"INT:seed": INT,
"INT:noise_seed": INT,
FLOAT,
INT,
STRING,
COMBO,
IMAGEUPLOAD,
}
export default ComfyWidgets

View File

@@ -0,0 +1,44 @@
<script lang="ts">
import type { ComfyButtonNode } from "$lib/nodes/ComfyWidgetNodes";
import type { ComfySliderNode } from "$lib/nodes/index";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Button } from "@gradio/button";
import { get, type Writable } from "svelte/store";
export let widget: WidgetLayout | null = null;
let node: ComfyButtonNode | null = null;
let nodeValue: Writable<boolean> | null = null;
let propsChanged: Writable<number> | null = null;
$: widget && setNodeValue(widget);
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfyButtonNode
nodeValue = node.value;
propsChanged = node.propsChanged;
}
};
function onClick(e: MouseEvent) {
node.onClick();
}
const style = {
full_width: "100%"
}
</script>
<div class="wrapper gr-button">
{#if node !== null}
<Button on:click={onClick} variant="primary" {style}>
{widget.attrs.title}
</Button>
{/if}
</div>
<style>
.wrapper {
padding: 2px;
width: 100%;
}
</style>

View File

@@ -1,50 +1,117 @@
<script lang="ts">
import type { WidgetDrawState, WidgetUIState, WidgetUIStateStore } from "$lib/stores/widgetState";
import { BlockTitle } from "@gradio/atoms";
import { Dropdown } from "@gradio/form";
import { get } from "svelte/store";
import Select from 'svelte-select';
export let item: WidgetUIState | null = null;
let itemValue: WidgetUIStateStore | null = null;
let option: any;
import type { ComfyComboNode } from "$lib/nodes/index";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { get, type Writable } from "svelte/store";
export let widget: WidgetLayout | null = null;
let node: ComfyComboNode | null = null;
let nodeValue: Writable<string> | null = null;
let propsChanged: Writable<number> | null = null;
let option: any
$: if(item) {
if (!itemValue)
itemValue = item.value;
if (!option)
option = get(item.value);
};
export let debug: boolean = false;
$: if (option && itemValue) {
$itemValue = option.value
$: widget && setNodeValue(widget);
$: if (nodeValue !== null && (!$propsChanged || $propsChanged)) {
if (node.properties.values.indexOf(option.value) === -1) {
setOption($nodeValue)
$nodeValue = option
}
else {
$nodeValue = option
setOption($nodeValue)
}
setNodeValue(widget)
node.properties = node.properties
}
function setNodeValue(widget: WidgetLayout) {
if(widget) {
node = widget.node as ComfyComboNode
nodeValue = node.value;
propsChanged = node.propsChanged;
setOption($nodeValue) // don't react on option
}
}
function setOption(value: any) {
option = value;
}
$: if (nodeValue && option && option.value) {
$nodeValue = option.value;
}
function getLinkValue() {
if (!node)
return "???";
const links = node.getOutputLinks(0)
if (links.length === 0)
return "???";
return links[0].data
}
let lastPropsChanged: number = 0;
let werePropsChanged: boolean = false;
$: if ($propsChanged !== lastPropsChanged) {
werePropsChanged = true;
lastPropsChanged = $propsChanged;
setTimeout(() => (werePropsChanged = false), 2000);
}
</script>
<div class="wrapper gr-combo">
{#if item !== null && option !== undefined}
<label>
<BlockTitle show_label={true}>{item.widget.name}</BlockTitle>
<Select
bind:value={option}
bind:items={item.widget.options.values}
disabled={item.widget.options.values.length === 0}
clearable={false}
on:change
on:select
on:filter
on:blur
/>
</label>
{/if}
<div class="wrapper gr-combo" class:updated={werePropsChanged}>
{#key $propsChanged}
{#if node !== null && nodeValue !== null}
<label>
<BlockTitle show_label={true}>{widget.attrs.title}</BlockTitle>
<Select
bind:value={option}
bind:items={node.properties.values}
disabled={node.properties.values.length === 0}
clearable={false}
on:change
on:select
on:filter
on:blur
/>
{#if debug}
<div>Value: {option?.value}</div>
<div>Items: {node.properties.values}</div>
<div>NodeValue: {$nodeValue}</div>
<div>LinkValue: {getLinkValue()}</div>
{/if}
</label>
{/if}
{/key}
</div>
<style>
<style lang="scss">
.wrapper {
padding: 2px;
width: 100%;
}
@keyframes -global-light-up {
from {
background-color: var(--color-yellow-400);
}
to {
background-color: none;
}
}
.updated {
animation: light-up 1s ease-out;
:global(.svelte-select) {
animation: light-up 1s ease-out;
}
}
:global(.svelte-select-list) {
z-index: var(--layer-5) !important;
z-index: var(--layer-top) !important;
}
</style>

View File

@@ -1,57 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { ImageViewer } from "$lib/ImageViewer";
import type { WidgetUIState, WidgetUIStateStore } from "$lib/stores/widgetState";
import { Block } from "@gradio/atoms";
import { Gallery } from "@gradio/gallery";
import type { Styles } from "@gradio/utils";
export let item: WidgetUIState | null = null;
let itemValue: WidgetUIStateStore | null = null; // stores must be declared at top level
$: if(item) {
itemValue = item.value;
}
let style: Styles = {
// grid_cols: [2],
grid: [3],
// object_fit: "cover",
}
let element: HTMLDivElement;
function updateForLightbox() {
// Wait for gradio gallery to show the large preview image, if no timeout then
// the event might fire too early
setTimeout(() => {
const images = element.querySelectorAll<HTMLImageElement>('div.block div > img')
if (images != null) {
images.forEach(ImageViewer.instance.setupImageForLightbox.bind(ImageViewer.instance));
}
ImageViewer.instance.updateOnBackgroundChange();
}, 200)
}
</script>
<div class="wrapper comfy-gallery-widget gr-gallery" bind:this={element}>
{#if item && itemValue}
<Block variant="solid" padding={false}>
<Gallery
bind:value={$itemValue}
label={item.widget.name}
show_label={true}
{style}
root={""}
root_url={""}
on:select={updateForLightbox}
/>
</Block>
{/if}
</div>
<style>
.wrapper {
padding: 2px;
width: 100%;
}
</style>

View File

@@ -1,26 +0,0 @@
import { get } from "svelte/store";
import type { WidgetPanelOptions } from "@litegraph-ts/core";
import ComfyWidget from "./ComfyWidget";
import type { ComfyImageResult } from "$lib/nodes/ComfySaveImageNode";
import queueState from "$lib/stores/queueState";
export type ComfyGalleryEntry = [string, string | null]; // <img> src and alt/title, gradio format
export interface ComfyGalleryWidgetOptions extends WidgetPanelOptions {
}
export default class ComfyGalleryWidget extends ComfyWidget<ComfyGalleryWidgetOptions, ComfyGalleryEntry[]> {
override type = "comfy/gallery";
override isVirtual = true;
addImages(images: ComfyImageResult[]) {
this.setValue(this.value.concat(images));
}
override afterQueued() {
let queue = get(queueState)
if (!(typeof queue.queueRemaining === "number" && queue.queueRemaining > 1)) {
this.setValue([])
}
}
}

View File

@@ -1,55 +0,0 @@
import type { IEnumWidget, IEnumWidgetOptions, INumberWidget, LGraphNode, WidgetPanelOptions } from "@litegraph-ts/core";
import ComfyWidget from "./ComfyWidget";
import type { ComfyImageResult } from "$lib/nodes/ComfySaveImageNode";
import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import widgetState from "$lib/stores/widgetState"
export interface ComfyValueControlWidgetOptions extends IEnumWidgetOptions {
}
export default class ComfyValueControlWidget extends ComfyWidget<ComfyValueControlWidgetOptions, string> {
override type = "combo";
targetWidget: INumberWidget;
constructor(name: string, value: string, node: ComfyGraphNode, targetWidget: INumberWidget) {
super(name, value, node)
this.targetWidget = targetWidget;
this.options = { values: ["fixed", "increment", "decrement", "randomize"], serialize: false };
}
override afterQueued() {
var v = this.value;
let min = this.targetWidget.options.min;
let max = this.targetWidget.options.max;
// limit to something that javascript can handle
max = Math.min(1125899906842624, max);
min = Math.max(-1125899906842624, min);
let range = (max - min) / (this.targetWidget.options.step);
//adjust values based on valueControl Behaviour
switch (v) {
case "fixed":
break;
case "increment":
this.targetWidget.value += this.targetWidget.options.step;
break;
case "decrement":
this.targetWidget.value -= this.targetWidget.options.step;
break;
case "randomize":
this.targetWidget.value = Math.floor(Math.random() * range) * (this.targetWidget.options.step) + min;
default:
break;
}
/*check if values are over or under their respective
* ranges and set them to min or max.*/
if (this.targetWidget.value < min)
this.targetWidget.value = min;
if (this.targetWidget.value > max)
this.targetWidget.value = max;
widgetState.widgetStateChanged(this.node.id, this.targetWidget);
}
}

View File

@@ -1,6 +1,5 @@
import type ComfyGraphNode from "$lib/nodes/ComfyGraphNode";
import type { IWidget, LGraphNode, SerializedLGraphNode, Vector2, WidgetCallback, WidgetTypes } from "@litegraph-ts/core";
import widgetState from "$lib/stores/widgetState";
export default abstract class ComfyWidget<T = any, V = any> implements IWidget<T, V> {
name: string;
@@ -27,7 +26,6 @@ export default abstract class ComfyWidget<T = any, V = any> implements IWidget<T
setValue(value: V) {
this.value = value;
widgetState.widgetStateChanged(this.node.id, this);
}
draw?(ctx: CanvasRenderingContext2D, node: LGraphNode, width: number, posY: number, height: number): void;

View File

@@ -0,0 +1,78 @@
<script lang="ts">
import { ImageViewer } from "$lib/ImageViewer";
import { Block } from "@gradio/atoms";
import { Gallery } from "@gradio/gallery";
import type { Styles } from "@gradio/utils";
import type { WidgetLayout } from "$lib/stores/layoutState";
import type { Writable } from "svelte/store";
import type { ComfyGalleryNode } from "$lib/nodes/ComfyWidgetNodes";
import type { FileData as GradioFileData } from "@gradio/upload";
export let widget: WidgetLayout | null = null;
let node: ComfyGalleryNode | null = null;
let nodeValue: Writable<GradioFileData[]> | null = null;
let propsChanged: Writable<number> | null = null;
let option: number | null = null;
$: widget && setNodeValue(widget);
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfyGalleryNode
nodeValue = node.value;
propsChanged = node.propsChanged;
}
};
let style: Styles = {
// grid_cols: [2],
grid: [3],
// object_fit: "cover",
}
let element: HTMLDivElement;
function updateForLightbox() {
// Wait for gradio gallery to show the large preview image, if no timeout then
// the event might fire too early
setTimeout(() => {
const images = element.querySelectorAll<HTMLImageElement>('div.block div > img')
if (images != null) {
images.forEach(ImageViewer.instance.setupImageForLightbox.bind(ImageViewer.instance));
}
ImageViewer.instance.updateOnBackgroundChange();
}, 200)
}
</script>
<div class="wrapper comfy-gallery-widget gr-gallery" bind:this={element}>
{#if widget && node && nodeValue}
<Block variant="solid" padding={false}>
<div class="padding">
<Gallery
bind:value={$nodeValue}
label={widget.attrs.title}
show_label={true}
{style}
root={""}
root_url={""}
on:select={updateForLightbox}
/>
</div>
</Block>
{/if}
</div>
<style>
.wrapper {
padding: 2px;
width: 100%;
}
.padding {
height: 30rem;
}
.wrapper :global(button.thumbnail-lg) {
width: var(--size-32);
}
</style>

View File

@@ -1,35 +1,52 @@
<script lang="ts">
import type { WidgetUIState, WidgetUIStateStore } from "$lib/stores/widgetState";
import type { ComfySliderNode } from "$lib/nodes/index";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { Range } from "@gradio/form";
import { get } from "svelte/store";
export let item: WidgetUIState | null = null;
let itemValue: WidgetUIStateStore | null = null;
import { get, type Writable } from "svelte/store";
export let widget: WidgetLayout | null = null;
let node: ComfySliderNode | null = null;
let nodeValue: Writable<number> | null = null;
let propsChanged: Writable<number> | null = null;
let option: number | null = null;
$: if (item) {
itemValue = item.value;
updateOption(); // don't react on option
$: widget && setNodeValue(widget);
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfySliderNode
nodeValue = node.value;
propsChanged = node.propsChanged;
setOption($nodeValue); // don't react on option
}
};
// I don't know why but this is necessary to watch for changes to node
// properties from ComfyWidgetNode.
$: if (nodeValue !== null && (!$propsChanged || $propsChanged)) {
setOption($nodeValue)
setNodeValue(widget)
node.properties = node.properties
}
function updateOption() {
option = get(itemValue);
function setOption(value: any) {
option = value;
}
function onRelease(e: Event) {
if (itemValue && option) {
$itemValue = option
if (nodeValue && option) {
$nodeValue = option
}
}
</script>
<div class="wrapper gr-range">
{#if item !== null && option !== null}
{#if node !== null && option !== null}
<Range
bind:value={option}
minimum={item.widget.options.min}
maximum={item.widget.options.max}
step={item.widget.options.step}
label={item.widget.name}
minimum={node.properties.min}
maximum={node.properties.max}
step={node.properties.step}
label={widget.attrs.title}
show_label={true}
on:release={onRelease}
on:change

View File

@@ -1,18 +1,39 @@
<script lang="ts">
import type { WidgetUIState, WidgetUIStateStore } from "$lib/stores/widgetState";
import { TextBox } from "@gradio/form";
export let item: WidgetUIState | null = null;
import type { ComfyComboNode } from "$lib/nodes/index";
import { type WidgetLayout } from "$lib/stores/layoutState";
import { get, type Writable } from "svelte/store";
export let widget: WidgetLayout | null = null;
let node: ComfyComboNode | null = null;
let nodeValue: Writable<string> | null = null;
let propsChanged: Writable<number> | null = null;
let itemValue: WidgetUIStateStore | null = null;
$: if (item) { itemValue = item.value; }
$: widget && setNodeValue(widget);
function setNodeValue(widget: WidgetLayout) {
if (widget) {
node = widget.node as ComfySliderNode
nodeValue = node.value;
propsChanged = node.propsChanged;
}
};
// I don't know why but this is necessary to watch for changes to node
// properties from ComfyWidgetNode.
$: if (nodeValue !== null && (!$propsChanged || $propsChanged)) {
setNodeValue(widget)
node.properties = node.properties
}
</script>
<div class="wrapper gr-textbox">
{#if item !== null && itemValue !== null}
{#if node !== null && nodeValue !== null}
<TextBox
bind:value={$itemValue}
label={item.widget.name}
lines={item.widget.options.multiline ? 5 : 1}
max_lines={item.widget.options.multiline ? 5 : 1}
bind:value={$nodeValue}
label={widget.attrs.title}
lines={node.properties.multiline ? 5 : 1}
max_lines={node.properties.multiline ? 5 : 1}
show_label={true}
on:change
on:submit

View File

@@ -1,21 +1,8 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { Pane, Splitpanes } from 'svelte-splitpanes';
import { Button } from "@gradio/button";
import ComfyApp, { type SerializedAppState } from "$lib/components/ComfyApp";
import { Checkbox } from "@gradio/form"
import widgetState from "$lib/stores/widgetState";
import nodeState from "$lib/stores/nodeState";
import uiState from "$lib/stores/uiState";
import { ImageViewer } from "$lib/ImageViewer";
import { download } from "$lib/utils"
import { LGraph, LGraphNode } from "@litegraph-ts/core";
import type { ComfyAPIStatus } from "$lib/api";
import queueState from "$lib/stores/queueState";
import { Page, Navbar, Link, BlockTitle, Block, List, ListItem, Toolbar } from "framework7-svelte"
import { getComponentForWidgetState } from "$lib/utils"
import { Link, Toolbar } from "framework7-svelte"
import { f7 } from "framework7-svelte"
export let subworkflowID: number = -1;

View File

@@ -5,8 +5,6 @@
import { Button } from "@gradio/button";
import ComfyApp, { type SerializedAppState } from "$lib/components/ComfyApp";
import { Checkbox } from "@gradio/form"
import widgetState from "$lib/stores/widgetState";
import nodeState from "$lib/stores/nodeState";
import uiState from "$lib/stores/uiState";
import { ImageViewer } from "$lib/ImageViewer";
import { download } from "$lib/utils"
@@ -18,47 +16,11 @@
let app: ComfyApp | null = null;
let serializedPaneOrder = {};
function serializeAppState(): SerializedAppState {
const graph = app.lGraph;
const serializedGraph = graph.serialize()
return {
createdBy: "ComfyBox",
version: 1,
workflow: serializedGraph,
panes: serializedPaneOrder
}
}
function doAutosave(graph: LGraph): void {
const savedWorkflow = serializeAppState();
localStorage.setItem("workflow", JSON.stringify(savedWorkflow))
}
function doRestore(workflow: SerializedAppState) {
serializedPaneOrder = workflow.panes;
}
onMount(async () => {
if (app)
return
app = $uiState.app = new ComfyApp();
// TODO dedup
app.eventBus.on("nodeAdded", nodeState.nodeAdded);
app.eventBus.on("nodeRemoved", nodeState.nodeRemoved);
app.eventBus.on("configured", nodeState.configureFinished);
app.eventBus.on("cleared", nodeState.clear);
app.eventBus.on("nodeAdded", widgetState.nodeAdded);
app.eventBus.on("nodeRemoved", widgetState.nodeRemoved);
app.eventBus.on("configured", widgetState.configureFinished);
app.eventBus.on("cleared", widgetState.clear);
app.eventBus.on("autosave", doAutosave);
app.eventBus.on("restored", doRestore);
app.api.addEventListener("status", (ev: CustomEvent) => {
queueState.statusUpdated(ev.detail as ComfyAPIStatus);

View File

@@ -5,8 +5,6 @@
import { Button } from "@gradio/button";
import ComfyApp, { type SerializedAppState } from "$lib/components/ComfyApp";
import { Checkbox } from "@gradio/form"
import widgetState from "$lib/stores/widgetState";
import nodeState from "$lib/stores/nodeState";
import uiState from "$lib/stores/uiState";
import { ImageViewer } from "$lib/ImageViewer";
import { download } from "$lib/utils"

View File

@@ -1,22 +1,9 @@
<script lang="ts">
import { onMount } from "svelte";
import { get } from "svelte/store";
import { Pane, Splitpanes } from 'svelte-splitpanes';
import { Button } from "@gradio/button";
import ComfyApp, { type SerializedAppState } from "$lib/components/ComfyApp";
import { Checkbox } from "@gradio/form"
import widgetState from "$lib/stores/widgetState";
import nodeState from "$lib/stores/nodeState";
import uiState from "$lib/stores/uiState";
import { ImageViewer } from "$lib/ImageViewer";
import { download } from "$lib/utils"
import { LGraph, LGraphNode } from "@litegraph-ts/core";
import type { ComfyAPIStatus } from "$lib/api";
import queueState from "$lib/stores/queueState";
import { Page, Navbar, Link, BlockTitle, Block, List, ListItem, Toolbar } from "framework7-svelte"
import { getComponentForWidgetState } from "$lib/utils"
import { f7 } from "framework7-svelte"
export let subworkflowID: number = -1;
let app: ComfyApp = undefined;
@@ -29,24 +16,7 @@
<Page name="subworkflow">
<Navbar title="Workflow {subworkflowID}" backLink="Back" />
{#each Object.entries($widgetState) as [id, ws]}
{@const node = app.lGraph.getNodeById(id)}
<div class:is-executing={$queueState.runningNodeId === node.id}>
<Block>
<label for={String(id)} class={$uiState.unlocked ? "edit-title-label" : ""}>
<BlockTitle>
{node.title}
</BlockTitle>
</label>
{#each $widgetState[id] as item}
<svelte:component this={getComponentForWidgetState(item)} {item} />
{/each}
{#if $uiState.unlocked}
<div class="handle" on:mousedown={startDrag} on:touchstart={startDrag} on:mouseup={stopDrag} on:touchend={stopDrag}/>
{/if}
</Block>
</div>
{/each}
<div>Workflow!</div>
</Page>
<style>

View File

@@ -6,7 +6,12 @@
"module": "ESNext",
"resolveJsonModule": true,
"allowJs": true,
"checkJs": true
"checkJs": true,
"baseUrl": "./src",
"paths": {
"$lib": ["lib"],
"$lib/*": ["lib/*"]
}
},
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
"references": [{ "path": "./tsconfig.node.json" }]