Compare commits

..

135 Commits

Author SHA1 Message Date
Chenlei Hu
0117964ca5 1.3.3 (#1036) 2024-09-28 11:22:19 +09:00
Chenlei Hu
9d110d39b2 Make action bar draggable (#1035)
* Basic draggable

* Nowrap

* Prevent double reset

* Persist position

* nit

* nit

* Window resize adjustment

* Fix playwright test
2024-09-28 11:21:38 +09:00
Chenlei Hu
8d7693e5ad 1.3.2 (#1024) 2024-09-27 15:31:48 +09:00
Yoland Yan
ec9a30d269 Minor: change app-menu bottom spacing (#1020) 2024-09-27 15:10:13 +09:00
Chenlei Hu
56fc2dd753 Update litegraph (Canvas readonly attr change event) (#1019) 2024-09-27 15:00:54 +09:00
Chenlei Hu
9050591ff9 Update litegraph (Drag cursor shape) (#1014) 2024-09-27 10:51:52 +09:00
Alex "mcmonkey" Goodwin
0cf21b190c don't remove invalid dropdown values (#998)
for #963
2024-09-27 09:03:55 +09:00
Alex "mcmonkey" Goodwin
2531ec178e minor css improvement with editable text (#990) 2024-09-27 08:26:24 +09:00
Alex "mcmonkey" Goodwin
81119acaf2 add a hotkey for settings (#991)
* add a hotkey for settings

for #942

* playwright test for settings menu hotkey

* make hotkey intercompatible with both old and new UI
2024-09-27 08:25:56 +09:00
Chenlei Hu
05f999903d 1.3.1 (#989) 2024-09-26 16:23:35 +09:00
Chenlei Hu
66c02d1e3a Revert "Mark Comfy.Workflow.ShowMissingModelsWarning as stable & enabled by d…" (#988)
This reverts commit cdaa0bda5b.
2024-09-26 16:19:09 +09:00
Chenlei Hu
a05df99a8a Update litegraph (Link copy) (#986)
* Update litegraph (Link copy)

* Update readme
2024-09-26 16:06:59 +09:00
Chenlei Hu
e200e2f89c Add model info for flux workflow template (#987) 2024-09-26 16:06:37 +09:00
Chenlei Hu
cdaa0bda5b Mark Comfy.Workflow.ShowMissingModelsWarning as stable & enabled by default (#985) 2024-09-26 15:10:17 +09:00
Chenlei Hu
a41f3b1ac6 Hide Comfy.Workflow.WorkflowTabsPosition (#984) 2024-09-26 15:10:03 +09:00
Chenlei Hu
3585cb69f5 Allow extension register custom topbar menu command (#982)
* Refactor command store

* Rename coreMenuStore to menuStore

* Extension API to register command

* Update README

* Add playwright test
2024-09-26 10:44:15 +09:00
Chenlei Hu
a53f0ba4db 1.3.0 (#981) 2024-09-26 08:51:13 +09:00
huchenlei
7300f6edc2 Format 2024-09-26 08:48:51 +09:00
christian-byrne
1126eaa346 Add test workflow 2024-09-26 08:48:51 +09:00
christian-byrne
7e5d82d0e8 Use visibility of textarea to determine if loaded correctly 2024-09-26 08:48:51 +09:00
christian-byrne
2b2b1cdb85 Add Playwright test 2024-09-26 08:48:51 +09:00
christian-byrne
0b7c1609fd Fix nodes with only optional inputs 2024-09-26 08:48:51 +09:00
Chenlei Hu
7760f91a56 Revert "Disable broken playwright test by backend change (#967)"
This reverts commit da651eee6f.
2024-09-25 19:20:08 +09:00
七海千秋
58d8ab40c4 add missing i18n messages
added all missing chinese messages
2024-09-25 19:18:59 +09:00
Chenlei Hu
4ab3aa9a39 Backward compatibility with extension injections on legacy menu bar (#970)
* Compatible to legacy top menu extensions

* Rework css

* nit
2024-09-25 16:01:50 +09:00
AustinMroz
9199639320 Reset FileInput value after load (#958)
When a file is browsed for, the fileInput remains associated with the
chosen file even after the associated workflow is loaded. If a user
attempts to load the same file again, the onchange event does not fire
and loading fails silently. This is fixed by clearing the fileInput
after the workflow has been loaded.

Out of an abundance of caution, the onchange is made async to ensure the
fileInput isn't cleared until after the workflow has loaded.

Add a test to check if the same workflow file can be loaded
consecutively.
2024-09-25 16:01:50 +09:00
Chenlei Hu
5ee0fd3519 Rework queue button (#968)
* Move queue button to right side

* Rework split button

* Group

* Remove unused code

* x2 buttons

* Use primevue divider

* adjust style

* Add tooltip

* Update test

* Add clearing pending tasks button to queue bar

* Fix state

* Dropdown list fix
2024-09-25 16:01:50 +09:00
AustinMroz
59976ea357 Reduce SearchBox margins for lower resolutions. (#959)
On lower screen widths, the SearchBox would scale itself down instead of
reducing the fairly wide margins. This wastes space and has reduces the
usability of the search box contents itself by cutting off information
(such as the experimental badge) on nodes with medium or longer titles

This is not without side effects, so further adjustments may be needed.
Currently, the searchbox is slightly offset to the right even for wide
screens and the adjustments are disabled for very small screens (<=768)
such that the preview is offscreen, but the entirety of the searchbox is
properly displayed down to 512
2024-09-25 16:01:50 +09:00
Chenlei Hu
62bddded37 Move clipspace from action bar to topbar dropdown menu (#956) 2024-09-25 16:01:50 +09:00
Alex "mcmonkey" Goodwin
35a7c81fd8 initial download-folder-selector interface (#890)
* initial download-folder-selector interface

* use primevue select

* add a folder select visibility checkbox

* slightly reduce indirection

* fix up select box updating

* revert bad upstream changes

* cleanup

* allow localhost sourced models in ui side

(for testing purposes only basically, but does no harm in deployed envs)

* add screenshot expectations to test

* Update test expectations [skip ci]

* add testing of folder select

* fix test

* don't exclude folder selector when there's only 1

since the checkbox covers that better anyway

* oo - fix checkbox

* Update test expectations [skip ci]

* testing - don't expect screenshots :(

* experimental new test code

* toHaveClass is silly

* add // comments documenting intent of allowedSources

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-25 16:01:50 +09:00
AustinMroz
30469a6d88 Support redo with Ctrl+Shift+Z (#957) 2024-09-25 16:01:50 +09:00
Alex "mcmonkey" Goodwin
d2d645f74b better badges for empty/loading model library folders (#953)
* better badges for empty/loading model library folders

for #945

* fix total count on loaded nodes

* fix test break

* additional test fix

* use a null prop fallback instead of having to explicitly calc

* patch
2024-09-25 16:01:50 +09:00
Chenlei Hu
4e5bcd6a3b Migrate deprecated setting values (#954) 2024-09-25 16:01:50 +09:00
Chenlei Hu
6a8287e192 Show opened workflows as topbar tabs (#952)
* Basic tab switching

* Closing tabs

* Style buttons

* wip

* Fix scroll style

* Add setting

* Add playwright test

* Add unsaved status

* nit
2024-09-25 16:01:50 +09:00
Alex "mcmonkey" Goodwin
7b344d5629 Fix routing (#939)
* reinstate working routing code and remove broken code

* forward object_info

* remove object_info bit
2024-09-25 16:01:50 +09:00
Alex "mcmonkey" Goodwin
35579e644e don't show redundant model previews (#949)
for #944
2024-09-25 16:01:50 +09:00
Chenlei Hu
1bc78a716e Fix action bar commands (#946) 2024-09-25 16:01:50 +09:00
Chenlei Hu
0d28c108d2 Add topbar dropdown menu (#937)
* Add basic menu

* Add workflows/edit to menu bar

* Add command store

* Fix z-index

* Fix beta menu setting switch

* nit

* Drop to center

* Fix command invocation
2024-09-25 16:01:50 +09:00
Alex "mcmonkey" Goodwin
6a158d46b8 [Draft] Model library sidebar tab (#837)
* basic/empty model library sidebar tab

in-progress

* make it actually list out models

* extremely primitive search impl

* list out available folders

(incomplete list atm)

* load list dynamically

* nice lil loading icon

* that's not doing anything

* run autoformatter

* fix up some absolute vue shenanigans

* swap to pi-box

* is_fake_object

* i think apply the tailwind thingo

* trim '.safetensors' from end of display title

* oop

* after load, retain title if no new title is given

* is_load_requested to prevent duplication

* dirty initial model metadata load & preview

based on node preview code

* update model store tests

* initial image icon for model lib

* i hate this

* better empty spacer

* add api handler for '/models'

* load model folders list instead of hardcoding

* add a 'no content' placeholder for empty folders

* autoformat

* autoload model metadata

* error handling on metadata loading

* larger model icons

* click a model to spawn a node for it

* draggable model nodes

* add a setting for whether to autoload or not

* autoformat will be the death of me

* cleanup promise code

* make the model preview actually half-decent

* revert bad unchecked change

* put registration back
2024-09-25 16:01:50 +09:00
pythongosssss
bf7652227a Workflow templates (#938)
* Add template gallery

* Add simple test

* Add examples

* Enable floating menu in test
2024-09-25 16:01:50 +09:00
Chenlei Hu
2aaee5c331 Remove support of Top/Bottom in menu positions (#933)
* Remove support of Top/Bottom in menu positions

* Update menu positions in test setting

* nit
2024-09-25 16:01:50 +09:00
huchenlei
fa2884f9b2 Resolve merge conflict 2024-09-25 16:01:50 +09:00
ArtificialLab
9c7ea5bd87 Fix routing (#929)
* fix router and move graph related parts to GraphView.vue

* (fix) add back child element in UnloadWindowConfirmDialog

* (cleanup) remove empty callback

* (fix) routing issue when base url is not webroot

* add back DEV_SERVER_COMFYUI_URL
2024-09-25 16:01:50 +09:00
Chenlei Hu
5e51ae37cf Relands "Fix routing and layout issue" (#931)
* Revert "Revert "Fix routing and layout issue (#923)" (#930)"

This reverts commit 1e2dfea173.

* Fix merge conflicts
2024-09-25 16:01:50 +09:00
Chenlei Hu
4fa3a38f98 Revert "Fix routing and layout issue (#923)" (#930)
This reverts commit 94db2e90da.
2024-09-25 16:01:50 +09:00
ArtificialLab
d735513e60 Fix routing and layout issue (#923)
* fix router and move graph related parts to GraphView.vue

* (fix) add back child element in UnloadWindowConfirmDialog

* (cleanup) remove empty callback
2024-09-25 16:01:50 +09:00
filtered
38c2ec7532 Fix workflow search cannot find uppercase letters (#908) 2024-09-25 16:01:50 +09:00
Chenlei Hu
f4d4cc3439 Move workflow dropdown to sidebar tab (#893)
* Initial move to sidebar

Remove broken CSS

Move action buttons

Migrate open workflows

Add basic browse

WIP

Add insert support

Remove legacy workflow manager

Remove unused CSS

Reorder

Remove legacy workflow UI

nit

* Support bookmark

Add workflow bookmark store

nit

Add back bookmark functionality

Correctly load bookmarks

nit

Fix many other issues

Fix this binding

style divider

* Extract tree leaf component

* Hide bookmark section when no bookmarks

* nit

* Fix save

* Add workflows searchbox

* Add search support

* Show total opened

* Add basic test

* Add more tests

* Fix redo/undo test

* Temporarily disable browser tab title test
2024-09-25 16:01:50 +09:00
Chenlei Hu
4ae066c57d Proxy ComfyWorkflow objects (#869)
* Proxy ComfyWorkflow objects

* nit
2024-09-25 16:01:50 +09:00
pythongosssss
2d1ff64951 Floating menu option (#726)
* Add floating menu

* Fix

* Updates

* Add auto-queue change test

* Fix
2024-09-25 16:01:50 +09:00
Chenlei Hu
73a7f7dae0 1.2.64 (#972) 2024-09-25 15:56:09 +09:00
Chenlei Hu
cf6367b649 Fix node bypass color (#971)
* Fix node bypass color

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-25 15:54:05 +09:00
bymyself
84fc0e9205 Fix group node naming compatibility (#969)
* Convery legacy group node names in workflow

* Add Playwright test

* Remove hardcoded strings
2024-09-25 15:25:08 +09:00
bymyself
941f71faea Move groupnode test teardown logic to afterEach (#965) 2024-09-25 11:53:52 +09:00
Chenlei Hu
da651eee6f Disable broken playwright test by backend change (#967) 2024-09-25 11:41:11 +09:00
Chenlei Hu
eed00f97f9 Disable jest test on audio_stable_audio_example.flac.json (#966)
* Disable jest test on audio_stable_audio_example.flac.json

* nit

* nit
2024-09-25 11:33:23 +09:00
Chenlei Hu
2387a5e9bd 1.2.63 (#955) 2024-09-24 16:30:45 +09:00
bymyself
6b9c1b70ba Fix group node bookmarking (#950)
* Resolves #926 group node bookmark

* Remove expect outside scope of test

* Update unit tests

* Update group node manager path separators

* Update group node path sepator in fixture
2024-09-24 16:26:02 +09:00
bymyself
b21c0f59f9 Apply node opacity setting to all node colors (#947)
* Apply opacity to node colors. Resolves #928

* Handle default and custom colors all in draw handler

* Add colorUtil unit tests

* Add Playwright test

* Remove comment

* Revert colorPalette.ts changes

* Remove unused imports

* Fix typo

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-24 16:19:53 +09:00
dependabot[bot]
5d8e8a2486 Bump rollup from 4.22.0 to 4.22.4 (#951)
Bumps [rollup](https://github.com/rollup/rollup) from 4.22.0 to 4.22.4.
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.22.0...v4.22.4)

---
updated-dependencies:
- dependency-name: rollup
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-24 14:57:46 +09:00
Alex "mcmonkey" Goodwin
423df92ff8 allow browser tests update off main branch (#940)
why was this blocked??
2024-09-23 19:43:04 +09:00
Chenlei Hu
04a950d7f5 More robust group node playwright test (#935)
* More robust group node playwright test

* nit

* nit
2024-09-23 14:30:45 +09:00
Chenlei Hu
65560604a8 Revert "test to validate subrouting (#927)" (#936)
This reverts commit 6a3dbe08de.
2024-09-23 14:30:17 +09:00
Chenlei Hu
78dea484c9 Fix type for Comfy.Sidebar.Size in apiTypes (#932) 2024-09-23 11:31:18 +09:00
Alex "mcmonkey" Goodwin
6a3dbe08de test to validate subrouting (#927)
* test to validate subrouting

* Update test expectations [skip ci]

* core tests need to prep the page

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-23 09:24:49 +09:00
Chenlei Hu
9aa976fdf0 Show total/free ram in about page (#924) 2024-09-22 17:40:40 +09:00
Chenlei Hu
39eeda8430 1.2.62 (#922) 2024-09-22 16:40:00 +09:00
Chenlei Hu
2878952b1d Makes forceInput node input slot correctly reflect option/required state (#921)
* Correctly style optional force input input slot

* Add force input playwright test

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-22 16:35:56 +09:00
pythongosssss
223a1f677b Fix links being lost after manage group node (#916)
* Fix links being lost after manage group node

* Change to use groupnodebuilder

* Make test more reliable
2024-09-22 16:17:39 +09:00
Chenlei Hu
7b4b40db5b Update litegraph (Slot style) (#919)
* Update litegraph (Slot style)

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-22 16:15:39 +09:00
Chenlei Hu
1052603a17 Revert "Any keyboard layout for Ctrl + V, Z, Y... (#763)" (#920)
This reverts commit 23796d9040.
2024-09-22 16:14:59 +09:00
pythongosssss
4ee1b23e9b Exclude litegraph from being cached (#918) 2024-09-22 15:25:52 +09:00
bymyself
326e0748c0 Add node opacity setting (#909)
* Add node opacity setting

* Add colorUtil unit test

* Add playwright test

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-22 15:18:38 +09:00
ArtificialLab
ea0f74a9f6 Cleanup (#915)
* (update) cleanup:
- move reflect to main.ts
- add config.ts with comfy frontend version
- cleanup index.html and App.vue

* (fix) lint doesn't like branch assignments

* (fix) properly add __COMFYUI_FRONTEND_VERSION__ to ts globals
2024-09-22 10:12:54 +04:00
Chenlei Hu
cdaac0d9bb 1.2.61 (#913) 2024-09-22 12:14:13 +09:00
Chenlei Hu
f749734863 Make optional node input's slot hollow circle (#912)
* Use hollow circle for optional input

* nit

* Show hollow shape for optional input

* Add playwright tests

* Update litegraph

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-22 12:12:48 +09:00
bymyself
a15c4d1612 Fix audio widget serialize option (#910) 2024-09-22 11:54:07 +09:00
bymyself
290bf52fc5 Fix frontend node tooltip error (#911) 2024-09-22 11:52:35 +09:00
dmx
529e889d0e (move) treenode style to style.css 2024-09-22 06:09:56 +04:00
dmx
5a5a69de17 (UI) NodeTree 2024-09-22 06:04:45 +04:00
dmx
194549a4b0 (clean) CSS in App.vue 2024-09-22 06:03:54 +04:00
bymyself
4052fc55f3 Fix node preview styles (#903)
* Use colorPalette to style node previews

* Use widget text secondary color for description

* Remove unused css

* nit

---------

Co-authored-by: huchenlei <chenlei.hu@mail.utoronto.ca>
2024-09-22 09:05:06 +09:00
Chenlei Hu
82d03b5c1b Add colorPalette cleanup for playwright test (#907) 2024-09-22 08:53:01 +09:00
filtered
c7f123766e Add TS types / merge ComfyLGraphNode (#902)
* Add TS type for LGraphNodeConstructor

* Add TS type & move shared prop to parent

* Add TS types - Comfy augmentations

* nit - TS type

* Merge ComfyLGNode into existing augmentations

* nit - fix missed explicit type on import
2024-09-21 18:18:27 +09:00
filtered
88acabb355 Fix TS type on InputSpec (#901) 2024-09-21 14:12:39 +09:00
Chenlei Hu
e5f1eb8609 Update browser tests README (#900) 2024-09-21 10:49:29 +09:00
Chenlei Hu
eb7ab0860d 1.2.60 (#896) 2024-09-20 19:52:09 +09:00
Chenlei Hu
9ed3545b95 Fix frontend node conflicting with node badge (#895) 2024-09-20 19:45:34 +09:00
Chenlei Hu
d223f3865b 1.2.59 (#892) 2024-09-20 09:25:50 +09:00
Chenlei Hu
4538db86cf Add node execution progress to browser title (#891)
* Add node execution progress to browser title

* nit

* nit
2024-09-20 09:22:09 +09:00
dependabot[bot]
3931cae044 Bump vite from 5.3.3 to 5.4.6 (#889)
Bumps [vite](https://github.com/vitejs/vite/tree/HEAD/packages/vite) from 5.3.3 to 5.4.6.
- [Release notes](https://github.com/vitejs/vite/releases)
- [Changelog](https://github.com/vitejs/vite/blob/v5.4.6/packages/vite/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite/commits/v5.4.6/packages/vite)

---
updated-dependencies:
- dependency-name: vite
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-09-19 20:11:34 +09:00
Chenlei Hu
810a63f808 Support async hooks in TreeExplorerNode (#888)
* Support async hooks in TreeExplorerNode

* rebase

* nit

* Fix component test failure

* Add edit vitest

* Add more tests

* Add component test
2024-09-19 20:10:43 +09:00
Chenlei Hu
609984d400 No selection on tree node if selectionKeys prop is not set (#887) 2024-09-19 16:48:56 +09:00
Chenlei Hu
a57c958058 Bind extra context menu items on TreeExplorerNode interface (#886) 2024-09-19 14:51:07 +09:00
Chenlei Hu
b6dbe8f07b Shorten node source package name by remove ComfyUI prefix/suffix (#883)
* Shorten node source package name by remove ComfyUI prefix/suffix

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-19 12:40:05 +09:00
Chenlei Hu
29d69338ef 1.2.58 (#882) 2024-09-19 12:00:45 +09:00
Chenlei Hu
98de010811 Fix node searchbox filter removal (#881) 2024-09-19 11:58:29 +09:00
Chenlei Hu
63302a6634 Fix sorting on type filter + empty query (#880)
* Fix sorting on type filter + empty query

* nit

* nit
2024-09-19 11:22:23 +09:00
Chenlei Hu
8568e037bf Sort search result by node frequency (#879)
* Sort search result by node frequency

* Fix jest test
2024-09-19 10:09:54 +09:00
Chenlei Hu
6c4143ca94 Show node by frequency on empty query (#878) 2024-09-19 09:35:22 +09:00
Chenlei Hu
efa2fa269d 1.2.57 (#868) 2024-09-18 09:38:27 +09:00
Chenlei Hu
a2cf6a7be2 Refactor TreeExplorer (Add handleClick hook) (#867)
* Refactor TreeExplorer (Add handleClick hook)

* nit
2024-09-18 09:36:21 +09:00
bymyself
e493473c35 Add tests on using group nodes in library sidebar (#864)
* Add tests on adding group node from library sidebar

* Improve test name clarity
2024-09-18 09:09:35 +09:00
MaraScott
415a2e7fa5 add square pointer (#848)
* add square pointer

* create enum + refactorize to create init_shape and draw_shape methods
2024-09-17 17:24:07 +09:00
Chenlei Hu
ba9a3b4a9b Move workflows management to pinia (#862) 2024-09-17 17:15:20 +09:00
bymyself
174c52958f Add test on mobile canvas panning (#863)
* Add test on mobile canvas panning

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-17 17:15:05 +09:00
Chenlei Hu
4e41db2d6a [Beta Menu] Shows unsaved state on browser tab title (#860)
* [Beta Menu] Shows unsaved state on browser tab title

* Proper state management

* Add playwright test

* Fix browser tests
2024-09-17 16:14:06 +09:00
bymyself
e8daebdc0c Add group nodes to search and node library (#861)
* Register group nodes in nodeDefStore

* Add playwright tests

* Update test expectations [skip ci]

* Mock nodeDefStore in group node unit test

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-17 16:06:58 +09:00
Chenlei Hu
582acd7bd1 1.2.56 (#859) 2024-09-17 11:17:26 +09:00
Chenlei Hu
48fe14e263 [Beta Menu] Show active workflow name on browser tab title (#857) 2024-09-17 11:11:52 +09:00
bymyself
f9fd0f59ff Add nullcheck to snap-to-grid setting (#858) 2024-09-17 11:11:39 +09:00
Chenlei Hu
3fe4b4b856 Add generation progress to browser tab title (#855) 2024-09-17 10:31:29 +09:00
bymyself
c510b344af Allow zero as input slider min/max (#854) 2024-09-17 09:43:32 +09:00
Chenlei Hu
980dd285ad Revert move floating menu to Vue (#853) 2024-09-17 09:33:25 +09:00
Chenlei Hu
2b60244e4a Move setting declarations from ui to coreSettings (#847)
* Move setting declarations from ui to coreSettings

* nit

* nit

* Move effect to vue component
2024-09-16 17:47:47 +09:00
Chenlei Hu
45a866f194 Fix ComfyUI class setup procedure (#846) 2024-09-16 17:14:11 +09:00
Chenlei Hu
091b8a74fb Mock settingStore (#845) 2024-09-16 16:25:32 +09:00
Chenlei Hu
74fa4a2c2d Move inlined settings in settingsStore to a separate file (#844) 2024-09-16 14:46:06 +09:00
Chenlei Hu
327b67a022 Move floating menu to a Vue component (#843)
* Move floating menu to a Vue component

* nit

* Fix jest tests
2024-09-16 14:26:46 +09:00
Chenlei Hu
d0a4db5f4f Update litegraph (Copy connection by shift drag from path) (#841)
* Add playwright tests

* Update lg

* nit

* nit

* Skip tests

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-09-16 12:02:48 +09:00
Chenlei Hu
861eaa155f Refactor test fixture dnd (#840)
* Refactor test fixture dnd

* nit
2024-09-16 10:13:25 +09:00
Chenlei Hu
3550e7f7f1 Show bookmark icon on booked mark nodes in node search box (#839) 2024-09-15 17:31:44 +09:00
Chenlei Hu
7d25d976d1 Extract search option as a Vue component (#838) 2024-09-15 17:15:19 +09:00
Chenlei Hu
7025e321de 1.2.55 (#836) 2024-09-15 11:22:27 +09:00
huchenlei
429fa75fcc Update litegraph (Fix group right click) 2024-09-15 11:03:34 +09:00
Chenlei Hu
347563adf9 Move json format functions to formatUtil (#834) 2024-09-15 09:46:34 +09:00
Chenlei Hu
9bdb3c0332 1.2.54 (#830) 2024-09-14 17:12:50 +09:00
Chenlei Hu
12c699cc87 Update litegraph (Getters) (#829)
* Update litegraph (Getters)

* Update
2024-09-14 17:10:57 +09:00
Chenlei Hu
588cfeca4b Replace ComfyApp.runningNodeId with executionStore.executingNodeId (#828)
* Replace ComfyApp.runningNodeId with executionStore.executingNodeId

* nit
2024-09-14 16:01:37 +09:00
Chenlei Hu
f983f42c45 Add executionStore (#827)
* Extract execution store

* Fix executing nodes highlight

* nit

* nit

* nit
2024-09-14 15:15:15 +09:00
Chenlei Hu
fef780a72f Make useTreeExpansion hook accept expandedKeys as param (#826) 2024-09-14 11:27:38 +09:00
Chenlei Hu
ebdcd92977 Extract error handling with toast message as hook (#825) 2024-09-14 11:25:08 +09:00
filtered
c98ea5ba01 Use LiteGraph validation for node search->create (#822)
Adds LiteGraph type to augmentation until LG types are auto-generated
Removes @ts-expect-error
2024-09-14 08:36:48 +09:00
filtered
48f84a46cd Add apiTypes present in docs but missing in zod (#821)
* Add apiTypes present in docs but missing in zod

* Fix prettier check
2024-09-14 08:35:32 +09:00
filtered
9483cfe915 Add graceful correction when widgets undef. (#820)
Fixes crash on load of workflow where `node.widgets` has no `.find()`
2024-09-14 08:33:43 +09:00
180 changed files with 12009 additions and 3409 deletions

View File

@@ -16,4 +16,4 @@ DEPLOY_COMFYUI_DIR=/home/ComfyUI/web
EXAMPLE_REPO_PATH=tests-ui/ComfyUI_examples
# Whether to enable minification of the frontend code.
ENABLE_MINIFY=true
ENABLE_MINIFY=true

View File

@@ -5,7 +5,6 @@ name: Update Playwright Expectations
on:
pull_request:
types: [ labeled ]
branches: [ main, master ]
jobs:
test:

View File

@@ -90,6 +90,13 @@ https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
### QoL changes
<details>
<summary>v1.3.1: **Litegraph** Shift drag link to create a new link</summary>
[rec.webm](https://github.com/user-attachments/assets/7e73aaf9-79e2-4c3c-a26a-911cba3b85e4)
</details>
<details>
<summary>v1.2.44: **Litegraph** Double click group title to edit</summary>
@@ -137,6 +144,37 @@ https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
</details>
### Node developers API
<details>
<summary>v1.3.1: Extension API to register custom topbar menu items</summary>
Extensions can call the following API to register custom topbar menu items.
```js
app.extensionManager.menu.registerTopbarCommands(["ext", "ext2"], [{id:"foo", label: "foo", function: () => alert(1)}])
```
![image](https://github.com/user-attachments/assets/ae7b082f-7ce9-4549-a446-4563567102fe)
</details>
<details>
<summary>v1.2.27: Extension API to add toast message</summary>i
Extensions can call the following API to add toast messages.
```js
app.extensionManager.toast.add({
severity: 'info',
summary: 'Loaded!',
detail: 'Extension loaded!',
life: 3000
})
```
Documentation of all supported options can be found here: <https://primevue.org/toast/#api.toast.interfaces.ToastMessageOptions>
![image](https://github.com/user-attachments/assets/de02cd7e-cd81-43d1-a0b0-bccef92ff487)
</details>
<details>
<summary>v1.2.4: Extension API to register custom sidebar tab</summary>
@@ -162,24 +200,6 @@ We will support custom icons later.
![image](https://github.com/user-attachments/assets/7bff028a-bf91-4cab-bf97-55c243b3f5e0)
</details>
<details>
<summary>v1.2.27: Extension API to add toast message</summary>
Extensions can call the following API to add toast messages.
```js
app.extensionManager.toast.add({
severity: 'info',
summary: 'Loaded!',
detail: 'Extension loaded!',
life: 3000
})
```
Documentation of all supported options can be found here: <https://primevue.org/toast/#api.toast.interfaces.ToastMessageOptions>
![image](https://github.com/user-attachments/assets/de02cd7e-cd81-43d1-a0b0-bccef92ff487)
</details>
## Road Map
### What has been done

View File

@@ -1,9 +1,14 @@
import type { Page, Locator } from '@playwright/test'
import type { Page, Locator, APIRequestContext } from '@playwright/test'
import { expect } from '@playwright/test'
import { test as base } from '@playwright/test'
import { ComfyAppMenu } from './helpers/appMenu'
import dotenv from 'dotenv'
dotenv.config()
import * as fs from 'fs'
import { NodeBadgeMode } from '../src/types/nodeSource'
import { NodeId } from '../src/types/comfyWorkflow'
import { ManageGroupNode } from './helpers/manageGroupNode'
import { ComfyTemplates } from './helpers/templates'
interface Position {
x: number
@@ -15,9 +20,37 @@ interface Size {
height: number
}
class ComfyNodeSearchFilterSelectionPanel {
constructor(public readonly page: Page) {}
async selectFilterType(filterType: string) {
await this.page
.locator(
`.filter-type-select .p-togglebutton-label:has-text("${filterType}")`
)
.click()
}
async selectFilterValue(filterValue: string) {
await this.page.locator('.filter-value-select .p-select-dropdown').click()
await this.page
.locator(
`.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")`
)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.selectFilterType(filterType)
await this.selectFilterValue(filterValue)
await this.page.locator('.p-button-label:has-text("Add")').click()
}
}
class ComfyNodeSearchBox {
public readonly input: Locator
public readonly dropdown: Locator
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
constructor(public readonly page: Page) {
this.input = page.locator(
@@ -26,6 +59,11 @@ class ComfyNodeSearchBox {
this.dropdown = page.locator(
'.comfy-vue-node-search-container .p-autocomplete-list'
)
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
}
get filterButton() {
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
}
async fillAndSelectFirstNode(
@@ -43,11 +81,28 @@ class ComfyNodeSearchBox {
.nth(options?.suggestionIndex || 0)
.click()
}
async addFilter(filterValue: string, filterType: string) {
await this.filterButton.click()
await this.filterSelectionPanel.addFilter(filterValue, filterType)
}
get filterChips() {
return this.page.locator(
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
)
}
async removeFilter(index: number) {
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
}
}
class NodeLibrarySidebarTab {
public readonly tabId: string = 'node-library'
constructor(public readonly page: Page) {}
class SidebarTab {
constructor(
public readonly page: Page,
public readonly tabId: string
) {}
get tabButton() {
return this.page.locator(`.${this.tabId}-tab-button`)
@@ -59,6 +114,19 @@ class NodeLibrarySidebarTab {
)
}
async open() {
if (await this.selectedTabButton.isVisible()) {
return
}
await this.tabButton.click()
}
}
class NodeLibrarySidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'node-library')
}
get nodeLibrarySearchBoxInput() {
return this.page.locator('.node-lib-search-box input[type="text"]')
}
@@ -80,12 +148,17 @@ class NodeLibrarySidebarTab {
}
async open() {
if (await this.selectedTabButton.isVisible()) {
await super.open()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
}
async close() {
if (!this.tabButton.isVisible()) {
return
}
await this.tabButton.click()
await this.nodeLibraryTree.waitFor({ state: 'visible' })
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
}
folderSelector(folderName: string) {
@@ -105,19 +178,125 @@ class NodeLibrarySidebarTab {
}
}
class WorkflowsSidebarTab extends SidebarTab {
constructor(public readonly page: Page) {
super(page, 'workflows')
}
get browseGalleryButton() {
return this.page.locator('.browse-templates-button')
}
get newBlankWorkflowButton() {
return this.page.locator('.new-blank-workflow-button')
}
get browseWorkflowsButton() {
return this.page.locator('.browse-workflows-button')
}
get newDefaultWorkflowButton() {
return this.page.locator('.new-default-workflow-button')
}
async getOpenedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-open .node-label')
.allInnerTexts()
}
async getTopLevelSavedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-browse .node-label')
.allInnerTexts()
}
async switchToWorkflow(workflowName: string) {
const workflowLocator = this.page.locator(
'.comfyui-workflows-open .node-label',
{ hasText: workflowName }
)
await workflowLocator.click()
await this.page.waitForTimeout(300)
}
}
class Topbar {
constructor(public readonly page: Page) {}
async getTabNames(): Promise<string[]> {
return await this.page
.locator('.workflow-tabs .workflow-label')
.allInnerTexts()
}
async triggerTopbarCommand(path: string[]) {
if (path.length < 2) {
throw new Error('Path is too short')
}
const tabName = path[0]
const topLevelMenu = this.page.locator(
`.top-menubar .p-menubar-item:has-text("${tabName}")`
)
await topLevelMenu.waitFor({ state: 'visible' })
await topLevelMenu.click()
for (let i = 1; i < path.length; i++) {
const commandName = path[i]
const menuItem = this.page.locator(
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
)
await menuItem.waitFor({ state: 'visible' })
await menuItem.hover()
if (i === path.length - 1) {
await menuItem.click()
}
}
}
}
class ComfyMenu {
public readonly sideToolbar: Locator
public readonly themeToggleButton: Locator
public readonly saveButton: Locator
constructor(public readonly page: Page) {
this.sideToolbar = page.locator('.side-tool-bar-container')
this.themeToggleButton = page.locator('.comfy-vue-theme-toggle')
this.saveButton = page
.locator('button[title="Save the current workflow"]')
.nth(0)
}
async saveWorkflow(name: string) {
const acceptDialog = async (dialog) => {
await dialog.accept(name)
}
this.page.on('dialog', acceptDialog)
await this.saveButton.click()
// Wait a moment to ensure the dialog has been handled
await this.page.waitForTimeout(300)
// Remove the dialog listener
this.page.off('dialog', acceptDialog)
}
get nodeLibraryTab() {
return new NodeLibrarySidebarTab(this.page)
}
get workflowsTab() {
return new WorkflowsSidebarTab(this.page)
}
get topbar() {
return new Topbar(this.page)
}
async toggleTheme() {
await this.themeToggleButton.click()
await this.page.evaluate(() => {
@@ -142,6 +321,10 @@ class ComfyMenu {
}
}
type FolderStructure = {
[key: string]: FolderStructure | string
}
export class ComfyPage {
public readonly url: string
// All canvas position operations are based on default view of canvas.
@@ -158,8 +341,13 @@ export class ComfyPage {
// Components
public readonly searchBox: ComfyNodeSearchBox
public readonly menu: ComfyMenu
public readonly appMenu: ComfyAppMenu
public readonly templates: ComfyTemplates
constructor(public readonly page: Page) {
constructor(
public readonly page: Page,
public readonly request: APIRequestContext
) {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.canvas = page.locator('#graph-canvas')
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
@@ -168,16 +356,55 @@ export class ComfyPage {
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
this.appMenu = new ComfyAppMenu(page)
this.templates = new ComfyTemplates(page)
}
convertLeafToContent(structure: FolderStructure): FolderStructure {
const result: FolderStructure = {}
for (const [key, value] of Object.entries(structure)) {
if (typeof value === 'string') {
const filePath = this.assetPath(value)
result[key] = fs.readFileSync(filePath, 'utf-8')
} else {
result[key] = this.convertLeafToContent(value)
}
}
return result
}
async getGraphNodesCount(): Promise<number> {
return await this.page.evaluate(() => {
return window['app']?.graph?._nodes?.length || 0
return window['app']?.graph?.nodes?.length || 0
})
}
async setup() {
async setupWorkflowsDirectory(structure: FolderStructure) {
const resp = await this.request.post(
`${this.url}/api/devtools/setup_folder_structure`,
{
data: {
tree_structure: this.convertLeafToContent(structure),
base_path: 'user/default/workflows'
}
}
)
if (resp.status() !== 200) {
throw new Error(
`Failed to setup workflows directory: ${await resp.text()}`
)
}
}
async setup({ resetView = true } = {}) {
await this.goto()
await this.page.evaluate(() => {
localStorage.clear()
sessionStorage.clear()
})
// Unify font for consistent screenshots.
await this.page.addStyleTag({
url: 'https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap'
@@ -199,9 +426,11 @@ export class ComfyPage {
window['app']['canvas'].show_info = false
})
await this.nextFrame()
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await this.resetView()
if (resetView) {
// Reset view to force re-rendering of canvas. So that info fields like fps
// become hidden.
await this.resetView()
}
// Hide all badges by default.
await this.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', NodeBadgeMode.None)
@@ -366,25 +595,40 @@ export class ComfyPage {
await this.nextFrame()
}
// Default graph positions
get clipTextEncodeNode1InputSlot(): Position {
return { x: 427, y: 198 }
}
get clipTextEncodeNode2InputSlot(): Position {
return { x: 422, y: 402 }
}
// A point on input edge.
get clipTextEncodeNode2InputLinkPath(): Position {
return {
x: 395,
y: 422
}
}
get loadCheckpointNodeClipOutputSlot(): Position {
return { x: 332, y: 509 }
}
get emptySpace(): Position {
return { x: 427, y: 98 }
}
async disconnectEdge() {
// CLIP input anchor
await this.page.mouse.move(427, 198)
await this.page.mouse.down()
await this.page.mouse.move(427, 98)
await this.page.mouse.up()
// Move out the way to avoid highlight of menu item.
await this.page.mouse.move(10, 10)
await this.nextFrame()
await this.dragAndDrop(this.clipTextEncodeNode1InputSlot, this.emptySpace)
}
async connectEdge() {
// CLIP output anchor on Load Checkpoint Node.
await this.page.mouse.move(332, 509)
await this.page.mouse.down()
// CLIP input anchor on CLIP Text Encode Node.
await this.page.mouse.move(427, 198)
await this.page.mouse.up()
await this.nextFrame()
await this.dragAndDrop(
this.loadCheckpointNodeClipOutputSlot,
this.clipTextEncodeNode1InputSlot
)
}
async adjustWidgetValue() {
@@ -396,9 +640,10 @@ export class ComfyPage {
y: 645
}
})
await page.locator('input[type="text"]').click()
await page.locator('input[type="text"]').fill('128')
await page.locator('input[type="text"]').press('Enter')
const dialogInput = page.locator('.graphdialog input[type="text"]')
await dialogInput.click()
await dialogInput.fill('128')
await dialogInput.press('Enter')
await this.nextFrame()
}
@@ -422,6 +667,24 @@ export class ComfyPage {
await this.nextFrame()
}
async panWithTouch(offset: Position, safeSpot?: Position) {
safeSpot = safeSpot || { x: 10, y: 10 }
const client = await this.page.context().newCDPSession(this.page)
await client.send('Input.dispatchTouchEvent', {
type: 'touchStart',
touchPoints: [safeSpot]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchMove',
touchPoints: [{ x: offset.x + safeSpot.x, y: offset.y + safeSpot.y }]
})
await client.send('Input.dispatchTouchEvent', {
type: 'touchEnd',
touchPoints: []
})
await this.nextFrame()
}
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: 'right' })
await this.nextFrame()
@@ -511,6 +774,11 @@ export class ComfyPage {
await this.nextFrame()
}
async closeDialog() {
await this.page.locator('.p-dialog-close-button').click()
await expect(this.page.locator('.p-dialog')).toBeHidden()
}
async resizeNode(
nodePos: Position,
nodeSize: Size,
@@ -593,11 +861,199 @@ export class ComfyPage {
revertAfter
)
}
async convertAllNodesToGroupNode(groupNodeName: string) {
this.page.on('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.canvas.press('Control+a')
const node = await this.getFirstNodeRef()
await node!.clickContextMenuOption('Convert to Group Node')
await this.nextFrame()
}
async convertOffsetToCanvas(pos: [number, number]) {
return this.page.evaluate((pos) => {
return window['app'].canvas.ds.convertOffsetToCanvas(pos)
}, pos)
}
async getNodeRefById(id: NodeId) {
return new NodeReference(id, this)
}
async getNodeRefsByType(type: string): Promise<NodeReference[]> {
return (
await this.page.evaluate((type) => {
return window['app'].graph.nodes
.filter((n) => n.type === type)
.map((n) => n.id)
}, type)
).map((id: NodeId) => this.getNodeRefById(id))
}
async getFirstNodeRef(): Promise<NodeReference | null> {
const id = await this.page.evaluate(() => {
return window['app'].graph.nodes[0]?.id
})
if (!id) return null
return this.getNodeRefById(id)
}
}
class NodeSlotReference {
constructor(
readonly type: 'input' | 'output',
readonly index: number,
readonly node: NodeReference
) {}
async getPosition() {
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
return window['app'].canvas.ds.convertOffsetToCanvas(
node.getConnectionPos(type === 'input', index)
)
},
[this.type, this.node.id, this.index] as const
)
return {
x: pos[0],
y: pos[1]
}
}
async getLinkCount() {
return await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
return node.inputs[index].link == null ? 0 : 1
}
return node.outputs[index].links?.length ?? 0
},
[this.type, this.node.id, this.index] as const
)
}
async removeLinks() {
await this.node.comfyPage.page.evaluate(
([type, id, index]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error(`Node ${id} not found.`)
if (type === 'input') {
node.disconnectInput(index)
} else {
node.disconnectOutput(index)
}
},
[this.type, this.node.id, this.index] as const
)
}
}
class NodeReference {
constructor(
readonly id: NodeId,
readonly comfyPage: ComfyPage
) {}
async exists(): Promise<boolean> {
return await this.comfyPage.page.evaluate((id) => {
const node = window['app'].graph.getNodeById(id)
return !!node
}, this.id)
}
getType(): Promise<string> {
return this.getProperty('type')
}
async getPosition(): Promise<Position> {
const pos = await this.comfyPage.convertOffsetToCanvas(
await this.getProperty<[number, number]>('pos')
)
return {
x: pos[0],
y: pos[1]
}
}
async getSize(): Promise<Size> {
const size = await this.getProperty<[number, number]>('size')
return {
width: size[0],
height: size[1]
}
}
async getProperty<T>(prop: string): Promise<T> {
return await this.comfyPage.page.evaluate(
([id, prop]) => {
const node = window['app'].graph.getNodeById(id)
if (!node) throw new Error('Node not found')
return node[prop]
},
[this.id, prop] as const
)
}
async getOutput(index: number) {
return new NodeSlotReference('output', index, this)
}
async getInput(index: number) {
return new NodeSlotReference('input', index, this)
}
async click(position: 'title', options?: Parameters<Page['click']>[1]) {
const nodePos = await this.getPosition()
const nodeSize = await this.getSize()
let clickPos: Position
switch (position) {
case 'title':
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y + 15 }
break
default:
throw new Error(`Invalid click position ${position}`)
}
await this.comfyPage.canvas.click({
...options,
position: clickPos
})
await this.comfyPage.nextFrame()
}
async connectOutput(
originSlotIndex: number,
targetNode: NodeReference,
targetSlotIndex: number
) {
const originSlot = await this.getOutput(originSlotIndex)
const targetSlot = await targetNode.getInput(targetSlotIndex)
await this.comfyPage.dragAndDrop(
await originSlot.getPosition(),
await targetSlot.getPosition()
)
return originSlot
}
async clickContextMenuOption(optionText: string) {
await this.click('title', { button: 'right' })
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
this.comfyPage.page.once('dialog', async (dialog) => {
await dialog.accept(groupNodeName)
})
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nextFrame()
const nodes = await this.comfyPage.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
}
async manageGroupNode() {
await this.clickContextMenuOption('Manage Group Node')
await this.comfyPage.nextFrame()
return new ManageGroupNode(
this.comfyPage.page,
this.comfyPage.page.locator('.comfy-group-manage')
)
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
comfyPage: async ({ page }, use) => {
const comfyPage = new ComfyPage(page)
comfyPage: async ({ page, request }, use) => {
const comfyPage = new ComfyPage(page, request)
await comfyPage.setup()
await use(comfyPage)
}

View File

@@ -2,8 +2,16 @@
This document outlines the setup and usage of Playwright for testing the ComfyUI_frontend project.
## WARNING
The browser tests will change the ComfyUI backend state, such as user settings and saved workflows.
Please backup your ComfyUI data before running the tests locally.
## Setup
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing.
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
```bash

View File

@@ -0,0 +1,116 @@
import type { Response } from '@playwright/test'
import type { StatusWsMessage } from '../src/types/apiTypes.ts'
import { expect, mergeTests } from '@playwright/test'
import { comfyPageFixture } from './ComfyPage'
import { webSocketFixture } from './fixtures/ws.ts'
const test = mergeTests(comfyPageFixture, webSocketFixture)
test.describe('AppMenu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
/**
* This test ensures that the autoqueue change mode can only queue one change at a time
*/
test('Does not auto-queue multiple changes at a time', async ({
comfyPage,
ws
}) => {
// Enable change auto-queue mode
const queueOpts = await comfyPage.appMenu.queueButton.toggleOptions()
expect(await queueOpts.getMode()).toBe('disabled')
await queueOpts.setMode('change')
await comfyPage.nextFrame()
expect(await queueOpts.getMode()).toBe('change')
await comfyPage.appMenu.queueButton.toggleOptions()
// Intercept the prompt queue endpoint
let promptNumber = 0
comfyPage.page.route('**/api/prompt', async (route, req) => {
await new Promise((r) => setTimeout(r, 100))
route.fulfill({
status: 200,
body: JSON.stringify({
prompt_id: promptNumber,
number: ++promptNumber,
node_errors: {},
// Include the request data to validate which prompt was queued so we can validate the width
__request: req.postDataJSON()
})
})
})
// Start watching for a message to prompt
const requestPromise = comfyPage.page.waitForResponse('**/api/prompt')
// Find and set the width on the latent node
const triggerChange = async (value: number) => {
return await comfyPage.page.evaluate((value) => {
const node = window['app'].graph._nodes.find(
(n) => n.type === 'EmptyLatentImage'
)
node.widgets[0].value = value
window['app'].workflowManager.activeWorkflow.changeTracker.checkState()
}, value)
}
// Trigger a status websocket message
const triggerStatus = async (queueSize: number) => {
await ws.trigger({
type: 'status',
data: {
status: {
exec_info: {
queue_remaining: queueSize
}
}
}
} as StatusWsMessage)
}
// Extract the width from the queue response
const getQueuedWidth = async (resp: Promise<Response>) => {
const obj = await (await resp).json()
return obj['__request']['prompt']['5']['inputs']['width']
}
// Trigger a bunch of changes
const START = 32
const END = 64
for (let i = START; i <= END; i += 8) {
await triggerChange(i)
}
// Ensure the queued width is the first value
expect(
await getQueuedWidth(requestPromise),
'the first queued prompt should be the first change width'
).toBe(START)
// Ensure that no other changes are queued
await expect(
comfyPage.page.waitForResponse('**/api/prompt', { timeout: 250 })
).rejects.toThrow()
expect(
promptNumber,
'only 1 prompt should have been queued even though there were multiple changes'
).toBe(1)
// Trigger a status update so auto-queue re-runs
await triggerStatus(1)
await triggerStatus(0)
// Ensure the queued width is the last queued value
expect(
await getQueuedWidth(comfyPage.page.waitForResponse('**/api/prompt')),
'last queued prompt width should be the last change'
).toBe(END)
expect(promptNumber, 'queued prompt count should be 2').toBe(2)
})
})

View File

@@ -0,0 +1,135 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [413, 389],
"size": [425.27801513671875, 180.6060791015625],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [{ "name": "clip", "type": "CLIP", "link": 5 }],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [6],
"slot_index": 0
}
],
"properties": {},
"widgets_values": ["text, watermark"]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [415, 186],
"size": [422.84503173828125, 164.31304931640625],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [{ "name": "clip", "type": "CLIP", "link": 3 }],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [4],
"slot_index": 0
}
],
"properties": {},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [473, 609],
"size": [315, 106],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [2], "slot_index": 0 }],
"properties": {},
"widgets_values": [512, 512, 1]
},
{
"id": 3,
"type": "KSampler",
"pos": [863, 186],
"size": [315, 262],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{ "name": "model", "type": "MODEL", "link": 1 },
{ "name": "positive", "type": "CONDITIONING", "link": 4 },
{ "name": "negative", "type": "CONDITIONING", "link": 6 },
{ "name": "latent_image", "type": "LATENT", "link": 2 }
],
"outputs": [{ "name": "LATENT", "type": "LATENT", "links": [7], "slot_index": 0 }],
"properties": {},
"widgets_values": [156680208700286, true, 20, 8, "euler", "normal", 1]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [1209, 188],
"size": [210, 46],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{ "name": "samples", "type": "LATENT", "link": 7 },
{ "name": "vae", "type": "VAE", "link": 8 }
],
"outputs": [{ "name": "IMAGE", "type": "IMAGE", "links": [9], "slot_index": 0 }],
"properties": {}
},
{
"id": 9,
"type": "SaveImage",
"pos": [1451, 189],
"size": [210, 26],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [{ "name": "images", "type": "IMAGE", "link": 9 }],
"properties": {}
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [26, 474],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{ "name": "MODEL", "type": "MODEL", "links": [1], "slot_index": 0 },
{ "name": "CLIP", "type": "CLIP", "links": [3, 5], "slot_index": 1 },
{ "name": "VAE", "type": "VAE", "links": [8], "slot_index": 2 }
],
"properties": {},
"widgets_values": ["v1-5-pruned-emaonly.ckpt"]
}
],
"links": [
[1, 4, 0, 3, 0, "MODEL"],
[2, 5, 0, 3, 3, "LATENT"],
[3, 4, 1, 6, 0, "CLIP"],
[4, 6, 0, 3, 1, "CONDITIONING"],
[5, 4, 1, 7, 0, "CLIP"],
[6, 7, 0, 3, 2, "CONDITIONING"],
[7, 3, 0, 8, 0, "LATENT"],
[8, 4, 2, 8, 1, "VAE"],
[9, 8, 0, 9, 0, "IMAGE"]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1,504 @@
{
"last_node_id": 13,
"last_link_id": 9,
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": {
"0": 863,
"1": 186
},
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
7
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
],
"color": "#432",
"bgcolor": "#653"
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": {
"0": 36,
"1": 172
},
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
1
],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
3,
5
],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [
8
],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"Stable-diffusion/v1-5-pruned-emaonly.safetensors"
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": {
"0": 473,
"1": 609
},
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
2
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
512,
512,
1
],
"color": "#323",
"bgcolor": "#535"
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": {
"0": 415,
"1": 186
},
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
4
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
],
"color": "#233",
"bgcolor": "#355"
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": {
"0": 413,
"1": 389
},
"size": {
"0": 425.27801513671875,
"1": 180.6060791015625
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
6
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"text, watermark"
],
"color": "#323",
"bgcolor": "#535"
},
{
"id": 8,
"type": "VAEDecode",
"pos": {
"0": 866,
"1": 502
},
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEDecode"
},
"widgets_values": [],
"color": "#222",
"bgcolor": "#000"
},
{
"id": 9,
"type": "SaveImage",
"pos": {
"0": 857,
"1": 611
},
"size": [
214.2000732421875,
59.4000244140625
],
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"outputs": [],
"properties": {},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 10,
"type": "CheckpointLoaderSimple",
"pos": {
"0": 42,
"1": 329
},
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": null
},
{
"name": "CLIP",
"type": "CLIP",
"links": null
},
{
"name": "VAE",
"type": "VAE",
"links": null
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"Stable-diffusion/v1-5-pruned-emaonly.safetensors"
],
"color": "#332922",
"bgcolor": "#593930"
},
{
"id": 11,
"type": "CheckpointLoaderSimple",
"pos": {
"0": 40,
"1": 494
},
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": null
},
{
"name": "CLIP",
"type": "CLIP",
"links": null
},
{
"name": "VAE",
"type": "VAE",
"links": null
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"Stable-diffusion/v1-5-pruned-emaonly.safetensors"
],
"color": "#223",
"bgcolor": "#335"
},
{
"id": 13,
"type": "ImageScale",
"pos": {
"0": 42,
"1": 650
},
"size": {
"0": 315,
"1": 130
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "ImageScale"
},
"widgets_values": [
"nearest-exact",
512,
512,
"disabled"
],
"color": "#2a363b",
"bgcolor": "#3f5159"
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
8,
4,
2,
8,
1,
"VAE"
],
[
9,
8,
0,
9,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

View File

@@ -0,0 +1,62 @@
{
"last_node_id": 5,
"last_link_id": 0,
"nodes": [
{
"id": 5,
"type": "DevToolsNodeWithForceInput",
"pos": {
"0": 9,
"1": 39
},
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "int_input",
"type": "INT",
"link": null,
"widget": {
"name": "int_input"
}
},
{
"name": "float_input",
"type": "FLOAT",
"link": null,
"widget": {
"name": "float_input"
},
"shape": 7
}
],
"outputs": [],
"properties": {
"Node name for S&R": "DevToolsNodeWithForceInput"
},
"widgets_values": [
0,
1,
0
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,252 @@
{
"last_node_id": 15,
"last_link_id": 9,
"nodes": [
{
"id": 15,
"type": "workflow/hello",
"pos": {
"0": 566,
"1": 316
},
"size": {
"0": 468.5999755859375,
"1": 582
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null,
"label": "model"
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null,
"label": "positive"
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null,
"label": "negative"
},
{
"name": "latent_image",
"type": "LATENT",
"link": null,
"label": "latent_image"
},
{
"name": "KSampler model",
"type": "MODEL",
"link": null,
"label": "KSampler model"
},
{
"name": "KSampler positive",
"type": "CONDITIONING",
"link": null,
"label": "KSampler positive"
},
{
"name": "KSampler negative",
"type": "CONDITIONING",
"link": null,
"label": "KSampler negative"
},
{
"name": "KSampler latent_image",
"type": "LATENT",
"link": null,
"label": "KSampler latent_image"
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3,
"label": "LATENT"
},
{
"name": "KSampler LATENT",
"type": "LATENT",
"links": null,
"shape": 3,
"label": "KSampler LATENT"
}
],
"properties": {
"Node name for S&R": "workflow/hello"
},
"widgets_values": [
"enable",
0,
"randomize",
20,
8,
"euler",
"normal",
0,
10000,
"disable",
0,
"randomize",
20,
8,
"euler",
"normal",
1
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"groupNodes": {
"hello": {
"nodes": [
{
"id": -1,
"type": "KSamplerAdvanced",
"pos": {
"0": 351.3332824707031,
"1": 577.3333129882812
},
"size": {
"0": 315,
"1": 334
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null,
"label": "model"
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null,
"label": "positive"
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null,
"label": "negative"
},
{
"name": "latent_image",
"type": "LATENT",
"link": null,
"label": "latent_image"
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3,
"label": "LATENT"
}
],
"properties": {
"Node name for S&R": "KSamplerAdvanced"
},
"widgets_values": [
"enable",
0,
"randomize",
20,
8,
"euler",
"normal",
0,
10000,
"disable"
],
"index": 0
},
{
"id": -1,
"type": "KSampler",
"pos": {
"0": 636,
"1": 427
},
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null,
"label": "model"
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null,
"label": "positive"
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null,
"label": "negative"
},
{
"name": "latent_image",
"type": "LATENT",
"link": null,
"label": "latent_image"
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": null,
"shape": 3,
"label": "LATENT"
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
0,
"randomize",
20,
8,
"euler",
"normal",
1
],
"index": 1
}
],
"links": [],
"external": []
}
}
},
"version": 0.4
}

View File

@@ -0,0 +1,44 @@
{
"last_node_id": 11,
"last_link_id": 9,
"nodes": [
{
"id": 11,
"type": "DevToolsNodeWithOnlyOptionalInput",
"pos": {
"0": 150,
"1": 464.2916564941406
},
"size": {
"0": 400,
"1": 200
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": null,
"shape": 7,
"label": "clip"
}
],
"outputs": [],
"properties": {
"Node name for S&R": "DevToolsNodeWithOnlyOptionalInput"
},
"widgets_values": [
""
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"groupNodes": {}
},
"version": 0.4
}

View File

@@ -0,0 +1,57 @@
{
"last_node_id": 5,
"last_link_id": 0,
"nodes": [
{
"id": 5,
"type": "DevToolsNodeWithOptionalInput",
"pos": {
"0": 19,
"1": 46
},
"size": {
"0": 302.4000244140625,
"1": 46
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "required_input",
"type": "IMAGE",
"link": null
},
{
"name": "optional_input",
"type": "IMAGE",
"link": null,
"shape": 7
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsNodeWithOptionalInput"
}
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,56 @@
{
"last_node_id": 5,
"last_link_id": 0,
"nodes": [
{
"id": 5,
"type": "DevToolsNodeWithOptionalInput",
"pos": {
"0": 19,
"1": 46
},
"size": {
"0": 302.4000244140625,
"1": 46
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "required_input",
"type": "IMAGE",
"link": null
},
{
"name": "optional_input",
"type": "IMAGE",
"link": null
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsNodeWithOptionalInput"
}
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,57 @@
{
"last_node_id": 5,
"last_link_id": 0,
"nodes": [
{
"id": 5,
"type": "DevToolsNodeWithOptionalInput",
"pos": {
"0": 19,
"1": 46
},
"size": {
"0": 302.4000244140625,
"1": 46
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "required_input",
"type": "IMAGE",
"link": null
},
{
"name": "optional_input",
"type": "IMAGE",
"link": null,
"shape": 6
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": null
}
],
"properties": {
"Node name for S&R": "DevToolsNodeWithOptionalInput"
}
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,57 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Browser tab title', () => {
test.describe('Beta Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can display workflow name', async ({ comfyPage }) => {
const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].workflowManager.activeWorkflow.name
})
// Note: unsaved workflow name is always prepended with "*".
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
})
// Broken by https://github.com/Comfy-Org/ComfyUI_frontend/pull/893
// Release blocker for v1.3.0
test.skip('Can display workflow name with unsaved changes', async ({
comfyPage
}) => {
const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].workflowManager.activeWorkflow.name
})
// Note: unsaved workflow name is always prepended with "*".
expect(await comfyPage.page.title()).toBe(`*${workflowName}`)
await comfyPage.menu.saveWorkflow('test')
expect(await comfyPage.page.title()).toBe('test')
const textBox = comfyPage.widgetTextBox
await textBox.fill('Hello World')
await comfyPage.clickEmptySpace()
expect(await comfyPage.page.title()).toBe(`*test`)
// Delete the saved workflow for cleanup.
await comfyPage.page.evaluate(async () => {
window['app'].workflowManager.activeWorkflow.delete()
})
})
})
test.describe('Legacy Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can display default title', async ({ comfyPage }) => {
expect(await comfyPage.page.title()).toBe('ComfyUI')
})
})
})

View File

@@ -135,15 +135,116 @@ test.describe('Color Palette', () => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.CustomColorPalettes', {})
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
})
test('Can show custom color palette', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'custom-color-palette-obsidian-dark.png'
)
// Reset to default color palette for other tests
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('default-color-palette.png')
})
})
test.describe('Node Color Adjustments', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.loadWorkflow('every_node_color')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
})
test('should adjust opacity via node opacity setting', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.page.waitForTimeout(128)
// Drag mouse to force canvas to redraw
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-0.5.png')
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
await comfyPage.page.waitForTimeout(128)
await comfyPage.page.mouse.move(8, 8)
await expect(comfyPage.canvas).toHaveScreenshot('node-opacity-1.png')
})
test('should persist color adjustments when changing themes', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.2)
await comfyPage.setSetting('Comfy.ColorPalette', 'arc')
await comfyPage.nextFrame()
await comfyPage.page.mouse.move(0, 0)
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.2-arc-theme.png'
)
})
test('should not serialize color adjustments in workflow', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Node.Opacity', 0.5)
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
const saveWorkflowInterval = 1000
await comfyPage.page.waitForTimeout(saveWorkflowInterval)
const workflow = await comfyPage.page.evaluate(() => {
return localStorage.getItem('workflow')
})
for (const node of JSON.parse(workflow).nodes) {
if (node.bgcolor) expect(node.bgcolor).not.toMatch(/hsla/)
if (node.color) expect(node.color).not.toMatch(/hsla/)
}
})
test('should lighten node colors when switching to light theme', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('node-lightened-colors.png')
})
test.describe('Context menu color adjustments', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.ColorPalette', 'light')
await comfyPage.setSetting('Comfy.Node.Opacity', 0.3)
const node = await comfyPage.getFirstNodeRef()
await node.clickContextMenuOption('Colors')
})
test('should persist color adjustments when changing custom node colors', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("red")')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-changed.png'
)
})
test('should persist color adjustments when removing custom node color', async ({
comfyPage
}) => {
await comfyPage.page
.locator('.litemenu-entry.submenu span:has-text("No color")')
.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'node-opacity-0.3-color-removed.png'
)
})
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

View File

@@ -13,38 +13,13 @@ test.describe('Load workflow warning', () => {
})
})
test('Does not report warning when switching between opened workflows', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.loadWorkflow('missing_nodes')
await comfyPage.page.locator('.p-dialog-close-button').click()
// Load default workflow
const workflowSelector = comfyPage.page.locator(
'button.comfyui-workflows-button'
)
await workflowSelector.hover()
await workflowSelector.click()
await comfyPage.page.locator('button[title="Load default workflow"]').click()
// Switch back to the missing_nodes workflow
await workflowSelector.click()
await comfyPage.page.locator('span:has-text("missing_nodes")').first().click()
await comfyPage.nextFrame()
await expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Does not report warning on undo/redo', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.loadWorkflow('missing_nodes')
await comfyPage.page.locator('.p-dialog-close-button').click()
await comfyPage.nextFrame()
await comfyPage.closeDialog()
// Make a change to the graph
await comfyPage.setSetting('Comfy.NodeSearchBoxImpl', 'default')
await comfyPage.page.waitForTimeout(256)
await comfyPage.doubleClickCanvas()
await comfyPage.searchBox.fillAndSelectFirstNode('KSampler')
@@ -70,37 +45,53 @@ test.describe('Execution error', () => {
test.describe('Missing models warning', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
await comfyPage.page.evaluate((url: string) => {
return fetch(`${url}/api/devtools/cleanup_fake_model`)
}, comfyPage.url)
await comfyPage.setSetting('Comfy.Workflow.ModelDownload.AllowedSources', [
'http://localhost:8188'
])
await comfyPage.setSetting('Comfy.Workflow.ModelDownload.AllowedSuffixes', [
'.safetensors'
])
})
test('Should display a warning when missing models are found', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Workflow.ShowMissingModelsWarning', true)
// The fake_model.safetensors is served by
// https://github.com/Comfy-Org/ComfyUI_devtools/blob/main/__init__.py
await comfyPage.loadWorkflow('missing_models')
// Wait for the element with the .comfy-missing-models selector to be visible
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
// Click the download button
const downloadButton = comfyPage.page.getByLabel('Download')
await expect(downloadButton).toBeVisible()
await downloadButton.click()
// Wait for the element with the .download-complete selector to be visible
const downloadComplete = comfyPage.page.locator('.download-complete')
await expect(downloadComplete).toBeVisible()
})
test('Can configure download folder', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('missing_models')
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).toBeVisible()
const folderSelectToggle = comfyPage.page.locator(
'.model-path-select-checkbox'
)
const folderSelect = comfyPage.page.locator('.model-path-select')
await expect(folderSelectToggle).toBeVisible()
await expect(folderSelect).not.toBeVisible()
await folderSelectToggle.click() // show the selectors
await expect(folderSelect).toBeVisible()
await folderSelect.click() // open dropdown
await expect(folderSelect).toHaveClass(/p-select-open/)
await folderSelect.click() // close the dropdown
await expect(folderSelect).not.toHaveClass(/p-select-open/)
await folderSelectToggle.click() // hide the selectors
await expect(folderSelect).not.toBeVisible()
})
})

View File

@@ -0,0 +1,32 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Topbar commands', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Should allow registering topbar commands', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
window['app'].extensionManager.menu.registerTopbarCommands(
['ext'],
[
{
id: 'foo',
label: 'foo',
function: () => {
window['foo'] = true
}
}
]
)
})
await comfyPage.menu.topbar.triggerTopbarCommand(['ext', 'foo'])
expect(await comfyPage.page.evaluate(() => window['foo'])).toBe(true)
})
})

View File

@@ -0,0 +1,51 @@
import { test as base } from '@playwright/test'
export const webSocketFixture = base.extend<{
ws: { trigger(data: any, url?: string): Promise<void> }
}>({
ws: [
async ({ page }, use) => {
// Each time a page loads, to catch navigations
page.on('load', async () => {
await page.evaluate(function () {
// Create a wrapper for WebSocket that stores them globally
// so we can look it up to trigger messages
const store: Record<string, WebSocket> = ((window as any).__ws__ = {})
window.WebSocket = class extends window.WebSocket {
constructor() {
// @ts-expect-error
super(...arguments)
store[this.url] = this
}
}
})
})
await use({
async trigger(data, url) {
// Trigger a websocket event on the page
await page.evaluate(
function ([data, url]) {
if (!url) {
// If no URL specified, use page URL
const u = new URL(window.location.toString())
u.protocol = 'ws:'
u.pathname = '/'
url = u.toString() + 'ws'
}
const ws: WebSocket = (window as any).__ws__[url]
ws.dispatchEvent(
new MessageEvent('message', {
data
})
)
},
[JSON.stringify(data), url]
)
}
})
},
// We need this to run automatically as the first thing so it adds handlers as soon as the page loads
{ auto: true }
]
})

View File

@@ -0,0 +1,151 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Group Node', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
const groupNodeBookmarkName = `workflow>${groupNodeName}`
let libraryTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await libraryTab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
await libraryTab.close()
})
test('Is added to node library sidebar', async ({ comfyPage }) => {
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
})
test('Can be added to canvas using node library sidebar', async ({
comfyPage
}) => {
const initialNodeCount = await comfyPage.getGraphNodesCount()
// Add group node from node library sidebar
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getNode(groupNodeName).click()
// Verify the node is added to the canvas
expect(await comfyPage.getGraphNodesCount()).toBe(initialNodeCount + 1)
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
.locator('.bookmark-button')
.click()
// Verify the node is added to the bookmarks tab
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toEqual([groupNodeBookmarkName])
// Verify the bookmark node with the same name is added to the tree
expect(await libraryTab.getNode(groupNodeName).count()).not.toBe(0)
// Unbookmark the node
await libraryTab
.getNode(groupNodeName)
.locator('.bookmark-button')
.first()
.click()
// Verify the node is removed from the bookmarks tab
expect(
await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
).toHaveLength(0)
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab
.getNode(groupNodeName)
.locator('.bookmark-button')
.click()
await comfyPage.page.hover('.p-tree-node-label.tree-explorer-node-label')
expect(await comfyPage.page.isVisible('.node-lib-node-preview')).toBe(
true
)
await libraryTab
.getNode(groupNodeName)
.locator('.bookmark-button')
.first()
.click()
})
})
test('Can be added to canvas using search', async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.doubleClickCanvas()
await comfyPage.nextFrame()
await comfyPage.searchBox.fillAndSelectFirstNode(groupNodeName)
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
const tooltipTimeout = 500
await comfyPage.page.waitForTimeout(tooltipTimeout + 16)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
const nodes = await comfyPage.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
}
const latent = await expectSingleNode('EmptyLatentImage')
const sampler = await expectSingleNode('KSampler')
// Remove existing link
const samplerInput = await sampler.getInput(0)
await samplerInput.removeLinks()
// Group latent + sampler
await latent.click('title', {
modifiers: ['Shift']
})
await sampler.click('title', {
modifiers: ['Shift']
})
const groupNode = await sampler.convertToGroupNode()
// Connect node to group
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
const input = await ckpt.connectOutput(0, groupNode, 0)
expect(await input.getLinkCount()).toBe(1)
// Modify the group node via manage dialog
const manage = await groupNode.manageGroupNode()
await manage.selectNode('KSampler')
await manage.changeTab('Inputs')
await manage.setLabel('model', 'test')
await manage.save()
await manage.close()
// Ensure the link is still present
expect(await input.getLinkCount()).toBe(1)
})
test('Loads from a workflow using the legacy path separator ("/")', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('legacy_group_node')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View File

@@ -0,0 +1,44 @@
import type { Page, Locator } from '@playwright/test'
import type { AutoQueueMode } from '../../src/stores/queueStore'
export class ComfyAppMenu {
public readonly root: Locator
public readonly queueButton: ComfyQueueButton
constructor(public readonly page: Page) {
this.root = page.locator('.app-menu')
this.queueButton = new ComfyQueueButton(this)
}
}
class ComfyQueueButton {
public readonly root: Locator
public readonly primaryButton: Locator
public readonly dropdownButton: Locator
constructor(public readonly appMenu: ComfyAppMenu) {
this.root = appMenu.root.getByTestId('queue-button')
this.primaryButton = this.root.locator('.p-splitbutton-button')
this.dropdownButton = this.root.locator('.p-splitbutton-dropdown')
}
public async toggleOptions() {
await this.dropdownButton.click()
return new ComfyQueueButtonOptions(this.appMenu.page)
}
}
class ComfyQueueButtonOptions {
constructor(public readonly page: Page) {}
public async setMode(mode: AutoQueueMode) {
await this.page.evaluate((mode) => {
window['app'].extensionManager.queueSettings.mode = mode
}, mode)
}
public async getMode() {
return await this.page.evaluate(() => {
return window['app'].extensionManager.queueSettings.mode
})
}
}

View File

@@ -0,0 +1,37 @@
import { Locator, Page } from '@playwright/test'
export class ManageGroupNode {
footer: Locator
constructor(
readonly page: Page,
readonly root: Locator
) {
this.footer = root.locator('footer')
}
async setLabel(name: string, label: string) {
const active = this.root.locator('.comfy-group-manage-node-page.active')
const input = active.getByPlaceholder(name)
await input.fill(label)
}
async save() {
await this.footer.getByText('Save').click()
}
async close() {
await this.footer.getByText('Close').click()
}
async selectNode(name: string) {
const list = this.root.locator('.comfy-group-manage-list-items')
const item = list.getByText(name)
await item.click()
}
async changeTab(name: 'Inputs' | 'Widgets' | 'Outputs') {
const header = this.root.locator('.comfy-group-manage-node header')
const tab = header.getByText(name)
await tab.click()
}
}

View File

@@ -0,0 +1,12 @@
import { Locator, Page } from '@playwright/test'
export class ComfyTemplates {
readonly content: Locator
constructor(readonly page: Page) {
this.content = page.getByTestId('template-workflows-content')
}
async loadTemplate(id: string) {
await this.content.getByTestId(`template-workflow-${id}`).click()
}
}

View File

@@ -24,16 +24,56 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('dragged-node1.png')
})
test('Can disconnect/connect edge', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'no action')
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot(
'disconnected-edge-with-menu.png'
)
await comfyPage.connectEdge()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
test.describe('Edge Interaction', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'no action')
await comfyPage.setSetting('Comfy.LinkRelease.ActionShift', 'no action')
})
test('Can disconnect/connect edge', async ({ comfyPage }) => {
await comfyPage.disconnectEdge()
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.connectEdge()
// Litegraph renders edge with a slight offset.
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
maxDiffPixels: 50
})
})
// Chromium 2x cannot move link.
// See https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/10876381315/job/30176211513
test.skip('Can move link', async ({ comfyPage }) => {
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode1InputSlot,
comfyPage.emptySpace
)
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode2InputSlot,
comfyPage.clipTextEncodeNode1InputSlot
)
await expect(comfyPage.canvas).toHaveScreenshot('moved-link.png')
})
// Copy link is not working on CI at all
// Chromium 2x recognize it as dragging canvas.
// Chromium triggers search box after link release. The link is indeed copied.
// See https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/10876381315/job/30176211513
test.skip('Can copy link by shift-drag existing link', async ({
comfyPage
}) => {
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode1InputSlot,
comfyPage.emptySpace
)
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop(
comfyPage.clipTextEncodeNode2InputLinkPath,
comfyPage.clipTextEncodeNode1InputSlot
)
await comfyPage.page.keyboard.up('Shift')
await expect(comfyPage.canvas).toHaveScreenshot('copied-link.png')
})
})
@@ -211,6 +251,18 @@ test.describe('Node Interaction', () => {
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unpinned.png')
})
test('Can bypass/unbypass nodes with keyboard shortcut', async ({
comfyPage
}) => {
await comfyPage.select2Nodes()
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-bypassed.png')
await comfyPage.canvas.press('Control+b')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('nodes-unbypassed.png')
})
})
test.describe('Group Interaction', () => {
@@ -310,6 +362,12 @@ test.describe('Canvas Interaction', () => {
await comfyPage.pan({ x: 800, y: 300 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-one.png')
})
test('@mobile Can pan with touch', async ({ comfyPage }) => {
await comfyPage.closeMenu()
await comfyPage.panWithTouch({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-touch.png')
})
})
test.describe('Widget Interaction', () => {
@@ -344,3 +402,35 @@ test.describe('Load workflow', () => {
await expect(comfyPage.canvas).toHaveScreenshot('string_node_id.png')
})
})
test.describe('Load duplicate workflow', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('A workflow can be loaded multiple times in a row', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('single_ksampler')
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await comfyPage.loadWorkflow('single_ksampler')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
})
})
test.describe('Menu interactions', () => {
test('Can open settings with hotkey', async ({ comfyPage }) => {
await comfyPage.page.keyboard.down('ControlOrMeta')
await comfyPage.page.keyboard.press(',')
await comfyPage.page.keyboard.up('ControlOrMeta')
const settingsLocator = comfyPage.page.locator('.settings-container')
await expect(settingsLocator).toBeVisible()
await comfyPage.page.keyboard.press('Escape')
await expect(settingsLocator).not.toBeVisible()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

View File

@@ -3,7 +3,7 @@ import { comfyPageFixture as test } from './ComfyPage'
test.describe('Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
@@ -85,7 +85,21 @@ test.describe('Menu', () => {
const count = await comfyPage.getGraphNodesCount()
// Drag the node onto the canvas
const canvasSelector = '#graph-canvas'
await comfyPage.page.dragAndDrop(nodeSelector, canvasSelector)
// Get the bounding box of the canvas element
const canvasBoundingBox = (await comfyPage.page
.locator(canvasSelector)
.boundingBox())!
// Calculate the center position of the canvas
const targetPosition = {
x: canvasBoundingBox.x + canvasBoundingBox.width / 2,
y: canvasBoundingBox.y + canvasBoundingBox.height / 2
}
await comfyPage.page.dragAndDrop(nodeSelector, canvasSelector, {
targetPosition
})
// Verify the node is added to the canvas
expect(await comfyPage.getGraphNodesCount()).toBe(count + 1)
@@ -365,6 +379,100 @@ test.describe('Menu', () => {
})
})
test.describe('Workflows sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Sidebar'
)
// Open the sidebar
const tab = comfyPage.menu.workflowsTab
await tab.open()
})
test('Can create new blank workflow', async ({ comfyPage }) => {
const tab = comfyPage.menu.workflowsTab
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json'
])
await tab.newBlankWorkflowButton.click()
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'*Unsaved Workflow (2).json'
])
})
test('Can show top level saved workflows', async ({ comfyPage }) => {
await comfyPage.setupWorkflowsDirectory({
'workflow1.json': 'default.json',
'workflow2.json': 'default.json'
})
// Avoid reset view as the button is not visible in BetaMenu UI.
await comfyPage.setup({ resetView: false })
const tab = comfyPage.menu.workflowsTab
await tab.open()
expect(await tab.getTopLevelSavedWorkflowNames()).toEqual([
'workflow1.json',
'workflow2.json'
])
})
test('Does not report warning when switching between opened workflows', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('missing_nodes')
await comfyPage.closeDialog()
// Load default workflow
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newDefaultWorkflowButton.click()
// Switch back to the missing_nodes workflow
await comfyPage.menu.workflowsTab.switchToWorkflow('missing_nodes')
await expect(
comfyPage.page.locator('.comfy-missing-nodes')
).not.toBeVisible()
})
})
test.describe('Workflows topbar tabs', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting(
'Comfy.Workflow.WorkflowTabsPosition',
'Topbar'
)
})
test('Can show opened workflows', async ({ comfyPage }) => {
expect(await comfyPage.menu.topbar.getTabNames()).toEqual([
'Unsaved Workflow'
])
})
})
// Only test 'Top' to reduce test time.
// ['Bottom', 'Top']
;['Top'].forEach(async (position) => {
test(`Can migrate deprecated menu positions (${position})`, async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', position)
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Floating')
})
test(`Can migrate deprecated menu positions on initial load (${position})`, async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.UseNewMenu', position)
await comfyPage.setup()
expect(await comfyPage.getSetting('Comfy.UseNewMenu')).toBe('Floating')
})
})
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const [defaultSpeed, maxSpeed] = [1.1, 2.5]
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(

View File

@@ -9,8 +9,7 @@ test.describe('Node Badge', () => {
const LGraphBadge = window['LGraphBadge']
const app = window['app'] as ComfyApp
const graph = app.graph
// @ts-expect-error - accessing private property
const nodes = graph._nodes
const nodes = graph.nodes
for (const node of nodes) {
node.badges = [new LGraphBadge({ text: 'Test Badge' })]
@@ -27,8 +26,7 @@ test.describe('Node Badge', () => {
const LGraphBadge = window['LGraphBadge']
const app = window['app'] as ComfyApp
const graph = app.graph
// @ts-expect-error - accessing private property
const nodes = graph._nodes
const nodes = graph.nodes
for (const node of nodes) {
node.badges = [
@@ -48,8 +46,7 @@ test.describe('Node Badge', () => {
const LGraphBadge = window['LGraphBadge']
const app = window['app'] as ComfyApp
const graph = app.graph
// @ts-expect-error - accessing private property
const nodes = graph._nodes
const nodes = graph.nodes
for (const node of nodes) {
node.badges = [new LGraphBadge({ text: 'Test Badge' })]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 45 KiB

View File

@@ -0,0 +1,35 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
// If an input is optional by node definition, it should be shown as
// a hollow circle no matter what shape it was defined in the workflow JSON.
test.describe('Optional input', () => {
test('No shape specified', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('optional_input_no_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
})
test('Wrong shape specified', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('optional_input_wrong_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
})
test('Correct shape specified', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('optional_input_correct_shape')
await expect(comfyPage.canvas).toHaveScreenshot('optional_input.png')
})
test('Force input', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('force_input')
await expect(comfyPage.canvas).toHaveScreenshot('force_input.png')
})
test('Only optional inputs', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('only_optional_inputs')
expect(await comfyPage.getGraphNodesCount()).toBe(1)
expect(comfyPage.page.locator('.comfy-missing-nodes')).not.toBeVisible()
// If the node's multiline text widget is visible, then it was loaded successfully
expect(comfyPage.page.locator('.comfy-multiline-input')).toHaveCount(1)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -103,6 +103,30 @@ test.describe('Node search box', () => {
await comfyPage.page.waitForTimeout(256)
await expect(comfyPage.searchBox.input).not.toHaveCount(0)
})
test.describe('Filtering', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.doubleClickCanvas()
})
test('Can add filter', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await expect(comfyPage.searchBox.filterChips).toHaveCount(1)
})
test('Can add multiple filters', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('CLIP', 'Output Type')
await expect(comfyPage.searchBox.filterChips).toHaveCount(2)
})
test('Can remove filter', async ({ comfyPage }) => {
await comfyPage.searchBox.addFilter('MODEL', 'Input Type')
await comfyPage.searchBox.addFilter('CLIP', 'Output Type')
await comfyPage.searchBox.removeFilter(0)
await expect(comfyPage.searchBox.filterChips).toHaveCount(1)
})
})
})
test.describe('Release context menu', () => {

View File

@@ -2,8 +2,16 @@ import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Properties Panel', () => {
test('Can change property value', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
// TODO: Update expectation after new menu dropdown is added.
test.skip('Can change property value', async ({ comfyPage }) => {
await comfyPage.rightClickEmptyLatentNode()
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.nextFrame()
@@ -19,6 +27,5 @@ test.describe('Properties Panel', () => {
)
await propertyInput.fill('Empty Latent Image')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
})

View File

@@ -0,0 +1,34 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Templates', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Floating')
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Can load template workflows', async ({ comfyPage }) => {
// This test will need expanding on once the templates are decided
// Clear the workflow
await comfyPage.menu.workflowsTab.open()
await comfyPage.menu.workflowsTab.newBlankWorkflowButton.click()
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBe(0)
}).toPass({ timeout: 250 })
// Load a template
await comfyPage.menu.workflowsTab.browseGalleryButton.click()
await expect(comfyPage.templates.content).toBeVisible()
await comfyPage.templates.loadTemplate('default')
await expect(comfyPage.templates.content).toBeHidden()
// Ensure we now have some nodes
await expect(async () => {
expect(await comfyPage.getGraphNodesCount()).toBeGreaterThan(0)
}).toPass({ timeout: 250 })
})
})

View File

@@ -14,7 +14,14 @@ export default [
'src/types/vue-shim.d.ts'
]
},
{ languageOptions: { globals: globals.browser } },
{
languageOptions: {
globals: {
...globals.browser,
__COMFYUI_FRONTEND_VERSION__: 'readonly'
}
}
},
pluginJs.configs.recommended,
...tseslint.configs.recommended,
...pluginVue.configs['flat/essential'],

1
global.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
declare const __COMFYUI_FRONTEND_VERSION__: string

View File

@@ -4,25 +4,10 @@
<meta charset="UTF-8">
<title>ComfyUI</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<!-- Browser Test Fonts -->
<!-- <link href="https://fonts.googleapis.com/css2?family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
<link href="https://fonts.googleapis.com/css2?family=Noto+Color+Emoji&family=Roboto+Mono:ital,wght@0,100..700;1,100..700&family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap" rel="stylesheet">
<style>
* {
font-family: 'Roboto Mono', 'Noto Color Emoji';
}
</style> -->
<script type="module" src="node_modules/reflect-metadata/Reflect.js"></script>
<script type="module">
import 'reflect-metadata';
window["__COMFYUI_FRONTEND_VERSION__"] = __COMFYUI_FRONTEND_VERSION__;
console.log("ComfyUI Front-end version:", __COMFYUI_FRONTEND_VERSION__);
</script>
<script type="module" src="src/main.ts"></script>
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
</head>
<body class="litegraph">
<body class="litegraph grid">
<div id="vue-app"></div>
<div id="comfy-user-selection" class="comfy-user-selection" style="display: none;">
<main class="comfy-user-selection-inner">
@@ -51,5 +36,7 @@
</form>
</main>
</div>
<script type="module" src="node_modules/reflect-metadata/Reflect.js"></script>
<script type="module" src="src/main.ts"></script>
</body>
</html>

246
package-lock.json generated
View File

@@ -1,15 +1,15 @@
{
"name": "comfyui-frontend",
"version": "1.2.53",
"version": "1.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.2.53",
"version": "1.3.3",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.71",
"@comfyorg/litegraph": "^0.7.80",
"@primevue/themes": "^4.0.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",
@@ -33,6 +33,7 @@
"@babel/preset-env": "^7.22.20",
"@eslint/js": "^9.8.0",
"@iconify/json": "^2.2.245",
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.44.1",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
@@ -63,7 +64,7 @@
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.2.0",
"vite": "^5.4.6",
"vite-plugin-static-copy": "^1.0.5",
"vitest": "^2.0.5",
"zip-dir": "^2.0.0"
@@ -1909,9 +1910,9 @@
"dev": true
},
"node_modules/@comfyorg/litegraph": {
"version": "0.7.71",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.71.tgz",
"integrity": "sha512-WjV5ZY+yfNfu9n99bdfeUTdeFvCkOW/8KIFsCFu6aqGGUbsuRRwTbXk+qOvcDquzPGzrnDmo4z7UQpaMqCT9nA==",
"version": "0.7.80",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.80.tgz",
"integrity": "sha512-kn9RByKDa2zYdXtUkfWvY8fV+GkJfdnFXP0JEFT4RdoLRNpuXqsGujogjIz9BWfOlKTN7JE0H6zLv+rscQ7JeQ==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -3350,6 +3351,49 @@
"dev": true,
"license": "MIT"
},
"node_modules/@pinia/testing": {
"version": "0.1.5",
"resolved": "https://registry.npmjs.org/@pinia/testing/-/testing-0.1.5.tgz",
"integrity": "sha512-AcGzuotkzhRoF00htuxLfIPBBHVE6HjjB3YC5Y3os8vRgKu6ipknK5GBQq9+pduwYQhZ+BcCZDC9TyLAUlUpoQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"pinia": ">=2.2.1"
}
},
"node_modules/@pinia/testing/node_modules/vue-demi": {
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/antfu"
},
"peerDependencies": {
"@vue/composition-api": "^1.0.0-rc.1",
"vue": "^3.0.0-0 || ^2.6.0"
},
"peerDependenciesMeta": {
"@vue/composition-api": {
"optional": true
}
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3460,9 +3504,9 @@
}
},
"node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.18.0.tgz",
"integrity": "sha512-Tya6xypR10giZV1XzxmH5wr25VcZSncG0pZIjfePT0OVBvqNEurzValetGNarVrGiq66EBVAFn15iYX4w6FKgQ==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz",
"integrity": "sha512-Fxamp4aEZnfPOcGA8KSNEohV8hX7zVHOemC8jVBoBUHu5zpJK/Eu3uJwt6BMgy9fkvzxDaurgj96F/NiLukF2w==",
"cpu": [
"arm"
],
@@ -3472,9 +3516,9 @@
]
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.18.0.tgz",
"integrity": "sha512-avCea0RAP03lTsDhEyfy+hpfr85KfyTctMADqHVhLAF3MlIkq83CP8UfAHUssgXTYd+6er6PaAhx/QGv4L1EiA==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.22.4.tgz",
"integrity": "sha512-VXoK5UMrgECLYaMuGuVTOx5kcuap1Jm8g/M83RnCHBKOqvPPmROFJGQaZhGccnsFtfXQ3XYa4/jMCJvZnbJBdA==",
"cpu": [
"arm64"
],
@@ -3484,9 +3528,9 @@
]
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.18.0.tgz",
"integrity": "sha512-IWfdwU7KDSm07Ty0PuA/W2JYoZ4iTj3TUQjkVsO/6U+4I1jN5lcR71ZEvRh52sDOERdnNhhHU57UITXz5jC1/w==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.22.4.tgz",
"integrity": "sha512-xMM9ORBqu81jyMKCDP+SZDhnX2QEVQzTcC6G18KlTQEzWK8r/oNZtKuZaCcHhnsa6fEeOBionoyl5JsAbE/36Q==",
"cpu": [
"arm64"
],
@@ -3496,9 +3540,9 @@
]
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.18.0.tgz",
"integrity": "sha512-n2LMsUz7Ynu7DoQrSQkBf8iNrjOGyPLrdSg802vk6XT3FtsgX6JbE8IHRvposskFm9SNxzkLYGSq9QdpLYpRNA==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.22.4.tgz",
"integrity": "sha512-aJJyYKQwbHuhTUrjWjxEvGnNNBCnmpHDvrb8JFDbeSH3m2XdHcxDd3jthAzvmoI8w/kSjd2y0udT+4okADsZIw==",
"cpu": [
"x64"
],
@@ -3508,9 +3552,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.18.0.tgz",
"integrity": "sha512-C/zbRYRXFjWvz9Z4haRxcTdnkPt1BtCkz+7RtBSuNmKzMzp3ZxdM28Mpccn6pt28/UWUCTXa+b0Mx1k3g6NOMA==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.22.4.tgz",
"integrity": "sha512-j63YtCIRAzbO+gC2L9dWXRh5BFetsv0j0va0Wi9epXDgU/XUi5dJKo4USTttVyK7fGw2nPWK0PbAvyliz50SCQ==",
"cpu": [
"arm"
],
@@ -3520,9 +3564,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.18.0.tgz",
"integrity": "sha512-l3m9ewPgjQSXrUMHg93vt0hYCGnrMOcUpTz6FLtbwljo2HluS4zTXFy2571YQbisTnfTKPZ01u/ukJdQTLGh9A==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.22.4.tgz",
"integrity": "sha512-dJnWUgwWBX1YBRsuKKMOlXCzh2Wu1mlHzv20TpqEsfdZLb3WoJW2kIEsGwLkroYf24IrPAvOT/ZQ2OYMV6vlrg==",
"cpu": [
"arm"
],
@@ -3532,9 +3576,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.18.0.tgz",
"integrity": "sha512-rJ5D47d8WD7J+7STKdCUAgmQk49xuFrRi9pZkWoRD1UeSMakbcepWXPF8ycChBoAqs1pb2wzvbY6Q33WmN2ftw==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.22.4.tgz",
"integrity": "sha512-AdPRoNi3NKVLolCN/Sp4F4N1d98c4SBnHMKoLuiG6RXgoZ4sllseuGioszumnPGmPM2O7qaAX/IJdeDU8f26Aw==",
"cpu": [
"arm64"
],
@@ -3544,9 +3588,9 @@
]
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.18.0.tgz",
"integrity": "sha512-be6Yx37b24ZwxQ+wOQXXLZqpq4jTckJhtGlWGZs68TgdKXJgw54lUUoFYrg6Zs/kjzAQwEwYbp8JxZVzZLRepQ==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.22.4.tgz",
"integrity": "sha512-Gl0AxBtDg8uoAn5CCqQDMqAx22Wx22pjDOjBdmG0VIWX3qUBHzYmOKh8KXHL4UpogfJ14G4wk16EQogF+v8hmA==",
"cpu": [
"arm64"
],
@@ -3556,9 +3600,9 @@
]
},
"node_modules/@rollup/rollup-linux-powerpc64le-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.18.0.tgz",
"integrity": "sha512-hNVMQK+qrA9Todu9+wqrXOHxFiD5YmdEi3paj6vP02Kx1hjd2LLYR2eaN7DsEshg09+9uzWi2W18MJDlG0cxJA==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.22.4.tgz",
"integrity": "sha512-3aVCK9xfWW1oGQpTsYJJPF6bfpWfhbRnhdlyhak2ZiyFLDaayz0EP5j9V1RVLAAxlmWKTDfS9wyRyY3hvhPoOg==",
"cpu": [
"ppc64"
],
@@ -3568,9 +3612,9 @@
]
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.18.0.tgz",
"integrity": "sha512-ROCM7i+m1NfdrsmvwSzoxp9HFtmKGHEqu5NNDiZWQtXLA8S5HBCkVvKAxJ8U+CVctHwV2Gb5VUaK7UAkzhDjlg==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.22.4.tgz",
"integrity": "sha512-ePYIir6VYnhgv2C5Xe9u+ico4t8sZWXschR6fMgoPUK31yQu7hTEJb7bCqivHECwIClJfKgE7zYsh1qTP3WHUA==",
"cpu": [
"riscv64"
],
@@ -3580,9 +3624,9 @@
]
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.18.0.tgz",
"integrity": "sha512-0UyyRHyDN42QL+NbqevXIIUnKA47A+45WyasO+y2bGJ1mhQrfrtXUpTxCOrfxCR4esV3/RLYyucGVPiUsO8xjg==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.22.4.tgz",
"integrity": "sha512-GqFJ9wLlbB9daxhVlrTe61vJtEY99/xB3C8e4ULVsVfflcpmR6c8UZXjtkMA6FhNONhj2eA5Tk9uAVw5orEs4Q==",
"cpu": [
"s390x"
],
@@ -3592,9 +3636,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.18.0.tgz",
"integrity": "sha512-xuglR2rBVHA5UsI8h8UbX4VJ470PtGCf5Vpswh7p2ukaqBGFTnsfzxUBetoWBWymHMxbIG0Cmx7Y9qDZzr648w==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.22.4.tgz",
"integrity": "sha512-87v0ol2sH9GE3cLQLNEy0K/R0pz1nvg76o8M5nhMR0+Q+BBGLnb35P0fVz4CQxHYXaAOhE8HhlkaZfsdUOlHwg==",
"cpu": [
"x64"
],
@@ -3604,9 +3648,9 @@
]
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.18.0.tgz",
"integrity": "sha512-LKaqQL9osY/ir2geuLVvRRs+utWUNilzdE90TpyoX0eNqPzWjRm14oMEE+YLve4k/NAqCdPkGYDaDF5Sw+xBfg==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.22.4.tgz",
"integrity": "sha512-UV6FZMUgePDZrFjrNGIWzDo/vABebuXBhJEqrHxrGiU6HikPy0Z3LfdtciIttEUQfuDdCn8fqh7wiFJjCNwO+g==",
"cpu": [
"x64"
],
@@ -3616,9 +3660,9 @@
]
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.18.0.tgz",
"integrity": "sha512-7J6TkZQFGo9qBKH0pk2cEVSRhJbL6MtfWxth7Y5YmZs57Pi+4x6c2dStAUvaQkHQLnEQv1jzBUW43GvZW8OFqA==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.22.4.tgz",
"integrity": "sha512-BjI+NVVEGAXjGWYHz/vv0pBqfGoUH0IGZ0cICTn7kB9PyjrATSkX+8WkguNjWoj2qSr1im/+tTGRaY+4/PdcQw==",
"cpu": [
"arm64"
],
@@ -3628,9 +3672,9 @@
]
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.18.0.tgz",
"integrity": "sha512-Txjh+IxBPbkUB9+SXZMpv+b/vnTEtFyfWZgJ6iyCmt2tdx0OF5WhFowLmnh8ENGNpfUlUZkdI//4IEmhwPieNg==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.22.4.tgz",
"integrity": "sha512-SiWG/1TuUdPvYmzmYnmd3IEifzR61Tragkbx9D3+R8mzQqDBz8v+BvZNDlkiTtI9T15KYZhP0ehn3Dld4n9J5g==",
"cpu": [
"ia32"
],
@@ -3640,9 +3684,9 @@
]
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.18.0.tgz",
"integrity": "sha512-UOo5FdvOL0+eIVTgS4tIdbW+TtnBLWg1YBCcU2KWM7nuNwRz9bksDX1bekJJCpu25N1DVWaCwnT39dVQxzqS8g==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.22.4.tgz",
"integrity": "sha512-j8pPKp53/lq9lMXN57S8cFz0MynJk8OWNuUnXct/9KCpKU7DgU3bYMJhwWmcqC0UU29p8Lr0/7KEVcaM6bf47Q==",
"cpu": [
"x64"
],
@@ -10036,9 +10080,9 @@
}
},
"node_modules/picocolors": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz",
"integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew=="
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz",
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw=="
},
"node_modules/picomatch": {
"version": "2.3.1",
@@ -10074,12 +10118,13 @@
}
},
"node_modules/pinia": {
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.1.7.tgz",
"integrity": "sha512-+C2AHFtcFqjPih0zpYuvof37SFxMQ7OEG2zV9jRI12i9BOy3YQVAHwdKtyyc8pDcDyIc33WCIsZaCFWU7WWxGQ==",
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-2.2.2.tgz",
"integrity": "sha512-ja2XqFWZC36mupU4z1ZzxeTApV7DOw44cV4dhQ9sGwun+N89v/XP7+j7q6TanS1u1tdbK4r+1BUx7heMaIdagA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^6.5.0",
"vue-demi": ">=0.14.5"
"@vue/devtools-api": "^6.6.3",
"vue-demi": "^0.14.10"
},
"funding": {
"url": "https://github.com/sponsors/posva"
@@ -10099,10 +10144,11 @@
}
},
"node_modules/pinia/node_modules/vue-demi": {
"version": "0.14.8",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.8.tgz",
"integrity": "sha512-Uuqnk9YE9SsWeReYqK2alDI5YzciATE0r2SkA6iMAtuXvNTMNACJLJEXNXaEy94ECuBe4Sk6RzRU80kjdbIo1Q==",
"version": "0.14.10",
"resolved": "https://registry.npmjs.org/vue-demi/-/vue-demi-0.14.10.tgz",
"integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
"hasInstallScript": true,
"license": "MIT",
"bin": {
"vue-demi-fix": "bin/vue-demi-fix.js",
"vue-demi-switch": "bin/vue-demi-switch.js"
@@ -10200,9 +10246,9 @@
}
},
"node_modules/postcss": {
"version": "8.4.39",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz",
"integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==",
"version": "8.4.47",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz",
"integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==",
"funding": [
{
"type": "opencollective",
@@ -10219,8 +10265,8 @@
],
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.1",
"source-map-js": "^1.2.0"
"picocolors": "^1.1.0",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
@@ -10722,9 +10768,9 @@
"dev": true
},
"node_modules/rollup": {
"version": "4.18.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.18.0.tgz",
"integrity": "sha512-QmJz14PX3rzbJCN1SG4Xe/bAAX2a6NpCP8ab2vfu2GiUr8AQcr2nCV/oEO3yneFarB67zk8ShlIyWb2LGTb3Sg==",
"version": "4.22.4",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.22.4.tgz",
"integrity": "sha512-vD8HJ5raRcWOyymsR6Z3o6+RzfEPCnVLMFJ6vRslO1jt4LO6dUo5Qnpg7y4RkZFM2DMe3WUirkI5c16onjrc6A==",
"dependencies": {
"@types/estree": "1.0.5"
},
@@ -10736,22 +10782,22 @@
"npm": ">=8.0.0"
},
"optionalDependencies": {
"@rollup/rollup-android-arm-eabi": "4.18.0",
"@rollup/rollup-android-arm64": "4.18.0",
"@rollup/rollup-darwin-arm64": "4.18.0",
"@rollup/rollup-darwin-x64": "4.18.0",
"@rollup/rollup-linux-arm-gnueabihf": "4.18.0",
"@rollup/rollup-linux-arm-musleabihf": "4.18.0",
"@rollup/rollup-linux-arm64-gnu": "4.18.0",
"@rollup/rollup-linux-arm64-musl": "4.18.0",
"@rollup/rollup-linux-powerpc64le-gnu": "4.18.0",
"@rollup/rollup-linux-riscv64-gnu": "4.18.0",
"@rollup/rollup-linux-s390x-gnu": "4.18.0",
"@rollup/rollup-linux-x64-gnu": "4.18.0",
"@rollup/rollup-linux-x64-musl": "4.18.0",
"@rollup/rollup-win32-arm64-msvc": "4.18.0",
"@rollup/rollup-win32-ia32-msvc": "4.18.0",
"@rollup/rollup-win32-x64-msvc": "4.18.0",
"@rollup/rollup-android-arm-eabi": "4.22.4",
"@rollup/rollup-android-arm64": "4.22.4",
"@rollup/rollup-darwin-arm64": "4.22.4",
"@rollup/rollup-darwin-x64": "4.22.4",
"@rollup/rollup-linux-arm-gnueabihf": "4.22.4",
"@rollup/rollup-linux-arm-musleabihf": "4.22.4",
"@rollup/rollup-linux-arm64-gnu": "4.22.4",
"@rollup/rollup-linux-arm64-musl": "4.22.4",
"@rollup/rollup-linux-powerpc64le-gnu": "4.22.4",
"@rollup/rollup-linux-riscv64-gnu": "4.22.4",
"@rollup/rollup-linux-s390x-gnu": "4.22.4",
"@rollup/rollup-linux-x64-gnu": "4.22.4",
"@rollup/rollup-linux-x64-musl": "4.22.4",
"@rollup/rollup-win32-arm64-msvc": "4.22.4",
"@rollup/rollup-win32-ia32-msvc": "4.22.4",
"@rollup/rollup-win32-x64-msvc": "4.22.4",
"fsevents": "~2.3.2"
}
},
@@ -10904,9 +10950,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz",
"integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==",
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"engines": {
"node": ">=0.10.0"
}
@@ -11945,13 +11991,13 @@
}
},
"node_modules/vite": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.3.3.tgz",
"integrity": "sha512-NPQdeCU0Dv2z5fu+ULotpuq5yfCS1BzKUIPhNbP3YBfAMGJXbt2nS+sbTFu+qchaqWTD+H3JK++nRwr6XIcp6A==",
"version": "5.4.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.6.tgz",
"integrity": "sha512-IeL5f8OO5nylsgzd9tq4qD2QqI0k2CQLGrWD0rCN0EQJZpBK5vJAx0I+GDkMOXxQX/OfFHMuLIx6ddAxGX/k+Q==",
"dependencies": {
"esbuild": "^0.21.3",
"postcss": "^8.4.39",
"rollup": "^4.13.0"
"postcss": "^8.4.43",
"rollup": "^4.20.0"
},
"bin": {
"vite": "bin/vite.js"
@@ -11970,6 +12016,7 @@
"less": "*",
"lightningcss": "^1.21.0",
"sass": "*",
"sass-embedded": "*",
"stylus": "*",
"sugarss": "*",
"terser": "^5.4.0"
@@ -11987,6 +12034,9 @@
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.2.53",
"version": "1.3.3",
"type": "module",
"scripts": {
"dev": "vite",
@@ -25,6 +25,7 @@
"@babel/preset-env": "^7.22.20",
"@eslint/js": "^9.8.0",
"@iconify/json": "^2.2.245",
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.44.1",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
@@ -55,14 +56,14 @@
"typescript-eslint": "^8.0.0",
"unplugin-icons": "^0.19.3",
"unplugin-vue-components": "^0.27.4",
"vite": "^5.2.0",
"vite": "^5.4.6",
"vite-plugin-static-copy": "^1.0.5",
"vitest": "^2.0.5",
"zip-dir": "^2.0.0"
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.71",
"@comfyorg/litegraph": "^0.7.80",
"@primevue/themes": "^4.0.5",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",

1
public/assets/CREDIT.txt Normal file
View File

@@ -0,0 +1 @@
Thanks to OpenArt (https://openart.ai) for providing the sorted-custom-node-map data, captured in September 2024.

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,351 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": [
425.27801513671875,
180.6060791015625
],
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 5
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
6
],
"slot_index": 0
}
],
"properties": {},
"widgets_values": [
"text, watermark"
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
415,
186
],
"size": [
422.84503173828125,
164.31304931640625
],
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 3
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
4
],
"slot_index": 0
}
],
"properties": {},
"widgets_values": [
"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
473,
609
],
"size": [
315,
106
],
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
2
],
"slot_index": 0
}
],
"properties": {},
"widgets_values": [
512,
512,
1
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": [
315,
262
],
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 1
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
7
],
"slot_index": 0
}
],
"properties": {},
"widgets_values": [
156680208700286,
true,
20,
8,
"euler",
"normal",
1
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1209,
188
],
"size": [
210,
46
],
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 8
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
],
"slot_index": 0
}
],
"properties": {}
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1451,
189
],
"size": [
210,
26
],
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"properties": {}
},
{
"id": 4,
"type": "CheckpointLoaderSimple",
"pos": [
26,
474
],
"size": [
315,
98
],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
1
],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
3,
5
],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [
8
],
"slot_index": 2
}
],
"properties": {},
"widgets_values": [
"v1-5-pruned-emaonly.ckpt"
]
}
],
"links": [
[
1,
4,
0,
3,
0,
"MODEL"
],
[
2,
5,
0,
3,
3,
"LATENT"
],
[
3,
4,
1,
6,
0,
"CLIP"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
5,
4,
1,
7,
0,
"CLIP"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
8,
4,
2,
8,
1,
"VAE"
],
[
9,
8,
0,
9,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 320 KiB

View File

@@ -0,0 +1,420 @@
{
"last_node_id": 36,
"last_link_id": 58,
"nodes": [
{
"id": 33,
"type": "CLIPTextEncode",
"pos": [
390,
400
],
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {
"collapsed": true
},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 54,
"slot_index": 0
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
55
],
"slot_index": 0
}
],
"title": "CLIP Text Encode (Negative Prompt)",
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
""
],
"color": "#322",
"bgcolor": "#533"
},
{
"id": 27,
"type": "EmptySD3LatentImage",
"pos": [
471,
455
],
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
51
],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptySD3LatentImage"
},
"widgets_values": [
1024,
1024,
1
],
"color": "#323",
"bgcolor": "#535"
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1151,
195
],
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 52
},
{
"name": "vae",
"type": "VAE",
"link": 46
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1375,
194
],
"size": {
"0": 985.3012084960938,
"1": 1060.3828125
},
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"properties": {},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 31,
"type": "KSampler",
"pos": [
816,
192
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 47
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 58
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 55
},
{
"name": "latent_image",
"type": "LATENT",
"link": 51
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
52
],
"shape": 3,
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
173805153958730,
"randomize",
4,
1,
"euler",
"simple",
1
]
},
{
"id": 30,
"type": "CheckpointLoaderSimple",
"pos": [
48,
192
],
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
47
],
"shape": 3,
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
45,
54
],
"shape": 3,
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [
46
],
"shape": 3,
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"flux1-schnell-fp8.safetensors"
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
384,
192
],
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 45
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
58
],
"slot_index": 0
}
],
"title": "CLIP Text Encode (Positive Prompt)",
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"a bottle with a beautiful rainbow galaxy inside it on top of a wooden table in the middle of a modern kitchen beside a plate of vegetables and mushrooms and a wine glasse that contains a planet earth with a plate with a half eaten apple pie on it"
],
"color": "#232",
"bgcolor": "#353"
},
{
"id": 34,
"type": "Note",
"pos": [
831,
501
],
"size": {
"0": 282.8617858886719,
"1": 164.08004760742188
},
"flags": {},
"order": 2,
"mode": 0,
"properties": {
"text": ""
},
"widgets_values": [
"Note that Flux dev and schnell do not have any negative prompt so CFG should be set to 1.0. Setting CFG to 1.0 means the negative prompt is ignored.\n\nThe schnell model is a distilled model that can generate a good image with only 4 steps."
],
"color": "#432",
"bgcolor": "#653"
}
],
"links": [
[
9,
8,
0,
9,
0,
"IMAGE"
],
[
45,
30,
1,
6,
0,
"CLIP"
],
[
46,
30,
2,
8,
1,
"VAE"
],
[
47,
30,
0,
31,
0,
"MODEL"
],
[
51,
27,
0,
31,
3,
"LATENT"
],
[
52,
31,
0,
8,
0,
"LATENT"
],
[
54,
30,
1,
33,
0,
"CLIP"
],
[
55,
33,
0,
31,
2,
"CONDITIONING"
],
[
58,
6,
0,
31,
1,
"CONDITIONING"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.1,
"offset": [
0.6836674124529055,
1.8290357611967831
]
}
},
"models": [
{
"name": "flux1-schnell-fp8.safetensors",
"url": "https://huggingface.co/Comfy-Org/flux1-schnell/resolve/main/flux1-schnell-fp8.safetensors?download=true",
"directory": "checkpoints"
}
],
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 330 KiB

View File

@@ -0,0 +1,442 @@
{
"last_node_id": 14,
"last_link_id": 17,
"nodes": [
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
413,
389
],
"size": {
"0": 425.27801513671875,
"1": 180.6060791015625
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 15
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
6
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"watermark, text\n"
]
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
415,
186
],
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 14
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
4
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"photograph of victorian woman with wings, sky clouds, meadow grass\n"
]
},
{
"id": 8,
"type": "VAEDecode",
"pos": [
1209,
188
],
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 17
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1451,
189
],
"size": {
"0": 210,
"1": 58
},
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"properties": {},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 10,
"type": "LoadImage",
"pos": [
215.9799597167969,
703.6800268554688
],
"size": [
315,
314.00002670288086
],
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
10
],
"slot_index": 0
},
{
"name": "MASK",
"type": "MASK",
"links": null,
"shape": 3
}
],
"properties": {
"Node name for S&R": "LoadImage"
},
"widgets_values": [
"example.png",
"image"
]
},
{
"id": 12,
"type": "VAEEncode",
"pos": [
614.979959716797,
707.6800268554688
],
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "pixels",
"type": "IMAGE",
"link": 10
},
{
"name": "vae",
"type": "VAE",
"link": 16
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
11
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEEncode"
}
},
{
"id": 3,
"type": "KSampler",
"pos": [
863,
186
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 13
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 11
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
7
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
280823642470253,
"randomize",
20,
8,
"dpmpp_2m",
"normal",
0.8700000000000001
]
},
{
"id": 14,
"type": "CheckpointLoaderSimple",
"pos": [
19,
433
],
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
13
],
"shape": 3,
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
14,
15
],
"shape": 3,
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [
16,
17
],
"shape": 3,
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v1-5-pruned-emaonly.ckpt"
]
}
],
"links": [
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
9,
8,
0,
9,
0,
"IMAGE"
],
[
10,
10,
0,
12,
0,
"IMAGE"
],
[
11,
12,
0,
3,
3,
"LATENT"
],
[
13,
14,
0,
3,
0,
"MODEL"
],
[
14,
14,
1,
6,
0,
"CLIP"
],
[
15,
14,
1,
7,
0,
"CLIP"
],
[
16,
14,
2,
12,
1,
"VAE"
],
[
17,
14,
2,
8,
1,
"VAE"
]
],
"groups": [
{
"title": "Loading images",
"bounding": [
150,
630,
726,
171
],
"color": "#3f789e"
}
],
"config": {},
"extra": {},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

View File

@@ -0,0 +1,645 @@
{
"last_node_id": 16,
"last_link_id": 23,
"nodes": [
{
"id": 8,
"type": "VAEDecode",
"pos": [
1235.7215957031258,
577.1878720703122
],
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 5,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 7
},
{
"name": "vae",
"type": "VAE",
"link": 21
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
9
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 10,
"type": "LatentUpscale",
"pos": [
1238,
170
],
"size": {
"0": 315,
"1": 130
},
"flags": {},
"order": 6,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 10
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
14
]
}
],
"properties": {
"Node name for S&R": "LatentUpscale"
},
"widgets_values": [
"nearest-exact",
1152,
1152,
"disabled"
]
},
{
"id": 13,
"type": "VAEDecode",
"pos": [
1961,
125
],
"size": {
"0": 210,
"1": 46
},
"flags": {},
"order": 9,
"mode": 0,
"inputs": [
{
"name": "samples",
"type": "LATENT",
"link": 15
},
{
"name": "vae",
"type": "VAE",
"link": 22
}
],
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
17
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "VAEDecode"
}
},
{
"id": 6,
"type": "CLIPTextEncode",
"pos": [
374,
171
],
"size": {
"0": 422.84503173828125,
"1": 164.31304931640625
},
"flags": {},
"order": 2,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 19
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
4,
12
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"masterpiece HDR victorian portrait painting of woman, blonde hair, mountain nature, blue sky\n"
]
},
{
"id": 7,
"type": "CLIPTextEncode",
"pos": [
377,
381
],
"size": {
"0": 425.27801513671875,
"1": 180.6060791015625
},
"flags": {},
"order": 3,
"mode": 0,
"inputs": [
{
"name": "clip",
"type": "CLIP",
"link": 20
}
],
"outputs": [
{
"name": "CONDITIONING",
"type": "CONDITIONING",
"links": [
6,
13
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "CLIPTextEncode"
},
"widgets_values": [
"bad hands, text, watermark\n"
]
},
{
"id": 5,
"type": "EmptyLatentImage",
"pos": [
435,
600
],
"size": {
"0": 315,
"1": 106
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
2
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "EmptyLatentImage"
},
"widgets_values": [
768,
768,
1
]
},
{
"id": 11,
"type": "KSampler",
"pos": [
1585,
114
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 8,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 23,
"slot_index": 0
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 12,
"slot_index": 1
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 13,
"slot_index": 2
},
{
"name": "latent_image",
"type": "LATENT",
"link": 14,
"slot_index": 3
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
15
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
469771404043268,
"randomize",
14,
8,
"dpmpp_2m",
"simple",
0.5
]
},
{
"id": 12,
"type": "SaveImage",
"pos": [
2203,
123
],
"size": {
"0": 407.53717041015625,
"1": 468.13226318359375
},
"flags": {},
"order": 10,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 17
}
],
"properties": {},
"widgets_values": [
"ComfyUI"
]
},
{
"id": 3,
"type": "KSampler",
"pos": [
845,
172
],
"size": {
"0": 315,
"1": 262
},
"flags": {},
"order": 4,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": 18
},
{
"name": "positive",
"type": "CONDITIONING",
"link": 4
},
{
"name": "negative",
"type": "CONDITIONING",
"link": 6
},
{
"name": "latent_image",
"type": "LATENT",
"link": 2
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [
7,
10
],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
89848141647836,
"randomize",
12,
8,
"dpmpp_sde",
"normal",
1
]
},
{
"id": 16,
"type": "CheckpointLoaderSimple",
"pos": [
24,
315
],
"size": {
"0": 315,
"1": 98
},
"flags": {},
"order": 1,
"mode": 0,
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": [
18,
23
],
"slot_index": 0
},
{
"name": "CLIP",
"type": "CLIP",
"links": [
19,
20
],
"slot_index": 1
},
{
"name": "VAE",
"type": "VAE",
"links": [
21,
22
],
"slot_index": 2
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple"
},
"widgets_values": [
"v2-1_768-ema-pruned.ckpt"
]
},
{
"id": 9,
"type": "SaveImage",
"pos": [
1495.7215957031258,
576.1878720703122
],
"size": [
232.9403301043692,
282.4336258387117
],
"flags": {},
"order": 7,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 9
}
],
"properties": {},
"widgets_values": [
"ComfyUI"
]
}
],
"links": [
[
2,
5,
0,
3,
3,
"LATENT"
],
[
4,
6,
0,
3,
1,
"CONDITIONING"
],
[
6,
7,
0,
3,
2,
"CONDITIONING"
],
[
7,
3,
0,
8,
0,
"LATENT"
],
[
9,
8,
0,
9,
0,
"IMAGE"
],
[
10,
3,
0,
10,
0,
"LATENT"
],
[
12,
6,
0,
11,
1,
"CONDITIONING"
],
[
13,
7,
0,
11,
2,
"CONDITIONING"
],
[
14,
10,
0,
11,
3,
"LATENT"
],
[
15,
11,
0,
13,
0,
"LATENT"
],
[
17,
13,
0,
12,
0,
"IMAGE"
],
[
18,
16,
0,
3,
0,
"MODEL"
],
[
19,
16,
1,
6,
0,
"CLIP"
],
[
20,
16,
1,
7,
0,
"CLIP"
],
[
21,
16,
2,
8,
1,
"VAE"
],
[
22,
16,
2,
13,
1,
"VAE"
],
[
23,
16,
0,
11,
0,
"MODEL"
]
],
"groups": [
{
"title": "Txt2Img",
"bounding": [
-1,
30,
1211,
708
],
"color": "#a1309b"
},
{
"title": "Save Intermediate Image",
"bounding": [
1225,
500,
516,
196
],
"color": "#3f789e"
},
{
"title": "Hires Fix",
"bounding": [
1224,
29,
710,
464
],
"color": "#b58b2a"
},
{
"title": "Save Final Image",
"bounding": [
1949,
31,
483,
199
],
"color": "#3f789e"
}
],
"config": {},
"extra": {},
"version": 0.4
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 626 KiB

View File

@@ -1,154 +1,25 @@
<template>
<router-view />
<ProgressSpinner v-if="isLoading" class="spinner"></ProgressSpinner>
<BlockUI full-screen :blocked="isLoading" />
<ProgressSpinner
v-if="isLoading"
class="absolute inset-0 flex justify-center items-center h-screen"
/>
<GlobalDialog />
<GlobalToast />
<UnloadWindowConfirmDialog />
<BlockUI full-screen :blocked="isLoading" />
</template>
<script setup lang="ts">
import {
computed,
markRaw,
onMounted,
onUnmounted,
watch,
watchEffect
} from 'vue'
import config from '@/config'
import { computed, onMounted, onBeforeUnmount } from 'vue'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import BlockUI from 'primevue/blockui'
import ProgressSpinner from 'primevue/progressspinner'
import QueueSidebarTab from '@/components/sidebar/tabs/QueueSidebarTab.vue'
import { app } from './scripts/app'
import { useSettingStore } from './stores/settingStore'
import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from './stores/workspaceStateStore'
import NodeLibrarySidebarTab from './components/sidebar/tabs/NodeLibrarySidebarTab.vue'
import GlobalDialog from './components/dialog/GlobalDialog.vue'
import GlobalToast from './components/toast/GlobalToast.vue'
import UnloadWindowConfirmDialog from './components/dialog/UnloadWindowConfirmDialog.vue'
import { api } from './scripts/api'
import { StatusWsMessageStatus } from './types/apiTypes'
import { useQueuePendingTaskCountStore } from './stores/queueStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { i18n } from './i18n'
import GlobalDialog from '@/components/dialog/GlobalDialog.vue'
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
const theme = computed<string>(() =>
useSettingStore().get('Comfy.ColorPalette')
)
watch(
theme,
(newTheme) => {
const DARK_THEME_CLASS = 'dark-theme'
const isDarkTheme = newTheme !== 'light'
if (isDarkTheme) {
document.body.classList.add(DARK_THEME_CLASS)
} else {
document.body.classList.remove(DARK_THEME_CLASS)
}
},
{ immediate: true }
)
watchEffect(() => {
const fontSize = useSettingStore().get('Comfy.TextareaWidget.FontSize')
document.documentElement.style.setProperty(
'--comfy-textarea-font-size',
`${fontSize}px`
)
})
watchEffect(() => {
const padding = useSettingStore().get('Comfy.TreeExplorer.ItemPadding')
document.documentElement.style.setProperty(
'--comfy-tree-explorer-item-padding',
`${padding}px`
)
})
watchEffect(() => {
const locale = useSettingStore().get('Comfy.Locale')
if (locale) {
i18n.global.locale.value = locale
}
})
const { t } = useI18n()
const init = () => {
useSettingStore().addSettings(app.ui.settings)
app.extensionManager = useWorkspaceStore()
app.extensionManager.registerSidebarTab({
id: 'queue',
icon: 'pi pi-history',
iconBadge: () => {
const value = useQueuePendingTaskCountStore().count.toString()
return value === '0' ? null : value
},
title: t('sideToolbar.queue'),
tooltip: t('sideToolbar.queue'),
component: markRaw(QueueSidebarTab),
type: 'vue'
})
app.extensionManager.registerSidebarTab({
id: 'node-library',
icon: 'pi pi-book',
title: t('sideToolbar.nodeLibrary'),
tooltip: t('sideToolbar.nodeLibrary'),
component: markRaw(NodeLibrarySidebarTab),
type: 'vue'
})
}
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const onStatus = (e: CustomEvent<StatusWsMessageStatus>) =>
queuePendingTaskCountStore.update(e)
const toast = useToast()
const reconnectingMessage: ToastMessageOptions = {
severity: 'error',
summary: t('reconnecting')
}
const onReconnecting = () => {
toast.remove(reconnectingMessage)
toast.add(reconnectingMessage)
}
const onReconnected = () => {
toast.remove(reconnectingMessage)
toast.add({
severity: 'success',
summary: t('reconnected'),
life: 2000
})
}
onMounted(() => {
api.addEventListener('status', onStatus)
api.addEventListener('reconnecting', onReconnecting)
api.addEventListener('reconnected', onReconnected)
try {
init()
} catch (e) {
console.error('Failed to init Vue app', e)
}
})
onUnmounted(() => {
api.removeEventListener('status', onStatus)
api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected)
window['__COMFYUI_FRONTEND_VERSION__'] = config.app_version
console.log('ComfyUI Front-end version:', config.app_version)
})
</script>
<style>
.p-tree-node-content {
padding: var(--comfy-tree-explorer-item-padding) !important;
}
</style>
<style scoped>
.spinner {
@apply absolute inset-0 flex justify-center items-center h-screen;
}
</style>

View File

@@ -82,7 +82,8 @@ body {
grid-column: 1/-1;
/* Position at the first row */
grid-row: 1;
z-index: 10;
/* Top menu bar dropdown needs to be above of graph canvas splitter overlay which is z-index: 999 */
z-index: 1000;
display: flex;
flex-direction: column;
}
@@ -708,3 +709,7 @@ audio.comfy-audio.empty-audio-widget {
.p-autocomplete-overlay {
max-width: 25vw;
}
.p-tree-node-content {
padding: var(--comfy-tree-explorer-item-padding) !important;
}

View File

@@ -0,0 +1,48 @@
<template>
<div>
<!-- This component does not render anything visible. -->
</div>
</template>
<script setup lang="ts">
import { useExecutionStore } from '@/stores/executionStore'
import { useSettingStore } from '@/stores/settingStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useTitle } from '@vueuse/core'
import { computed } from 'vue'
const DEFAULT_TITLE = 'ComfyUI'
const executionStore = useExecutionStore()
const executionText = computed(() =>
executionStore.isIdle ? '' : `[${executionStore.executionProgress}%]`
)
const settingStore = useSettingStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
const workflowStore = useWorkflowStore()
const isUnsavedText = computed(() =>
workflowStore.activeWorkflow?.unsaved ? ' *' : ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.name
return workflowName ? isUnsavedText.value + workflowName : DEFAULT_TITLE
})
const nodeExecutionTitle = computed(() =>
executionStore.executingNode && executionStore.executingNodeProgress
? `${executionText.value}[${executionStore.executingNodeProgress}%] ${executionStore.executingNode.type}`
: ''
)
const workflowTitle = computed(
() =>
executionText.value +
(betaMenuEnabled.value ? workflowNameText.value : DEFAULT_TITLE)
)
const title = computed(() => nodeExecutionTitle.value || workflowTitle.value)
useTitle(title)
</script>

View File

@@ -0,0 +1,243 @@
<template>
<Panel
v-show="visible"
class="app-menu w-fit"
:style="style"
:class="{ 'is-dragging': isDragging }"
>
<div class="app-menu-content flex items-center" ref="panelRef">
<span class="drag-handle cursor-move mr-2 p-0!" ref="dragHandleRef">
</span>
<div class="queue-button-group flex">
<SplitButton
class="comfyui-queue-button"
:label="activeQueueModeMenuItem.label"
:icon="activeQueueModeMenuItem.icon"
severity="primary"
@click="queuePrompt"
:model="queueModeMenuItems"
data-testid="queue-button"
v-tooltip.bottom="$t('menu.queueWorkflow')"
>
<template #item="{ item }">
<Button
:label="item.label"
:icon="item.icon"
:severity="item.key === queueMode ? 'primary' : 'secondary'"
text
v-tooltip="item.tooltip"
/>
</template>
</SplitButton>
<BatchCountEdit />
<ButtonGroup class="execution-actions ml-2 flex flex-nowrap">
<Button
v-tooltip.bottom="$t('menu.interrupt')"
icon="pi pi-times"
:severity="executingPrompt ? 'danger' : 'secondary'"
:disabled="!executingPrompt"
@click="() => commandStore.getCommandFunction('Comfy.Interrupt')()"
>
</Button>
<Button
v-tooltip.bottom="$t('sideToolbar.queueTab.clearPendingTasks')"
icon="pi pi-stop"
:severity="hasPendingTasks ? 'danger' : 'secondary'"
:disabled="!hasPendingTasks"
@click="
() => commandStore.getCommandFunction('Comfy.ClearPendingTasks')()
"
/>
</ButtonGroup>
</div>
<Divider layout="vertical" class="mx-2" />
<ButtonGroup class="flex flex-nowrap">
<Button
v-tooltip.bottom="$t('menu.refresh')"
icon="pi pi-refresh"
severity="secondary"
@click="
() =>
commandStore.getCommandFunction('Comfy.RefreshNodeDefinitions')()
"
/>
<Button
v-tooltip.bottom="$t('menu.resetView')"
icon="pi pi-expand"
severity="secondary"
@click="() => commandStore.getCommandFunction('Comfy.ResetView')()"
/>
</ButtonGroup>
</div>
</Panel>
</template>
<script lang="ts" setup>
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import Panel from 'primevue/panel'
import Divider from 'primevue/divider'
import SplitButton from 'primevue/splitbutton'
import Button from 'primevue/button'
import ButtonGroup from 'primevue/buttongroup'
import BatchCountEdit from './BatchCountEdit.vue'
import {
AutoQueueMode,
useQueuePendingTaskCountStore,
useQueueSettingsStore
} from '@/stores/queueStore'
import { app } from '@/scripts/app'
import { storeToRefs } from 'pinia'
import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { MenuItem } from 'primevue/menuitem'
import { useI18n } from 'vue-i18n'
import { useDraggable, useEventListener, useLocalStorage } from '@vueuse/core'
import { debounce, clamp } from 'lodash'
const settingsStore = useSettingStore()
const commandStore = useCommandStore()
const queueCountStore = storeToRefs(useQueuePendingTaskCountStore())
const { batchCount, mode: queueMode } = storeToRefs(useQueueSettingsStore())
const visible = computed(
() => settingsStore.get('Comfy.UseNewMenu') === 'Floating'
)
const { t } = useI18n()
const queueModeMenuItemLookup: Record<AutoQueueMode, MenuItem> = {
disabled: {
key: 'disabled',
label: 'Queue',
icon: 'pi pi-play',
tooltip: t('menu.disabledTooltip'),
command: () => {
queueMode.value = 'disabled'
}
},
instant: {
key: 'instant',
label: 'Queue (Instant)',
icon: 'pi pi-forward',
tooltip: t('menu.instantTooltip'),
command: () => {
queueMode.value = 'instant'
}
},
change: {
key: 'change',
label: 'Queue (Change)',
icon: 'pi pi-step-forward-alt',
tooltip: t('menu.changeTooltip'),
command: () => {
queueMode.value = 'change'
}
}
}
const activeQueueModeMenuItem = computed(
() => queueModeMenuItemLookup[queueMode.value]
)
const queueModeMenuItems = computed(() =>
Object.values(queueModeMenuItemLookup)
)
const executingPrompt = computed(() => !!queueCountStore.count.value)
const hasPendingTasks = computed(() => queueCountStore.count.value > 1)
const queuePrompt = (e: MouseEvent) => {
app.queuePrompt(e.shiftKey ? -1 : 0, batchCount.value)
}
const panelRef = ref<HTMLElement | null>(null)
const dragHandleRef = ref<HTMLElement | null>(null)
const storedPosition = useLocalStorage('Comfy.MenuPosition.Floating', {
x: 0,
y: 0
})
const { x, y, style, isDragging } = useDraggable(panelRef, {
initialValue: { x: 0, y: 0 },
handle: dragHandleRef,
containerElement: document.body
})
// Update storedPosition when x or y changes
watch(
[x, y],
debounce(([newX, newY]) => {
storedPosition.value = { x: newX, y: newY }
}, 300)
)
// Set initial position to bottom center
const setInitialPosition = () => {
if (x.value !== 0 || y.value !== 0) {
return
}
if (storedPosition.value.x !== 0 || storedPosition.value.y !== 0) {
x.value = storedPosition.value.x
y.value = storedPosition.value.y
return
}
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
if (menuWidth === 0 || menuHeight === 0) {
return
}
x.value = (screenWidth - menuWidth) / 2
y.value = screenHeight - menuHeight - 10 // 10px margin from bottom
}
}
onMounted(setInitialPosition)
watch(visible, (newVisible) => {
if (newVisible) {
nextTick(setInitialPosition)
}
})
const adjustMenuPosition = () => {
if (panelRef.value) {
const screenWidth = window.innerWidth
const screenHeight = window.innerHeight
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Adjust x position if menu is off-screen horizontally
x.value = clamp(x.value, 0, screenWidth - menuWidth)
// Adjust y position if menu is off-screen vertically
y.value = clamp(y.value, 0, screenHeight - menuHeight)
}
}
useEventListener(window, 'resize', adjustMenuPosition)
</script>
<style scoped>
.app-menu {
pointer-events: all;
position: fixed;
z-index: 1000;
}
.app-menu.is-dragging {
user-select: none;
}
:deep(.p-panel-content) {
@apply p-2;
}
:deep(.p-panel-header) {
display: none;
}
.comfyui-queue-button :deep(.p-splitbutton-dropdown) {
border-top-right-radius: 0;
border-bottom-right-radius: 0;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<div
class="batch-count"
:class="props.class"
v-tooltip.bottom="$t('menu.batchCount')"
>
<InputNumber
class="w-14"
v-model="batchCount"
:min="minQueueCount"
:max="maxQueueCount"
fluid
showButtons
:pt="{
incrementButton: {
class: 'w-6',
onmousedown: () => {
handleClick(true)
}
},
decrementButton: {
class: 'w-6',
onmousedown: () => {
handleClick(false)
}
}
}"
/>
</div>
</template>
<script lang="ts" setup>
import { useQueueSettingsStore } from '@/stores/queueStore'
import { storeToRefs } from 'pinia'
import InputNumber from 'primevue/inputnumber'
interface Props {
class?: string
}
const props = withDefaults(defineProps<Props>(), {
class: ''
})
const queueSettingsStore = useQueueSettingsStore()
const { batchCount } = storeToRefs(queueSettingsStore)
const minQueueCount = 1
const maxQueueCount = 100
const handleClick = (increment: boolean) => {
let newCount: number
if (increment) {
const originalCount = batchCount.value - 1
newCount = originalCount * 2
} else {
const originalCount = batchCount.value + 1
newCount = Math.floor(originalCount / 2)
}
batchCount.value = newCount
}
</script>
<style scoped>
:deep(.p-inputtext) {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
</style>

View File

@@ -9,6 +9,7 @@
<script setup lang="ts">
import type { DeviceStats } from '@/types/apiTypes'
import { formatMemory } from '@/utils/formatUtil'
const props = defineProps<{
device: DeviceStats
@@ -29,11 +30,7 @@ const formatValue = (value: any, field: string) => {
field
)
) {
const mb = Math.round(value / (1024 * 1024))
if (mb >= 1024) {
return `${(mb / 1024).toFixed(2)} GB`
}
return `${mb} MB`
return formatMemory(value)
}
return value
}

View File

@@ -60,7 +60,7 @@ watch(
const start = 0
const end = fileName.length
const inputElement = inputRef.value.$el
inputElement.setSelectionRange(start, end)
inputElement.setSelectionRange?.(start, end)
})
}
},
@@ -73,7 +73,7 @@ const vFocus = {
<style scoped>
.editable-text {
display: inline-block;
display: inline;
}
.editable-text input {
width: 100%;

View File

@@ -54,8 +54,8 @@ const updateValue = (newValue: number | null) => {
newValue = Number(props.min) || 0
}
const min = Number(props.min) || Number.NEGATIVE_INFINITY
const max = Number(props.max) || Number.POSITIVE_INFINITY
const min = Number(props.min ?? Number.NEGATIVE_INFINITY)
const max = Number(props.max ?? Number.POSITIVE_INFINITY)
const step = Number(props.step) || 1
// Ensure the value is within the allowed range

View File

@@ -5,7 +5,7 @@
<div class="grid grid-cols-2 gap-2">
<template v-for="col in systemColumns" :key="col.field">
<div class="font-medium">{{ $t(col.header) }}</div>
<div>{{ systemInfo[col.field] }}</div>
<div>{{ formatValue(systemInfo[col.field], col.field) }}</div>
</template>
</div>
</div>
@@ -35,6 +35,7 @@ import TabPanel from 'primevue/tabpanel'
import Divider from 'primevue/divider'
import type { SystemStats } from '@/types/apiTypes'
import DeviceInfo from '@/components/common/DeviceInfo.vue'
import { formatMemory } from '@/utils/formatUtil'
const props = defineProps<{
stats: SystemStats
@@ -50,6 +51,15 @@ const systemColumns = [
{ field: 'python_version', header: 'Python Version' },
{ field: 'embedded_python', header: 'Embedded Python' },
{ field: 'pytorch_version', header: 'Pytorch Version' },
{ field: 'argv', header: 'Arguments' }
{ field: 'argv', header: 'Arguments' },
{ field: 'ram_total', header: 'RAM Total' },
{ field: 'ram_free', header: 'RAM Free' }
]
const formatValue = (value: any, field: string) => {
if (['ram_total', 'ram_free'].includes(field)) {
return formatMemory(value)
}
return value
}
</script>

View File

@@ -0,0 +1,27 @@
<template>
<div class="flex items-center" :class="props.class">
<span v-if="position === 'left'" class="mr-2 shrink-0">{{ text }}</span>
<Divider :align="align" :type="type" :layout="layout" class="flex-grow" />
<span v-if="position === 'right'" class="ml-2 shrink-0">{{ text }}</span>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
interface Props {
text: string
class?: string
position?: 'left' | 'right'
align?: 'left' | 'center' | 'right' | 'top' | 'bottom'
type?: 'solid' | 'dashed' | 'dotted'
layout?: 'horizontal' | 'vertical'
}
const props = withDefaults(defineProps<Props>(), {
position: 'left',
align: 'center',
type: 'solid',
layout: 'horizontal'
})
</script>

View File

@@ -3,6 +3,7 @@
class="tree-explorer"
:class="props.class"
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"
:value="renderedRoots"
selectionMode="single"
:pt="{
@@ -40,17 +41,20 @@ import type {
RenderedTreeExplorerNode,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import type { MenuItem } from 'primevue/menuitem'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { useI18n } from 'vue-i18n'
import { useErrorHandling } from '@/hooks/errorHooks'
const expandedKeys = defineModel<Record<string, boolean>>('expandedKeys')
provide('expandedKeys', expandedKeys)
const selectionKeys = defineModel<Record<string, boolean>>('selectionKeys')
provide('selectionKeys', selectionKeys)
// Tracks whether the caller has set the selectionKeys model.
const storeSelectionKeys = selectionKeys.value !== undefined
const props = defineProps<{
roots: TreeExplorerNode[]
class?: string
extraMenuItems?:
| MenuItem[]
| ((targetNode: RenderedTreeExplorerNode) => MenuItem[])
}>()
const emit = defineEmits<{
(e: 'nodeClick', node: RenderedTreeExplorerNode, event: MouseEvent): void
@@ -78,22 +82,40 @@ const getTreeNodeIcon = (node: TreeExplorerNode) => {
}
const fillNodeInfo = (node: TreeExplorerNode): RenderedTreeExplorerNode => {
const children = node.children?.map(fillNodeInfo)
const totalLeaves = node.leaf
? 1
: children.reduce((acc, child) => acc + child.totalLeaves, 0)
return {
...node,
icon: getTreeNodeIcon(node),
children,
type: node.leaf ? 'node' : 'folder',
totalLeaves: node.leaf
? 1
: children.reduce((acc, child) => acc + child.totalLeaves, 0)
totalLeaves,
badgeText: node.getBadgeText ? node.getBadgeText(node) : null
}
}
const onNodeContentClick = (e: MouseEvent, node: RenderedTreeExplorerNode) => {
const onNodeContentClick = async (
e: MouseEvent,
node: RenderedTreeExplorerNode
) => {
if (!storeSelectionKeys) {
selectionKeys.value = {}
}
if (node.handleClick) {
await node.handleClick(node, e)
}
emit('nodeClick', node, e)
}
const menu = ref(null)
const menuTargetNode = ref<RenderedTreeExplorerNode | null>(null)
provide('menuTargetNode', menuTargetNode)
const extraMenuItems = computed(() => {
return menuTargetNode.value?.contextMenuItems
? typeof menuTargetNode.value.contextMenuItems === 'function'
? menuTargetNode.value.contextMenuItems(menuTargetNode.value)
: menuTargetNode.value.contextMenuItems
: []
})
const renameEditingNode = ref<RenderedTreeExplorerNode | null>(null)
provide('renameEditingNode', renameEditingNode)
@@ -101,29 +123,34 @@ const { t } = useI18n()
const renameCommand = (node: RenderedTreeExplorerNode) => {
renameEditingNode.value = node
}
const deleteCommand = (node: RenderedTreeExplorerNode) => {
node.handleDelete?.(node)
const deleteCommand = async (node: RenderedTreeExplorerNode) => {
await node.handleDelete?.(node)
emit('nodeDelete', node)
}
const menuItems = computed<MenuItem[]>(() => [
{
label: t('rename'),
icon: 'pi pi-file-edit',
command: () => renameCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleRename !== undefined
},
{
label: t('delete'),
icon: 'pi pi-trash',
command: () => deleteCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleDelete !== undefined
},
...(props.extraMenuItems
? typeof props.extraMenuItems === 'function'
? props.extraMenuItems(menuTargetNode.value)
: props.extraMenuItems
: [])
])
const menuItems = computed<MenuItem[]>(() =>
[
{
label: t('rename'),
icon: 'pi pi-file-edit',
command: () => renameCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleRename !== undefined
},
{
label: t('delete'),
icon: 'pi pi-trash',
command: () => deleteCommand(menuTargetNode.value),
visible: menuTargetNode.value?.handleDelete !== undefined,
isAsync: true // The delete command can be async
},
...extraMenuItems.value
].map((menuItem) => ({
...menuItem,
command: wrapCommandWithErrorHandler(menuItem.command, {
isAsync: menuItem.isAsync ?? false
})
}))
)
const handleContextMenu = (node: RenderedTreeExplorerNode, e: MouseEvent) => {
menuTargetNode.value = node
emit('contextMenu', node, e)
@@ -131,6 +158,23 @@ const handleContextMenu = (node: RenderedTreeExplorerNode, e: MouseEvent) => {
menu.value?.show(e)
}
}
const errorHandling = useErrorHandling()
const wrapCommandWithErrorHandler = (
command: (event: MenuItemCommandEvent) => void,
{ isAsync = false }: { isAsync: boolean }
) => {
return isAsync
? errorHandling.wrapWithErrorHandlingAsync(
command as (...args: any[]) => Promise<any>,
menuTargetNode.value?.handleError
)
: errorHandling.wrapWithErrorHandling(
command,
menuTargetNode.value?.handleError
)
}
defineExpose({
renameCommand,
deleteCommand
@@ -145,6 +189,7 @@ defineExpose({
margin-left: var(--p-tree-node-gap);
flex-grow: 1;
}
/*
* The following styles are necessary to avoid layout shift when dragging nodes over folders.
* By setting the position to relative on the parent and using an absolutely positioned pseudo-element,
@@ -153,6 +198,7 @@ defineExpose({
:deep(.p-tree-node-content:has(.tree-folder)) {
position: relative;
}
:deep(.p-tree-node-content:has(.tree-folder.can-drop))::after {
content: '';
position: absolute;

View File

@@ -22,7 +22,7 @@
</span>
<Badge
v-if="!props.node.leaf"
:value="props.node.totalLeaves"
:value="props.node.badgeText ?? props.node.totalLeaves"
severity="secondary"
class="leaf-count-badge"
/>
@@ -46,6 +46,7 @@ import type {
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import EditableText from '@/components/common/EditableText.vue'
import { useErrorHandling } from '@/hooks/errorHooks'
const props = defineProps<{
node: RenderedTreeExplorerNode
@@ -67,10 +68,14 @@ const renameEditingNode =
const isEditing = computed(
() => labelEditable.value && renameEditingNode.value?.key === props.node.key
)
const handleRename = (newName: string) => {
props.node.handleRename(props.node, newName)
renameEditingNode.value = null
}
const errorHandling = useErrorHandling()
const handleRename = errorHandling.wrapWithErrorHandlingAsync(
async (newName: string) => {
await props.node.handleRename(props.node, newName)
renameEditingNode.value = null
},
props.node.handleError
)
const container = ref<HTMLElement | null>(null)
const canDrop = ref(false)
const treeNodeElement = ref<HTMLElement | null>(null)
@@ -83,10 +88,10 @@ onMounted(() => {
if (props.node.droppable) {
dropTargetCleanup = dropTargetForElements({
element: treeNodeElement.value,
onDrop: (event) => {
onDrop: async (event) => {
const dndData = event.source.data as TreeExplorerDragAndDropData
if (dndData.type === 'tree-explorer-node') {
props.node.handleDrop?.(props.node, dndData)
await props.node.handleDrop?.(props.node, dndData)
canDrop.value = false
emit('itemDropped', props.node, dndData.data)
}

View File

@@ -3,7 +3,20 @@ import { mount } from '@vue/test-utils'
import TreeExplorerTreeNode from '@/components/common/TreeExplorerTreeNode.vue'
import EditableText from '@/components/common/EditableText.vue'
import Badge from 'primevue/badge'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import { createTestingPinia } from '@pinia/testing'
import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { createI18n } from 'vue-i18n'
import { createApp } from 'vue'
import { useToastStore } from '@/stores/toastStore'
// Create a mock i18n instance
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {}
})
describe('TreeExplorerTreeNode', () => {
const mockNode = {
@@ -12,15 +25,28 @@ describe('TreeExplorerTreeNode', () => {
leaf: false,
totalLeaves: 3,
icon: 'pi pi-folder',
type: 'folder'
type: 'folder',
handleRename: () => {}
} as RenderedTreeExplorerNode
beforeAll(() => {
// Create a Vue app instance for PrimeVuePrimeVue
const app = createApp({})
app.use(PrimeVue)
vi.useFakeTimers()
})
afterAll(() => {
vi.useRealTimers()
})
it('renders correctly', () => {
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: mockNode },
global: {
components: { EditableText, Badge },
provide: { renameEditingNode: { value: null } }
provide: { renameEditingNode: { value: null } },
plugins: [createTestingPinia(), i18n]
}
})
@@ -30,6 +56,80 @@ describe('TreeExplorerTreeNode', () => {
expect(wrapper.findComponent(EditableText).props('modelValue')).toBe(
'Test Node'
)
expect(wrapper.findComponent(Badge).props()['value']).toBe(3)
expect(wrapper.findComponent(Badge).props()['value'].toString()).toBe('3')
})
it('makes node label editable when renamingEditingNode matches', async () => {
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: mockNode },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const editableText = wrapper.findComponent(EditableText)
expect(editableText.props('isEditing')).toBe(true)
})
it('triggers handleRename callback when editing is finished', async () => {
const handleRenameMock = vi.fn()
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
expect(handleRenameMock).toHaveBeenCalledOnce()
})
it('shows error toast when handleRename promise rejects', async () => {
const handleRenameMock = vi
.fn()
.mockRejectedValue(new Error('Rename failed'))
const nodeWithMockRename = {
...mockNode,
handleRename: handleRenameMock
}
const wrapper = mount(TreeExplorerTreeNode, {
props: { node: nodeWithMockRename },
global: {
components: { EditableText, Badge, InputText },
provide: { renameEditingNode: { value: { key: '1' } } },
plugins: [createTestingPinia(), i18n, PrimeVue]
}
})
const toastStore = useToastStore()
const addToastSpy = vi.spyOn(toastStore, 'add')
const editableText = wrapper.findComponent(EditableText)
editableText.vm.$emit('edit', 'New Node Name')
// Wait for the promise to reject and the toast to be added
vi.runAllTimers()
// Wait for any pending promises to resolve
await new Promise(process.nextTick)
expect(handleRenameMock).toHaveBeenCalledOnce()
expect(addToastSpy).toHaveBeenCalledWith({
severity: 'error',
summary: 'error',
detail: 'Rename failed',
life: 3000
})
})
})

View File

@@ -1,8 +1,20 @@
<template>
<div>
<!--
UnloadWindowConfirmDialog: This component does not render
anything visible. It is used to confirm the user wants to
close the window, and if they do, it will call the
beforeunload event.
-->
</div>
</template>
<script setup lang="ts">
import { useSettingStore } from '@/stores/settingStore'
import { onMounted, onUnmounted } from 'vue'
import { onMounted, onBeforeUnmount } from 'vue'
const settingStore = useSettingStore()
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
if (settingStore.get('Comfy.Window.UnloadConfirmation')) {
event.preventDefault()
@@ -15,7 +27,7 @@ onMounted(() => {
window.addEventListener('beforeunload', handleBeforeUnload)
})
onUnmounted(() => {
onBeforeUnmount(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
})
</script>

View File

@@ -4,6 +4,15 @@
<p class="warning-description">
When loading the graph, the following models were not found:
</p>
<p class="warning-options">
<Checkbox
class="model-path-select-checkbox"
v-model="showFolderSelect"
label="Show folder selector"
:binary="true"
/>
Show folder selector
</p>
<ListBox
:options="missingModels"
optionLabel="label"
@@ -29,6 +38,19 @@
</div>
</div>
<div class="model-action">
<Select
class="model-path-select"
v-if="
slotProps.option.action &&
!slotProps.option.downloading &&
!slotProps.option.completed &&
!slotProps.option.error &&
showFolderSelect
"
v-model="slotProps.option.folderPath"
:options="slotProps.option.paths"
@change="updateFolderPath(slotProps.option, $event)"
/>
<Button
v-if="
slotProps.option.action &&
@@ -60,19 +82,24 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import Checkbox from 'primevue/checkbox'
import ListBox from 'primevue/listbox'
import Select from 'primevue/select'
import { SelectChangeEvent } from 'primevue/select'
import Button from 'primevue/button'
import { api } from '@/scripts/api'
import { DownloadModelStatus } from '@/types/apiTypes'
import { useSettingStore } from '@/stores/settingStore'
const settingStore = useSettingStore()
const allowedSources = settingStore.get(
'Comfy.Workflow.ModelDownload.AllowedSources'
)
const allowedSuffixes = settingStore.get(
'Comfy.Workflow.ModelDownload.AllowedSuffixes'
)
const showFolderSelect = ref(false)
// TODO: Read this from server internal API rather than hardcoding here
// as some installations may wish to use custom sources
const allowedSources = [
'https://civitai.com/',
'https://huggingface.co/',
'http://localhost:' // Included for testing usage only
]
const allowedSuffixes = ['.safetensors', '.sft']
interface ModelInfo {
name: string
@@ -83,19 +110,26 @@ interface ModelInfo {
completed?: boolean
progress?: number
error?: string
folder_path?: string
}
const props = defineProps<{
missingModels: ModelInfo[]
paths: Record<string, string[]>
maximized: boolean
}>()
const modelDownloads = ref<Record<string, ModelInfo>>({})
let lastModel: string | null = null
const updateFolderPath = (model: any, event: SelectChangeEvent) => {
const downloadInfo = modelDownloads.value[model.name]
downloadInfo.folder_path = event.value
return false
}
const handleDownloadProgress = (detail: DownloadModelStatus) => {
if (detail.download_path) {
lastModel = detail.download_path.split('/', 2)[1]
lastModel = detail.download_path
}
if (!lastModel) return
if (detail.status === 'in_progress') {
@@ -134,7 +168,8 @@ const handleDownloadProgress = (detail: DownloadModelStatus) => {
const triggerDownload = async (
url: string,
directory: string,
filename: string
filename: string,
folder_path: string
) => {
modelDownloads.value[filename] = {
name: filename,
@@ -143,49 +178,75 @@ const triggerDownload = async (
downloading: true,
progress: 0
}
const download = await api.internalDownloadModel(url, directory, filename, 1)
const download = await api.internalDownloadModel(
url,
directory,
filename,
1,
folder_path
)
lastModel = filename
handleDownloadProgress(download)
}
api.addEventListener('download_progress', (event) => {
api.addEventListener('download_progress', (event: CustomEvent) => {
handleDownloadProgress(event.detail)
})
const missingModels = computed(() => {
return props.missingModels.map((model) => {
const downloadInfo = modelDownloads.value[model.name]
if (!allowedSources.some((source) => model.url.startsWith(source))) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
error:
'Download not allowed from this source: ' + allowedSources.join(', ')
}
}
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
error: 'Only allowed suffixes are ' + allowedSuffixes.join(', ')
}
}
if (model.directory_invalid) {
const paths = props.paths[model.directory]
if (model.directory_invalid || !paths) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
error: 'Invalid directory specified (does this require custom nodes?)'
}
}
const downloadInfo: ModelInfo = modelDownloads.value[model.name] ?? {
downloading: false,
completed: false,
progress: 0,
error: null,
name: model.name,
directory: model.directory,
url: model.url,
folder_path: paths[0]
}
modelDownloads.value[model.name] = downloadInfo
if (!allowedSources.some((source) => model.url.startsWith(source))) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
}
}
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
}
}
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
downloading: downloadInfo?.downloading ?? false,
completed: downloadInfo?.completed ?? false,
progress: downloadInfo?.progress ?? 0,
error: downloadInfo?.error,
downloading: downloadInfo.downloading,
completed: downloadInfo.completed,
progress: downloadInfo.progress,
error: downloadInfo.error,
name: model.name,
paths: paths,
folderPath: downloadInfo.folder_path,
action: {
text: 'Download',
callback: () => triggerDownload(model.url, model.directory, model.name)
callback: () =>
triggerDownload(
model.url,
model.directory,
model.name,
downloadInfo.folder_path
)
}
}
})
@@ -218,6 +279,10 @@ const missingModels = computed(() => {
margin-bottom: 1rem;
}
.warning-options {
color: var(--fg-color);
}
.missing-models-list {
max-height: 300px;
overflow-y: auto;

View File

@@ -22,7 +22,11 @@ import { ref, computed, onUnmounted, onMounted, watchEffect } from 'vue'
import { app as comfyApp } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import {
ComfyNodeDefImpl,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
import {
LiteGraph,
@@ -38,6 +42,8 @@ import {
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useCanvasStore } from '@/stores/graphStore'
import { ComfyModelDef } from '@/stores/modelStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -45,7 +51,7 @@ const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const canvasStore = useCanvasStore()
const modelToNodeStore = useModelToNodeStore()
const betaMenuEnabled = computed(
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
)
@@ -128,6 +134,22 @@ onMounted(async () => {
loc.clientY
])
comfyApp.addNodeOnGraph(nodeDef, { pos })
} else if (node.data instanceof ComfyModelDef) {
const model = node.data
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const pos = comfyApp.clientPosToCanvasPos([
loc.clientX - 20,
loc.clientY
])
const node = comfyApp.addNodeOnGraph(provider.nodeDef, { pos })
const widget = node.widgets.find(
(widget) => widget.name === provider.key
)
if (widget) {
widget.value = model.name
}
}
}
}
}
@@ -140,6 +162,8 @@ onMounted(async () => {
// node search is triggered
useNodeDefStore().nodeSearchService.endsWithFilterStartSequence('')
// Non-blocking load of node frequencies
useNodeFrequencyStore().loadNodeFrequencies()
emit('ready')
})

View File

@@ -109,7 +109,7 @@ const onIdle = () => {
[0, 0]
)
if (outputSlot !== -1) {
return showTooltip(nodeDef.output.all?.[outputSlot].tooltip)
return showTooltip(nodeDef.output.all?.[outputSlot]?.tooltip)
}
const widget = getHoveredWidget()

View File

@@ -5,7 +5,13 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<template>
<div class="_sb_node_preview">
<div class="_sb_table">
<div class="node_header">
<div
class="node_header"
:style="{
backgroundColor: litegraphColors.NODE_DEFAULT_COLOR,
color: litegraphColors.NODE_TITLE_COLOR
}"
>
<div class="_sb_dot headdot"></div>
{{ nodeDef.display_name }}
</div>
@@ -22,7 +28,12 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
</div>
<div class="_sb_col">{{ slotInput ? slotInput.name : '' }}</div>
<div class="_sb_col middle-column"></div>
<div class="_sb_col _sb_inherit">
<div
class="_sb_col _sb_inherit"
:style="{
color: litegraphColors.NODE_TEXT_COLOR
}"
>
{{ slotOutput ? slotOutput.name : '' }}
</div>
<div class="_sb_col">
@@ -37,15 +48,32 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
:key="widgetInput.name"
>
<div class="_sb_col _sb_arrow">&#x25C0;</div>
<div class="_sb_col">{{ widgetInput.name }}</div>
<div
class="_sb_col"
:style="{
color: litegraphColors.WIDGET_SECONDARY_TEXT_COLOR
}"
>
{{ widgetInput.name }}
</div>
<div class="_sb_col middle-column"></div>
<div class="_sb_col _sb_inherit">
<div
class="_sb_col _sb_inherit"
:style="{ color: litegraphColors.WIDGET_TEXT_COLOR }"
>
{{ truncateDefaultValue(widgetInput.default) }}
</div>
<div class="_sb_col _sb_arrow">&#x25B6;</div>
</div>
</div>
<div class="_sb_description" v-if="nodeDef.description">
<div
class="_sb_description"
v-if="nodeDef.description"
:style="{
color: litegraphColors.WIDGET_SECONDARY_TEXT_COLOR,
backgroundColor: litegraphColors.WIDGET_BGCOLOR
}"
>
{{ nodeDef.description }}
</div>
</div>
@@ -53,6 +81,10 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
<script setup lang="ts">
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import {
getColorPalette,
defaultColorPalette
} from '@/extensions/core/colorPalette'
import _ from 'lodash'
const props = defineProps({
@@ -62,6 +94,13 @@ const props = defineProps({
}
})
// Node preview currently is recreated every time something is hovered.
// So not reactive to the color palette changes after setup is fine.
// If later we want NodePreview to be shown more persistently, then we should
// make the getColorPalette() call reactive.
const colors = getColorPalette()?.colors?.litegraph_base
const litegraphColors = colors ?? defaultColorPalette.colors.litegraph_base
const nodeDefStore = useNodeDefStore()
const nodeDef = props.nodeDef
@@ -106,7 +145,6 @@ const truncateDefaultValue = (value: any, charLimit: number = 32): string => {
.node_header {
line-height: 1;
padding: 8px 13px 7px;
background: var(--comfy-input-bg);
margin-bottom: 5px;
font-size: 15px;
text-wrap: nowrap;

View File

@@ -1,29 +0,0 @@
<template>
<Chip :class="nodeSource.className">
{{ nodeSource.displayText }}
</Chip>
</template>
<script setup lang="ts">
import { getNodeSource } from '@/types/nodeSource'
import Chip from 'primevue/chip'
import { computed } from 'vue'
const props = defineProps({
python_module: {
type: String,
required: true
}
})
const nodeSource = computed(() => getNodeSource(props.python_module))
</script>
<style scoped>
.comfy-core,
.comfy-custom-nodes,
.comfy-unknown {
font-size: small;
font-weight: lighter;
}
</style>

View File

@@ -33,6 +33,7 @@
:suggestions="suggestions"
:min-length="0"
:delay="100"
:loading="!nodeFrequencyStore.isLoaded"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@focused-option-changed="setHoverSuggestion($event)"
@@ -43,38 +44,7 @@
:optionLabel="'display_name'"
>
<template v-slot:option="{ option }">
<div class="option-container">
<div class="option-display-name">
<div>
<span
v-html="highlightQuery(option.display_name, currentQuery)"
></span>
<span>&nbsp;</span>
<Tag v-if="showIdName" severity="secondary">
<span v-html="highlightQuery(option.name, currentQuery)"></span>
</Tag>
</div>
<div v-if="showCategory" class="option-category">
{{ option.category.replaceAll('/', ' > ') }}
</div>
</div>
<div class="option-badges">
<Tag
v-if="option.experimental"
:value="$t('experimental')"
severity="primary"
/>
<Tag
v-if="option.deprecated"
:value="$t('deprecated')"
severity="danger"
/>
<NodeSourceChip
v-if="option.python_module !== undefined"
:python_module="option.python_module"
/>
</div>
</div>
<NodeSearchItem :nodeDef="option" :currentQuery="currentQuery" />
</template>
<!-- FilterAndValue -->
<template v-slot:chip="{ value }">
@@ -92,14 +62,17 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import Tag from 'primevue/tag'
import Dialog from 'primevue/dialog'
import Button from 'primevue/button'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSourceChip from '@/components/node/NodeSourceChip.vue'
import NodeSearchItem from '@/components/searchbox/NodeSearchItem.vue'
import { type FilterAndValue } from '@/services/nodeSearchService'
import NodePreview from '@/components/node/NodePreview.vue'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import {
ComfyNodeDefImpl,
useNodeDefStore,
useNodeFrequencyStore
} from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useI18n } from 'vue-i18n'
import SearchFilterChip from '../common/SearchFilterChip.vue'
@@ -110,12 +83,6 @@ const { t } = useI18n()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
)
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
)
const showIdName = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
)
const props = withDefaults(
defineProps<{
@@ -136,19 +103,18 @@ const placeholder = computed(() => {
return props.filters.length === 0 ? t('searchNodes') + '...' : ''
})
const nodeDefStore = useNodeDefStore()
const nodeFrequencyStore = useNodeFrequencyStore()
const search = (query: string) => {
const queryIsEmpty = query === '' && props.filters.length === 0
currentQuery.value = query
suggestions.value = [
...useNodeDefStore().nodeSearchService.searchNode(query, props.filters, {
limit: props.searchLimit
})
]
}
const highlightQuery = (text: string, query: string) => {
if (!query) return text
const regex = new RegExp(`(${query})`, 'gi')
return text.replace(regex, '<span class="highlight">$1</span>')
suggestions.value = queryIsEmpty
? nodeFrequencyStore.topNodeDefs
: [
...nodeDefStore.nodeSearchService.searchNode(query, props.filters, {
limit: props.searchLimit
})
]
}
const emit = defineEmits(['addFilter', 'removeFilter', 'addNode'])
@@ -202,29 +168,6 @@ const setHoverSuggestion = (index: number) => {
@apply z-10 flex-grow;
}
.option-container {
@apply flex justify-between items-center px-2 py-0 cursor-pointer overflow-hidden w-full;
}
.option-display-name {
@apply font-semibold flex flex-col;
}
.option-category {
@apply font-light text-sm text-gray-400 overflow-hidden text-ellipsis;
/* Keeps the text on a single line by default */
white-space: nowrap;
}
:deep(.highlight) {
background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color);
font-weight: bold;
border-radius: 0.25rem;
padding: 0rem 0.125rem;
margin: -0.125rem 0.125rem;
}
._filter-button {
z-index: 10;
}

View File

@@ -34,7 +34,7 @@
<script setup lang="ts">
import { app } from '@/scripts/app'
import { computed, onMounted, onUnmounted, ref, watchEffect } from 'vue'
import { computed, onMounted, onUnmounted, ref, toRaw, watchEffect } from 'vue'
import NodeSearchBox from './NodeSearchBox.vue'
import Dialog from 'primevue/dialog'
import { ConnectingLink } from '@comfyorg/litegraph'
@@ -66,7 +66,9 @@ const addFilter = (filter: FilterAndValue) => {
nodeFilters.value.push(filter)
}
const removeFilter = (filter: FilterAndValue) => {
nodeFilters.value = nodeFilters.value.filter((f) => f !== filter)
nodeFilters.value = nodeFilters.value.filter(
(f) => toRaw(f) !== toRaw(filter)
)
}
const clearFilters = () => {
nodeFilters.value = []
@@ -224,12 +226,18 @@ onUnmounted(() => {
<style>
.invisible-dialog-root {
width: 30%;
width: 60%;
min-width: 24rem;
max-width: 48rem;
border: 0 !important;
background-color: transparent !important;
margin-top: 25vh;
margin-left: 400px;
}
@media all and (max-width: 768px) {
.invisible-dialog-root {
margin-left: 0px;
}
}
.node-search-box-dialog-mask {

View File

@@ -1,13 +1,19 @@
<template>
<div class="_content">
<SelectButton
class="filter-type-select"
v-model="selectedFilter"
:options="filters"
:allowEmpty="false"
optionLabel="name"
@change="updateSelectedFilterValue"
/>
<Select v-model="selectedFilterValue" :options="filterValues" filter />
<Select
class="filter-value-select"
v-model="selectedFilterValue"
:options="filterValues"
filter
/>
</div>
<div class="_footer">
<Button type="button" :label="$t('add')" @click="submit"></Button>

View File

@@ -0,0 +1,97 @@
<template>
<div
class="option-container flex justify-between items-center px-2 py-0 cursor-pointer overflow-hidden w-full"
>
<div class="option-display-name font-semibold flex flex-col">
<div>
<span v-if="isBookmarked">
<i class="pi pi-bookmark-fill text-sm mr-1"></i>
</span>
<span
v-html="highlightQuery(nodeDef.display_name, currentQuery)"
></span>
<span>&nbsp;</span>
<Tag v-if="showIdName" severity="secondary">
<span v-html="highlightQuery(nodeDef.name, currentQuery)"></span>
</Tag>
</div>
<div
v-if="showCategory"
class="option-category font-light text-sm text-gray-400 overflow-hidden text-ellipsis whitespace-nowrap"
>
{{ nodeDef.category.replaceAll('/', ' > ') }}
</div>
</div>
<div class="option-badges">
<Tag
v-if="nodeDef.experimental"
:value="$t('experimental')"
severity="primary"
/>
<Tag
v-if="nodeDef.deprecated"
:value="$t('deprecated')"
severity="danger"
/>
<Tag
v-if="showNodeFrequency && nodeFrequency > 0"
:value="formatNumberWithSuffix(nodeFrequency, { roundToInt: true })"
severity="secondary"
/>
<Chip
v-if="nodeDef.nodeSource.type !== NodeSourceType.Unknown"
class="text-sm font-light"
>
{{ nodeDef.nodeSource.displayText }}
</Chip>
</div>
</div>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import Chip from 'primevue/chip'
import { NodeSourceType } from '@/types/nodeSource'
import { ComfyNodeDefImpl, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { highlightQuery } from '@/utils/formatUtil'
import { computed } from 'vue'
import { useSettingStore } from '@/stores/settingStore'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { formatNumberWithSuffix } from '@/utils/formatUtil'
const settingStore = useSettingStore()
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
)
const showIdName = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowIdName')
)
const showNodeFrequency = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowNodeFrequency')
)
const nodeFrequencyStore = useNodeFrequencyStore()
const nodeFrequency = computed(() =>
nodeFrequencyStore.getNodeFrequency(props.nodeDef)
)
const nodeBookmarkStore = useNodeBookmarkStore()
const isBookmarked = computed(() =>
nodeBookmarkStore.isBookmarked(props.nodeDef)
)
const props = defineProps<{
nodeDef: ComfyNodeDefImpl
currentQuery: string
}>()
</script>
<style scoped>
:deep(.highlight) {
background-color: var(--p-primary-color);
color: var(--p-primary-contrast-color);
font-weight: bold;
border-radius: 0.25rem;
padding: 0rem 0.125rem;
margin: -0.125rem 0.125rem;
}
</style>

View File

@@ -1,6 +1,7 @@
<template>
<SidebarIcon
icon="pi pi-cog"
class="comfy-settings-btn"
@click="showSetting"
:tooltip="$t('settings')"
/>

View File

@@ -0,0 +1,225 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.modelLibrary')">
<template #tool-buttons> </template>
<template #body>
<div class="flex flex-col h-full">
<div class="flex-shrink-0">
<SearchBox
class="model-lib-search-box mx-4 mt-4"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchModels') + '...'"
/>
</div>
<div class="flex-grow overflow-y-auto">
<TreeExplorer
class="model-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<ModelTreeLeaf :node="node" />
</template>
</TreeExplorer>
</div>
</div>
</template>
</SidebarTabTemplate>
<div id="model-library-model-preview-container" />
</template>
<script setup lang="ts">
import SearchBox from '@/components/common/SearchBox.vue'
import { useI18n } from 'vue-i18n'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
import { ComfyModelDef, useModelStore } from '@/stores/modelStore'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import { useSettingStore } from '@/stores/settingStore'
import { useTreeExpansion } from '@/hooks/treeHooks'
import type {
RenderedTreeExplorerNode,
TreeExplorerNode
} from '@/types/treeExplorerTypes'
import { computed, ref, type ComputedRef, watch, toRef } from 'vue'
import type { TreeNode } from 'primevue/treenode'
import { app } from '@/scripts/app'
import { buildTree } from '@/utils/treeUtil'
const { t } = useI18n()
const modelStore = useModelStore()
const modelToNodeStore = useModelToNodeStore()
const settingStore = useSettingStore()
const searchQuery = ref<string>('')
const expandedKeys = ref<Record<string, boolean>>({})
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const root: ComputedRef<TreeNode> = computed(() => {
let modelList: ComfyModelDef[] = []
if (!modelStore.modelFolders.length) {
modelStore.getModelFolders()
}
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
for (let folder of modelStore.modelFolders) {
modelStore.getModelsInFolderCached(folder)
}
}
for (let folder of modelStore.modelFolders) {
const models = modelStore.modelStoreMap[folder]
if (models) {
if (Object.values(models.models).length) {
modelList.push(...Object.values(models.models))
} else {
const fakeModel = new ComfyModelDef('(No Content)', folder)
fakeModel.is_fake_object = true
modelList.push(fakeModel)
}
} else {
const fakeModel = new ComfyModelDef('Loading', folder)
fakeModel.is_fake_object = true
modelList.push(fakeModel)
}
}
if (searchQuery.value) {
const search = searchQuery.value.toLocaleLowerCase()
modelList = modelList.filter((model: ComfyModelDef) => {
return model.name.toLocaleLowerCase().includes(search)
})
}
const tree: TreeNode = buildTree(modelList, (model: ComfyModelDef) => {
return [model.directory, ...model.name.replaceAll('\\', '/').split('/')]
})
return tree
})
const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ComfyModelDef> => {
const children = node.children?.map(fillNodeInfo)
const model: ComfyModelDef | null =
node.leaf && node.data ? node.data : null
if (model?.is_fake_object) {
if (model.name === '(No Content)') {
return {
key: node.key,
label: t('noContent'),
leaf: true,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
return 'pi pi-file'
},
children: []
}
} else {
return {
key: node.key,
label: t('loading') + '...',
leaf: true,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
return 'pi pi-spin pi-spinner'
},
children: []
}
}
}
return {
key: node.key,
label: model ? model.title : node.label,
leaf: node.leaf,
data: node.data,
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
if (node.leaf) {
if (node.data && node.data.image) {
return 'pi pi-fake-spacer'
}
return 'pi pi-file'
}
},
getBadgeText: (node: TreeExplorerNode<ComfyModelDef>) => {
if (node.leaf) {
return null
}
if (node.children?.length === 1) {
const onlyChild = node.children[0]
if (onlyChild.data?.is_fake_object) {
if (onlyChild.data.name === '(No Content)') {
return '0'
} else if (onlyChild.data.name === 'Loading') {
return '?'
}
}
}
return null
},
children,
draggable: node.leaf,
handleClick: (
node: RenderedTreeExplorerNode<ComfyModelDef>,
e: MouseEvent
) => {
if (node.leaf) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
const node = app.addNodeOnGraph(provider.nodeDef, {
pos: app.getCanvasCenter()
})
const widget = node.widgets.find(
(widget) => widget.name === provider.key
)
if (widget) {
widget.value = model.name
}
}
}
}
}
}
return fillNodeInfo(root.value)
})
const handleSearch = (query: string) => {
// TODO
}
const handleNodeClick = (
node: RenderedTreeExplorerNode<ComfyModelDef>,
e: MouseEvent
) => {
if (node.leaf) {
// TODO
} else {
toggleNodeOnEvent(e, node)
}
}
watch(
toRef(expandedKeys, 'value'),
(newExpandedKeys) => {
Object.entries(newExpandedKeys).forEach(([key, isExpanded]) => {
if (isExpanded) {
const folderPath = key.split('/').slice(1).join('/')
if (folderPath && !folderPath.includes('/')) {
// Trigger (async) load of model data for this folder
modelStore.getModelsInFolderCached(folderPath)
}
}
})
},
{ deep: true }
)
</script>
<style>
.pi-fake-spacer {
height: 1px;
width: 16px;
}
</style>
<style scoped>
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
</style>

View File

@@ -49,7 +49,6 @@
class="node-lib-tree-explorer mt-1"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
@nodeClick="handleNodeClick"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
@@ -92,7 +91,8 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const { expandedKeys, expandNode, toggleNodeOnEvent } = useTreeExpansion()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
const nodeBookmarkTreeExplorerRef = ref<InstanceType<
typeof NodeBookmarkTreeExplorer
@@ -122,7 +122,17 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
}
},
children,
draggable: node.leaf
draggable: node.leaf,
handleClick: (
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => {
if (node.leaf) {
app.addNodeOnGraph(node.data, { pos: app.getCanvasCenter() })
} else {
toggleNodeOnEvent(e, node)
}
}
}
}
return fillNodeInfo(root.value)
@@ -163,17 +173,6 @@ const handleSearch = (query: string) => {
})
}
const handleNodeClick = (
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>,
e: MouseEvent
) => {
if (node.leaf) {
app.addNodeOnGraph(node.data, { pos: app.getCanvasCenter() })
} else {
toggleNodeOnEvent(e, node)
}
}
const onAddFilter = (filterAndValue: FilterAndValue) => {
filters.value.push({
filter: filterAndValue,

Some files were not shown because too many files have changed in this diff Show More