Compare commits

...

89 Commits

Author SHA1 Message Date
pythongosssss
02c13c403f Safety mechanism to prevent loading everything 2024-11-24 11:44:26 +00:00
pythongosssss
8254e3c9cf Fix number of items shown when loading more
Fix number of items shown on status update
2024-11-24 11:37:02 +00:00
Chenlei Hu
1160231b62 1.4.9 (#1661) 2024-11-23 17:49:38 -05:00
Chenlei Hu
a51e27bedf chore: update litegraph to 0.8.35 (#1662) 2024-11-23 17:49:27 -05:00
filtered
abed0656af Add Fit Group to Contents keybind (#1658)
* Add Fit Group to Nodes keyboard command

Fits all selected groups.

* nit - Rename

* Move to commandStore & Playwright test

* nit

* nit

* Update test expectations [skip ci]

---------

Co-authored-by: huchenlei <huchenlei@proton.me>
Co-authored-by: github-actions <github-actions@github.com>
2024-11-23 17:15:52 -05:00
Terry Jia
5febda16c7 fix bug and allow restore previous node size (#1659) 2024-11-23 10:56:59 -05:00
Chenlei Hu
069dc67c30 Reland "Fix undo / redo filling with empty steps" (#1653)
* Revert "Revert "Fix undo / redo filling with empty steps (#1649)" (#1652)"

This reverts commit 7623810166.

* Update test expectations

* Add dirty flag if workflow is not persisted

* Add dirty flag to other UI areas for new workflows

* Remove redundant code

* Fix regression: undo / redo steps lost on refresh

The history is still be cleared, but any changes made by issuing undo / redo comands prior to refresh are not lost.

* Update test expectations

Partially reverts f8cc2c0d67 - adds dirty flags back to unsaved workflows.

---------

Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2024-11-23 09:49:12 -05:00
Chenlei Hu
7623810166 Revert "Fix undo / redo filling with empty steps (#1649)" (#1652)
This reverts commit ad2c1a0d3e.
2024-11-22 22:02:56 -05:00
Chenlei Hu
21fa88461f [Electron][skip ci] Update install disk space requirement to 15GB (#1651) 2024-11-22 21:59:46 -05:00
Chenlei Hu
27b0493306 Move files to constants/ (#1650) 2024-11-22 21:55:44 -05:00
filtered
ad2c1a0d3e Fix undo / redo filling with empty steps (#1649) 2024-11-22 21:49:13 -05:00
Robin Huang
f51866d988 [desktop] Update crash report description (#1646)
* Update crash report descripton

* Update settings description.
2024-11-22 21:42:55 -05:00
Chenlei Hu
46627bb44b Remove host and port from server config panel (#1648) 2024-11-22 21:40:15 -05:00
Chenlei Hu
68cadbda9f 1.4.8 (#1647) 2024-11-22 20:36:56 -05:00
pythongosssss
0f2260065a [Electron] Allow users to submit error reports (#1633)
* Allows users to submit error reports

* Text change

* Add tooltip, change severity on submit
Remove unused import
2024-11-22 17:04:51 -05:00
Chenlei Hu
4007cc13c2 [Electron] ComfyUI server config (Launch args config) (#1644)
* Remove electron adapter server args

* Add server args typing

* Add server config constant file

* Tooltip to name; name to id

* Capitalize category

* Server config store

* Prevent default value

* Add serverconfig test

* Guard server config panel with electron flag

* Filter nullish values from server args

* Use slider for preview size
2024-11-22 16:50:24 -05:00
Chenlei Hu
3920210c5c Remove Ctrl+D keybinding (#1643) 2024-11-22 11:17:36 -05:00
Chenlei Hu
4e22bffae2 chore: update litegraph to 0.8.34 (#1642) 2024-11-22 11:03:02 -05:00
Chenlei Hu
462a131557 1.4.7 (#1638) 2024-11-21 17:12:14 -08:00
Chenlei Hu
ec01a04786 Hint shift to queue front on queue button tooltip (#1634) 2024-11-21 15:18:20 -05:00
Chenlei Hu
4c48241e19 Update litegraph 0.8.33 (#1632)
* chore: update litegraph to 0.8.33

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-11-21 14:57:21 -05:00
Chenlei Hu
886c40a69a Fix keybinding conflict (#1630) 2024-11-21 11:49:57 -05:00
Chenlei Hu
479d1b28c7 Update litegraph (Global snap to grid setting) (#1629) 2024-11-21 10:30:54 -05:00
Tristan Sommer
c41b57128a maskEditor UI interface revamp + brush smoothing precision adjustment (#1626) 2024-11-21 09:39:53 -05:00
Chenlei Hu
5d178a407d [chore] Update comfyui-electron-types (#1625)
* Remove electron external dep

* [chore] Update comfyui-electron-types
2024-11-21 00:07:50 -05:00
Chenlei Hu
73b7606f6e 1.4.6 (#1622) 2024-11-20 20:36:18 -05:00
oto-ciulis-tt
94f5031f0d feat: Update Electron Download types (#1621)
* feat: Update Electron Download types

* Fix vite rollup

---------

Co-authored-by: Oto Ciulis <oto.ciulis@gmail.com>
Co-authored-by: huchenlei <huchenlei@proton.me>
2024-11-20 20:34:27 -05:00
oto-ciulis-tt
c857e7d98c feat: #270 Improve error view (#1617)
* feat: #270 Improve error view

Reverting change

Lint & Format

PR comments

Fixing typo

* nit

---------

Co-authored-by: Oto Ciulis <oto.ciulis@gmail.com>
Co-authored-by: huchenlei <huchenlei@proton.me>
2024-11-20 16:35:14 -05:00
pythongosssss
d5b8a555d9 [Electron] xterm startup logs (#1620)
* Add live terminal output

* Fix scrolling

* Refactor loading

* Fallback to polling if endpoint fails

* Comment

* Move clientId to executionStore
Refactor types

* Remove polling

* wip terminal command input

* Refactor to use node-pty

* Hide tabs if not electron

* Lint fix

* ts fix

* Refactor tab components

* Use xterm for startup logs

* Nicer logs display

* Fix not setting xterm + mark terminal as raw
2024-11-20 16:09:54 -05:00
Chenlei Hu
f34d50da3d [Refactor] Extract 'FormItem' and 'SettingItem' (#1619)
* Extract SettingItem component

* Extract GeneralSettingItem

* Rename to FormItem

* nit

* nit
2024-11-20 15:10:17 -05:00
Chenlei Hu
4f3693e322 Reland 'Bind Ctrl+s to Comfy.SaveWorkflow' (#1618) 2024-11-20 15:01:04 -05:00
Terry Jia
431ad7d27f allow render depth directly (#1610) 2024-11-20 09:36:44 -05:00
Chenlei Hu
0c97b09a5a 1.4.5 (#1616) 2024-11-20 09:35:58 -05:00
Chenlei Hu
bdb9f0d845 chore: update litegraph to 0.8.31 (#1615) 2024-11-20 09:33:25 -05:00
Chenlei Hu
77b85acdd5 Revert "Bind Ctrl+s to Comfy.SaveWorkflow (#1599)" (#1614)
This reverts commit 0058691579.
2024-11-20 09:27:01 -05:00
Chenlei Hu
8906f5c26e Add Comfy-Desktop.ComfyServer.ExtraLaunchArgs (#1609) 2024-11-19 20:44:26 -05:00
Chenlei Hu
81194cc7fe 1.4.4 (#1608) 2024-11-19 19:59:22 -05:00
Chenlei Hu
f4b972fab5 chore: update litegraph to 0.8.30 (#1607) 2024-11-19 19:52:01 -05:00
Terry Jia
3aa1c03566 better support for animation (#1606) 2024-11-19 18:25:58 -05:00
Chenlei Hu
600b7f93e5 [Electron] Add missing i18n items (#1605) 2024-11-19 15:31:57 -05:00
Chenlei Hu
2a7df57404 Fix always snap to grid (#1604) 2024-11-19 12:10:40 -05:00
Chenlei Hu
6352cd86ee Show confirm dialog on workflow path conflict (Save As) (#1590)
* Show confirm dialog on workflow path conflict (Save As)

* Fix closeworkflow

* nit

* Add playwright tests

* nit

* nit

* Move workflows dir cleanup
2024-11-18 23:07:24 -05:00
Chenlei Hu
0058691579 Bind Ctrl+s to Comfy.SaveWorkflow (#1599) 2024-11-18 23:07:11 -05:00
Chenlei Hu
1531bb6d9f 1.4.3 (#1598) 2024-11-18 22:18:03 -05:00
filtered
40245aacf9 Run pre-commit type check only for TypeScript (#1597)
* Prevent unnecessary type-checks

* Remove commented code
2024-11-18 21:58:54 -05:00
Tristan Sommer
6e49685f58 fix: improve light mode visibility, add: select color up to mask option (#1596) 2024-11-18 21:31:28 -05:00
Chenlei Hu
946823ce6c [Electron] Add Comfy-Desktop.SendStatistics setting (#1594) 2024-11-18 20:25:18 -05:00
Chenlei Hu
c05f1465db [chore] npm audit fix (#1593) 2024-11-18 20:19:43 -05:00
Chenlei Hu
88164bdac5 [Electron] Fix initial default install location validation (#1592) 2024-11-18 20:10:22 -05:00
Chenlei Hu
fc9e347055 Disable slow jest tests (#1591)
* Disable slow jest tests

* nit
2024-11-18 20:10:03 -05:00
pythongosssss
6fbf1248f4 Filter cached/canceled results (#1586)
* Filter cached/canceled results

* Highlight if on

* Update setting
2024-11-18 16:59:59 -05:00
pythongosssss
56848724cd Fix id (#1589) 2024-11-18 16:59:36 -05:00
Chenlei Hu
26c3eeb942 Fix vue warning on unnecessary defineEmits import (#1588) 2024-11-18 13:15:16 -05:00
filtered
a8f869337e Fix load crash when graph or config unset (#1587)
Resolves #1585
2024-11-18 12:51:46 -05:00
filtered
7e245ba1cf Update Litegraph: Canvas Pointer (#1556)
* Litegraph: canvas.pointer

Clear @ts-expect-error

Fix exception thrown on slot double-click

Long-standing bug but has no real impact in prod - just logs an error.
Required for new connecting_link features.

Add settings: CanvasPointer options

Update litegraph 0.8.28

Fix regression in snap to grid render

Fix snap to grid marker always on

Update snap to grid to use Positionable API

Fix test clicks registering as double-click

Improve test precision

Current test proves it has changed to something smaller.
New test proves it is exactly what was specified.
Will need refinement when a limit is put on latent width.

Fix test expects collapse node to select node

Remove redundant code

Resolved by CanvasPointer

Fix flaky test - ContextMenu

Fix settings group

* Update litegraph

* Remove snapToGrid extension

* Update test expectations [skip ci]

---------

Co-authored-by: huchenlei <huchenlei@proton.me>
Co-authored-by: github-actions <github-actions@github.com>
2024-11-18 10:50:51 -05:00
filtered
2a93f873b4 Use Litegraph snap to grid (#1572)
Leaves only settings code in the extension
2024-11-18 10:27:37 -05:00
filtered
f8e7058e19 Add jest set/has pointer capture mocks (#1581)
* Add set/has pointer capter mocks

* Add mock impl.
2024-11-17 21:41:37 -05:00
Chenlei Hu
8d4e740baa 1.4.2 (#1580) 2024-11-17 21:23:49 -05:00
Tristan Sommer
3273ee938b New Mask Editor (#1284)
* implmentation of new mask editor

* fixed some problems, added some new ones

* Refactor: Split implementation into classes, fix multiple bugs -> all initial features work, more testing required

* first release - fixed all known issues, tested, added color select tool and settings toggle
2024-11-17 21:23:32 -05:00
Terry Jia
94f1bc3b38 add preview 3d node and up_direction parameter (#1579) 2024-11-17 21:17:00 -05:00
Terry Jia
d5ce140eb6 add load 3d node support (#1563)
* add load 3d node support

* stl and different material display support
2024-11-17 15:13:24 -05:00
pythongosssss
b5f0c4bf73 [Electron] Terminal commands (#1531)
* Add live terminal output

* Fix scrolling

* Refactor loading

* Fallback to polling if endpoint fails

* Comment

* Move clientId to executionStore
Refactor types

* Remove polling

* wip terminal command input

* Refactor to use node-pty

* Hide tabs if not electron

* Lint fix

* ts fix

* Refactor tab components
2024-11-17 14:43:08 -05:00
Chenlei Hu
545a990365 Disable debug logic in changeTracker (#1577) 2024-11-17 14:38:14 -05:00
Chenlei Hu
71e4a42cfe Only persist workflow on workflow change/switch (#1576)
* Only persist workflow on workflow change/switch

* nit

* Add playwright test

* Add modify test

* nit

* Fix playwright tests

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-11-17 14:13:52 -05:00
Chenlei Hu
16b0ebf75a Remove deprecated setting Comfy.NodeSearchBoxImpl.LinkReleaseTrigger (#1574) 2024-11-17 12:10:44 -05:00
Chenlei Hu
eaeb17bdc7 Add new top level setting category 'LiteGraph' and 'Appearance' (#1573) 2024-11-17 12:06:07 -05:00
filtered
239b464957 Apply group padding in context menu commands (#1570) 2024-11-17 11:13:28 -05:00
filtered
00b6d989ec Fix Vue console warning flood on settings open (#1571) 2024-11-17 11:12:37 -05:00
Chenlei Hu
c5f05b1855 1.4.1 (#1568) 2024-11-16 19:08:47 -05:00
oto-ciulis-tt
6fefcaad7b Show download percentage only if it's over 10% (#1539)
* Show download percentage only if it's over 10%

* PR comments

---------

Co-authored-by: Oto Ciulis <oto.ciulis@gmail.com>
2024-11-16 11:48:29 -05:00
Yoland Yan
22fdfd7f0b Minor: change adjustMenuPosition style to single side anchor and scale proportionally elsewhere (#1567) 2024-11-16 11:47:54 -05:00
oto-ciulis-tt
6842eb05de feat: Adding download count badge to sidebar (#1552)
* feat: Adding download count badge to sidebar

* Fixing lint

* Updating electronDownloadStore to handle missing DownloadManager

* PR comments

---------

Co-authored-by: Oto Ciulis <oto.ciulis@gmail.com>
2024-11-16 11:46:55 -05:00
filtered
37e7994d55 Fix husky pre-commit for winnt clients [skip ci] (#1564) 2024-11-16 09:39:27 -05:00
filtered
399893bbb2 Allow decimal places typed in settings (#1566) 2024-11-16 09:37:52 -05:00
Chenlei Hu
227db065f3 1.4.0 (#1562) 2024-11-15 21:18:29 -05:00
Chenlei Hu
b4352bcd8d Fix node search box filter test (#1561) 2024-11-15 21:18:19 -05:00
Chenlei Hu
39bab9d9e2 Disable flaky group node test (#1560) 2024-11-15 20:55:18 -05:00
Chenlei Hu
c71644f02f Use tailwind class in NodeSearchBox (#1559) 2024-11-15 20:46:04 -05:00
filtered
6aad7ee8b6 Allow remote dev to be switched on/off (#1558)
* Allow remote dev to be switched on/off

* nit - Docs
2024-11-15 19:26:41 -05:00
Chenlei Hu
2b96d831fc Fix install location path picker (#1557) 2024-11-15 18:55:58 -05:00
filtered
dde0291add Fix change tracker count desync on error (#1555)
* Add TS types

* Ensure changeTracker works after exceptions

Wraps all code between before/after change calls in try/finally blocks
2024-11-15 16:03:21 -05:00
filtered
8af016ffc1 Fix husky pre-commit for winnt clients [skip ci] (#1551) 2024-11-15 09:45:39 -05:00
Chenlei Hu
82b4547d7d Remove canvas border rendering (#1549)
* Remove canvas border rendering

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-11-15 09:24:51 -05:00
Chenlei Hu
791a25637f Revert "Fix husky pre-commit & type check only staged" (#1550)
* Revert "Fix husky pre-commit & type check only staged (#1361)"

This reverts commit 795e932b8f.

* Update package.json
2024-11-15 09:19:24 -05:00
filtered
b922aa5c7c Add option to disable ctrl + shift + zoom (#1545)
* Add option to disable ctrl + shift + zoom

Minor change to default behaviour: zoom no longer triggers if alt key is also down.

* Update coreSettings.ts

Next release will be 1.4.0 to leave room for patches in 1.3 stable after today's main repo sync.

---------

Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2024-11-15 09:10:09 -05:00
Chenlei Hu
cbaebbc9c2 chore: update litegraph to 0.8.27 (#1542) 2024-11-15 09:06:33 -05:00
Chenlei Hu
86b2e1aa6c Add electron adapter extension (#1538) 2024-11-14 19:57:09 -05:00
Chenlei Hu
61c5f05126 1.3.44 (#1541) 2024-11-14 19:56:46 -05:00
Chenlei Hu
dde9c3dad5 Fix tree explorer y-axis padding (#1540)
* Fix tree explorer y-axis padding

* nit
2024-11-14 17:15:57 -05:00
123 changed files with 9749 additions and 1799 deletions

View File

@@ -6,6 +6,11 @@ PLAYWRIGHT_TEST_URL=http://localhost:5173
# Note: localhost:8188 does not work.
DEV_SERVER_COMFYUI_URL=http://127.0.0.1:8188
# Allow dev server access from remote IP addresses.
# If true, the vite dev server will listen on all addresses, including LAN
# and public addresses.
VITE_REMOTE_DEV=false
# The target ComfyUI checkout directory to deploy the frontend code to.
# The dist directory will be copied to {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev
# Add `--front-end-root {DEPLOY_COMFYUI_DIR}/custom_web_versions/main/dev`

View File

@@ -20,7 +20,7 @@ jobs:
run: |
npm run test:generate
npm run test:generate:examples
npm test -- --verbose
npm run test:jest:fast -- --verbose
working-directory: ComfyUI_frontend
playwright-tests-chromium:

View File

@@ -431,6 +431,8 @@ core extensions will be loaded.
#### Access dev server on touch devices
Enable remote access to the dev server by setting `VITE_REMOTE_DEV` in `.env` to `true`.
After you start the dev server, you should see following logs:
```

View File

@@ -0,0 +1,90 @@
{
"last_node_id": 9,
"last_link_id": 9,
"nodes": [
{
"id": 3,
"type": "KSampler",
"pos": [
37,
98
],
"size": [
315,
262
],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "model",
"type": "MODEL",
"link": null
},
{
"name": "positive",
"type": "CONDITIONING",
"link": null
},
{
"name": "negative",
"type": "CONDITIONING",
"link": null
},
{
"name": "latent_image",
"type": "LATENT",
"link": null
}
],
"outputs": [
{
"name": "LATENT",
"type": "LATENT",
"links": [],
"slot_index": 0
}
],
"properties": {
"Node name for S&R": "KSampler"
},
"widgets_values": [
156680208700286,
"randomize",
20,
8,
"euler",
"normal",
1
]
}
],
"links": [],
"groups": [
{
"id": 1,
"title": "Group",
"bounding": [
23,
23,
900,
825
],
"color": "#3f789e",
"font_size": 24,
"flags": {}
}
],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0,
0
]
}
},
"version": 0.4
}

View File

@@ -11,7 +11,7 @@ test.describe('Browser tab title', () => {
const workflowName = await comfyPage.page.evaluate(async () => {
return window['app'].extensionManager.workflow.activeWorkflow.filename
})
expect(await comfyPage.page.title()).toBe(`${workflowName} - ComfyUI`)
expect(await comfyPage.page.title()).toBe(`*${workflowName} - ComfyUI`)
})
// Failing on CI

View File

@@ -56,34 +56,33 @@ test.describe('Change Tracker', () => {
expect(await comfyPage.getToastErrorCount()).toBe(0)
expect(await isModified()).toBe(false)
// TODO(huchenlei): Investigate why saving the workflow is causing the
// undo queue to be triggered.
expect(await getUndoQueueSize()).toBe(1)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(0)
const node = (await comfyPage.getFirstNodeRef())!
await node.click('title')
await node.click('collapse')
await expect(node).toBeCollapsed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(2)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(0)
await comfyPage.ctrlB()
await expect(node).toBeBypassed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(3)
expect(await getUndoQueueSize()).toBe(2)
expect(await getRedoQueueSize()).toBe(0)
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(2)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(1)
await comfyPage.ctrlZ()
await expect(node).not.toBeCollapsed()
expect(await isModified()).toBe(false)
expect(await getUndoQueueSize()).toBe(1)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(2)
})
})
@@ -98,6 +97,7 @@ test.describe('Change Tracker', () => {
// Make changes outside set
// Bypass + collapse node
await node.click('title')
await node.click('collapse')
await comfyPage.ctrlB()
await expect(node).toBeCollapsed()
@@ -111,6 +111,10 @@ test.describe('Change Tracker', () => {
await expect(node).not.toBeBypassed()
await expect(node).not.toBeCollapsed()
// Prevent clicks registering a double-click
await comfyPage.clickEmptySpace()
await node.click('title')
// Run again, but within a change transaction
await beforeChange(comfyPage)
@@ -152,6 +156,7 @@ test.describe('Change Tracker', () => {
const multipleChanges = async () => {
await beforeChange(comfyPage)
// Call other actions that uses begin/endChange
await node.click('title')
await collapse()
await bypassAndPin()
await afterChange(comfyPage)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 139 KiB

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 151 KiB

View File

@@ -24,14 +24,6 @@ test.describe('Copy Paste', () => {
test('Can copy and paste widget value', async ({ comfyPage }) => {
// Copy width value (512) from empty latent node to KSampler's seed.
// Empty latent node's width
await comfyPage.canvas.click({
position: {
x: 718,
y: 643
}
})
await comfyPage.ctrlC(null)
// KSampler's seed
await comfyPage.canvas.click({
position: {
@@ -39,6 +31,14 @@ test.describe('Copy Paste', () => {
y: 281
}
})
await comfyPage.ctrlC(null)
// Empty latent node's width
await comfyPage.canvas.click({
position: {
x: 718,
y: 643
}
})
await comfyPage.ctrlV(null)
await comfyPage.page.keyboard.press('Enter')
await expect(comfyPage.canvas).toHaveScreenshot('copied-widget-value.png')

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -204,13 +204,15 @@ export class ComfyPage {
}
}
async setup() {
async setup({ clearStorage = true }: { clearStorage?: boolean } = {}) {
await this.goto()
await this.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, this.id)
if (clearStorage) {
await this.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, this.id)
}
await this.goto()
// Unify font for consistent screenshots.
@@ -314,9 +316,9 @@ export class ComfyPage {
}, settingId)
}
async reload() {
async reload({ clearStorage = true }: { clearStorage?: boolean } = {}) {
await this.page.reload({ timeout: 15000 })
await this.setup()
await this.setup({ clearStorage })
}
async goto() {
@@ -524,9 +526,6 @@ export class ComfyPage {
safeSpot = safeSpot || { x: 10, y: 10 }
await this.page.mouse.move(safeSpot.x, safeSpot.y)
await this.page.mouse.down()
// TEMPORARY HACK: Multiple pans open the search menu, so cheat and keep it closed.
// TODO: Fix that (double-click at not-the-same-coordinations should not open the menu)
await this.page.keyboard.press('Escape')
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
await this.page.mouse.up()
await this.nextFrame()
@@ -555,6 +554,11 @@ export class ComfyPage {
await this.nextFrame()
}
async clickContextMenuItem(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name }).click()
await this.nextFrame()
}
async doubleClickCanvas() {
await this.page.mouse.dblclick(10, 10, { delay: 5 })
await this.nextFrame()
@@ -733,6 +737,19 @@ export class ComfyPage {
)
}
async confirmDialog(prompt: string, text: string = 'Yes') {
const modal = this.page.locator(
`.comfy-modal-content:has-text("${prompt}")`
)
await expect(modal).toBeVisible()
await modal
.locator('.comfyui-button', {
hasText: text
})
.click()
await expect(modal).toBeHidden()
}
async convertAllNodesToGroupNode(groupNodeName: string) {
this.page.on('dialog', async (dialog) => {
await dialog.accept(groupNodeName)

View File

@@ -43,7 +43,7 @@ export class ComfyNodeSearchBox {
}
get filterButton() {
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
return this.page.locator('.comfy-vue-node-search-container .filter-button')
}
async fillAndSelectFirstNode(

View File

@@ -103,6 +103,12 @@ export class WorkflowsSidebarTab extends SidebarTab {
.allInnerTexts()
}
async getActiveWorkflowName() {
return await this.page
.locator('.comfyui-workflows-open .p-tree-node-selected .node-label')
.innerText()
}
async getTopLevelSavedWorkflowNames() {
return await this.page
.locator('.comfyui-workflows-browse .node-label')

View File

@@ -77,8 +77,13 @@ test.describe('Group Node', () => {
.click()
})
})
test('Can be added to canvas using search', async ({ comfyPage }) => {
// The 500ms fixed delay on the search results is causing flakiness
// Potential solution: add a spinner state when the search is in progress,
// and observe that state from the test. Blocker: the PrimeVue AutoComplete
// does not have a v-model on the query, so we cannot observe the raw
// query update, and thus cannot set the spinning state between the raw query
// update and the debounced search update.
test.skip('Can be added to canvas using search', async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.doubleClickCanvas()

View File

@@ -320,6 +320,15 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('group-selected-nodes.png')
})
test('Can fit group to contents', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('oversized_group')
await comfyPage.ctrlA()
await comfyPage.nextFrame()
await comfyPage.executeCommand('Comfy.Graph.FitGroupToContents')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot('group-fit-to-contents.png')
})
// Somehow this test fails on GitHub Actions. It works locally.
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/736
test.skip('Can pin/unpin nodes with keyboard shortcut', async ({
@@ -537,6 +546,34 @@ test.describe('Load workflow', () => {
await comfyPage.loadWorkflow('string_input')
await expect(comfyPage.canvas).toHaveScreenshot('string_input.png')
})
test('Restore workflow on reload (switch workflow)', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('single_ksampler')
await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler.png')
await comfyPage.reload({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot('single_ksampler.png')
})
test('Restore workflow on reload (modify workflow)', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('single_ksampler')
const node = (await comfyPage.getFirstNodeRef())!
await node.click('collapse')
// Wait 300ms between 2 clicks so that it is not treated as a double click
// by litegraph.
await comfyPage.page.waitForTimeout(300)
await comfyPage.clickEmptySpace()
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
)
await comfyPage.reload({ clearStorage: false })
await expect(comfyPage.canvas).toHaveScreenshot(
'single_ksampler_modified.png'
)
})
})
test.describe('Load duplicate workflow', () => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 103 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -379,7 +379,9 @@ test.describe('Menu', () => {
// Open the sidebar
const tab = comfyPage.menu.workflowsTab
await tab.open()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.setupWorkflowsDirectory({})
})
@@ -392,7 +394,7 @@ test.describe('Menu', () => {
await tab.newBlankWorkflowButton.click()
expect(await tab.getOpenedWorkflowNames()).toEqual([
'*Unsaved Workflow.json',
'Unsaved Workflow (2).json'
'*Unsaved Workflow (2).json'
])
})
@@ -450,6 +452,44 @@ test.describe('Menu', () => {
).toEqual(['*Unsaved Workflow.json', 'workflow3.json', 'workflow4.json'])
})
test('Can save workflow as with same name', async ({ comfyPage }) => {
await comfyPage.menu.topbar.saveWorkflow('workflow5.json')
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['workflow5.json'])
await comfyPage.menu.topbar.saveWorkflowAs('workflow5.json')
await comfyPage.confirmDialog('Overwrite existing file?', 'Yes')
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['workflow5.json'])
})
test('Can overwrite other workflows with save as', async ({
comfyPage
}) => {
const topbar = comfyPage.menu.topbar
await topbar.saveWorkflow('workflow1.json')
await topbar.saveWorkflowAs('workflow2.json')
await comfyPage.nextFrame()
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['workflow1.json', 'workflow2.json'])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow2.json'
)
await topbar.saveWorkflowAs('workflow1.json')
await comfyPage.confirmDialog('Overwrite existing file?', 'Yes')
// The old workflow1.json should be deleted and the new one should be saved.
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['workflow2.json', 'workflow1.json'])
expect(await comfyPage.menu.workflowsTab.getActiveWorkflowName()).toEqual(
'workflow1.json'
)
})
test('Does not report warning when switching between opened workflows', async ({
comfyPage
}) => {
@@ -475,12 +515,12 @@ test.describe('Menu', () => {
`tempWorkflow-${test.info().title}`
)
const closeButton = comfyPage.page.locator(
'.comfyui-workflows-open .p-button-icon.pi-times'
'.comfyui-workflows-open .close-workflow-button'
)
await closeButton.click()
expect(
await comfyPage.menu.workflowsTab.getOpenedWorkflowNames()
).toEqual(['Unsaved Workflow.json'])
).toEqual(['*Unsaved Workflow.json'])
})
})

View File

@@ -36,7 +36,7 @@ test.describe('Canvas Right Click Menu', () => {
await dialog.accept('GroupNode2CLIP')
})
await comfyPage.rightClickCanvas()
await comfyPage.page.getByText('Convert to Group Node').click()
await comfyPage.clickContextMenuItem('Convert to Group Node')
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-group-node.png'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -14,6 +14,7 @@ const jestConfig: JestConfigWithTsJest = {
}
]
},
transformIgnorePatterns: ['/node_modules/(?!(three|@three)/)'],
moduleNameMapper: {
'^@/(.*)$': '<rootDir>/src/$1',
'\\.(css|less|scss|sass)$': 'identity-obj-proxy'

13
lint-staged.config.js Normal file
View File

@@ -0,0 +1,13 @@
export default {
'./**/*.js': (stagedFiles) => formatFiles(stagedFiles),
'./**/*.{ts,tsx,vue}': (stagedFiles) => [
...formatFiles(stagedFiles),
'tsc --noEmit',
'tsc-strict'
]
}
function formatFiles(fileNames) {
return [`prettier --write ${fileNames.join(' ')}`]
}

121
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "comfyui-frontend",
"version": "1.3.43",
"version": "1.4.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.3.43",
"version": "1.4.9",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.2.16",
"@comfyorg/litegraph": "^0.8.26",
"@comfyorg/comfyui-electron-types": "^0.3.6",
"@comfyorg/litegraph": "^0.8.35",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -24,6 +24,7 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.0.5",
"three": "^0.170.0",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.3",
@@ -40,6 +41,7 @@
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@types/three": "^0.169.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.6",
"@vue/vue3-jest": "^29.2.6",
@@ -63,7 +65,6 @@
"tailwindcss": "^3.4.4",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"tsc-files": "^1.1.4",
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
@@ -1916,15 +1917,15 @@
"dev": true
},
"node_modules/@comfyorg/comfyui-electron-types": {
"version": "0.2.16",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.2.16.tgz",
"integrity": "sha512-Hm6NeyMK4sd2V5AyOnvfI+tvCsXr5NBG8wOZlWyyD17ADpbQnpm6qPMWzvm4vCp/YvTR7cUbDGiY0quhofuQGg==",
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.3.6.tgz",
"integrity": "sha512-wgMgESnCcRzvVkk8CwWiTAUJxC4LBvw5uTENxzaWkEL0qrnmiGrVLore00yX3cYz04hJaTA6PqasLqgVLDOenw==",
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.8.26",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.26.tgz",
"integrity": "sha512-q0Vcd5usphR5nghfyFksVx+VM+eSB1MyX8Ne304KFDnr214KQMA6DAjrEQJlGBUUCybLiOtPCvd3dxPecEQiSQ==",
"version": "0.8.35",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.35.tgz",
"integrity": "sha512-taxjPoNJLajZa3z3JSxwgArRIi5lYy3nlkmemup8bo0AtC7QpKOOE+xQ5wtSXcSMZZMxbsgQHp7FoBTeIUHngA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -2459,9 +2460,9 @@
}
},
"node_modules/@eslint/plugin-kit": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.0.tgz",
"integrity": "sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==",
"version": "0.2.3",
"resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.3.tgz",
"integrity": "sha512-2b/g5hRmpbb1o4GnTZax9N9m0FXzz9OV42ZzI4rDDMDuHUqigAiQCEWChBWCY4ztAGVRjoWT19v0yMmc5/L5kA==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
@@ -3850,6 +3851,13 @@
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true
},
"node_modules/@tweenjs/tween.js": {
"version": "23.1.3",
"resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz",
"integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -3985,6 +3993,13 @@
"integrity": "sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==",
"dev": true
},
"node_modules/@types/stats.js": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.3.tgz",
"integrity": "sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/strip-bom": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@types/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -3999,6 +4014,21 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/three": {
"version": "0.169.0",
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.169.0.tgz",
"integrity": "sha512-oan7qCgJBt03wIaK+4xPWclYRPG9wzcg7Z2f5T8xYTNEF95kh0t0lklxLLYBDo7gQiGLYzE6iF4ta7nXF2bcsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@tweenjs/tween.js": "~23.1.3",
"@types/stats.js": "*",
"@types/webxr": "*",
"@webgpu/types": "*",
"fflate": "~0.8.2",
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/tough-cookie": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@types/tough-cookie/-/tough-cookie-4.0.5.tgz",
@@ -4011,6 +4041,13 @@
"integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
"license": "MIT"
},
"node_modules/@types/webxr": {
"version": "0.5.20",
"resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.20.tgz",
"integrity": "sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/yargs": {
"version": "17.0.32",
"resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-17.0.32.tgz",
@@ -4636,6 +4673,13 @@
}
}
},
"node_modules/@webgpu/types": {
"version": "0.1.51",
"resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.51.tgz",
"integrity": "sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@xterm/addon-fit": {
"version": "0.10.0",
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz",
@@ -5807,10 +5851,11 @@
"dev": true
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -6778,6 +6823,13 @@
"bser": "2.1.1"
}
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"dev": true,
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -10067,11 +10119,19 @@
"node": ">= 8"
}
},
"node_modules/micromatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
"node_modules/meshoptimizer": {
"version": "0.18.1",
"resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz",
"integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==",
"dev": true,
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
"integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
"dev": true,
"license": "MIT",
"dependencies": {
"braces": "^3.0.3",
"picomatch": "^2.3.1"
@@ -11952,6 +12012,12 @@
"node": ">=0.8"
}
},
"node_modules/three": {
"version": "0.170.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz",
"integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==",
"license": "MIT"
},
"node_modules/tinybench": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
@@ -12170,19 +12236,6 @@
}
}
},
"node_modules/tsc-files": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/tsc-files/-/tsc-files-1.1.4.tgz",
"integrity": "sha512-RePsRsOLru3BPpnf237y1Xe1oCGta8rmSYzM76kYo5tLGsv5R2r3s64yapYorGTPuuLyfS9NVbh9ydzmvNie2w==",
"dev": true,
"license": "MIT",
"bin": {
"tsc-files": "cli.js"
},
"peerDependencies": {
"typescript": ">=3"
}
},
"node_modules/tsconfig": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.3.43",
"version": "1.4.9",
"type": "module",
"scripts": {
"dev": "vite",
@@ -35,6 +35,7 @@
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
"@types/three": "^0.169.0",
"@vitejs/plugin-vue": "^5.1.4",
"@vue/test-utils": "^2.4.6",
"@vue/vue3-jest": "^29.2.6",
@@ -58,7 +59,6 @@
"tailwindcss": "^3.4.4",
"ts-jest": "^29.1.4",
"ts-node": "^10.9.2",
"tsc-files": "^1.1.4",
"tsx": "^4.15.6",
"typescript": "^5.4.5",
"typescript-eslint": "^8.0.0",
@@ -72,8 +72,8 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.2.16",
"@comfyorg/litegraph": "^0.8.26",
"@comfyorg/comfyui-electron-types": "^0.3.6",
"@comfyorg/litegraph": "^0.8.35",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -87,14 +87,11 @@
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.0.5",
"three": "^0.170.0",
"vue": "^3.4.31",
"vue-i18n": "^9.13.1",
"vue-router": "^4.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"lint-staged": {
"./**/*.{js,ts,tsx,vue}": "prettier --write",
"**/*.ts": "tsc-files --noEmit"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 B

View File

@@ -26,7 +26,10 @@ const betaMenuEnabled = computed(
const workflowStore = useWorkflowStore()
const isUnsavedText = computed(() =>
workflowStore.activeWorkflow?.isModified ? ' *' : ''
workflowStore.activeWorkflow?.isModified ||
!workflowStore.activeWorkflow?.isPersisted
? ' *'
: ''
)
const workflowNameText = computed(() => {
const workflowName = workflowStore.activeWorkflow?.filename

View File

@@ -125,30 +125,45 @@ const adjustMenuPosition = () => {
const menuWidth = panelRef.value.offsetWidth
const menuHeight = panelRef.value.offsetHeight
// Calculate the distance from each edge
// Calculate distances to all edges
const distanceLeft = lastDragState.value.x
const distanceRight =
lastDragState.value.windowWidth - (lastDragState.value.x + menuWidth)
const distanceTop = lastDragState.value.y
const distanceBottom =
lastDragState.value.windowHeight - (lastDragState.value.y + menuHeight)
// Determine if the menu is closer to right/bottom or left/top
const anchorRight = distanceRight < lastDragState.value.x
const anchorBottom = distanceBottom < lastDragState.value.y
// Find the smallest distance to determine which edge to anchor to
const distances = [
{ edge: 'left', distance: distanceLeft },
{ edge: 'right', distance: distanceRight },
{ edge: 'top', distance: distanceTop },
{ edge: 'bottom', distance: distanceBottom }
]
const closestEdge = distances.reduce((min, curr) =>
curr.distance < min.distance ? curr : min
)
// Calculate new position
if (anchorRight) {
x.value =
screenWidth - (lastDragState.value.windowWidth - lastDragState.value.x)
} else {
x.value = lastDragState.value.x
}
// Calculate vertical position as a percentage of screen height
const verticalRatio =
lastDragState.value.y / lastDragState.value.windowHeight
const horizontalRatio =
lastDragState.value.x / lastDragState.value.windowWidth
if (anchorBottom) {
y.value =
screenHeight -
(lastDragState.value.windowHeight - lastDragState.value.y)
// Apply positioning based on closest edge
if (closestEdge.edge === 'left') {
x.value = closestEdge.distance // Maintain exact distance from left
y.value = verticalRatio * screenHeight
} else if (closestEdge.edge === 'right') {
x.value = screenWidth - menuWidth - closestEdge.distance // Maintain exact distance from right
y.value = verticalRatio * screenHeight
} else if (closestEdge.edge === 'top') {
x.value = horizontalRatio * screenWidth
y.value = closestEdge.distance // Maintain exact distance from top
} else {
y.value = lastDragState.value.y
// bottom
x.value = horizontalRatio * screenWidth
y.value = screenHeight - menuHeight - closestEdge.distance // Maintain exact distance from bottom
}
// Ensure the menu stays within the screen bounds

View File

@@ -1,104 +0,0 @@
<template>
<div class="relative h-full w-full bg-black">
<p v-if="errorMessage" class="p-4 text-center">{{ errorMessage }}</p>
<ProgressSpinner
v-else-if="loading"
class="absolute inset-0 flex justify-center items-center h-full z-10"
/>
<div v-show="!loading" class="p-terminal rounded-none h-full w-full p-2">
<div class="h-full" ref="terminalEl"></div>
</div>
</div>
</template>
<script setup lang="ts">
import '@xterm/xterm/css/xterm.css'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import { api } from '@/scripts/api'
import { onMounted, onUnmounted, ref } from 'vue'
import { debounce } from 'lodash'
import ProgressSpinner from 'primevue/progressspinner'
import { useExecutionStore } from '@/stores/executionStore'
import { storeToRefs } from 'pinia'
import { until } from '@vueuse/core'
import { LogEntry, LogsWsMessage, TerminalSize } from '@/types/apiTypes'
const errorMessage = ref('')
const loading = ref(true)
const terminalEl = ref<HTMLDivElement>()
const fitAddon = new FitAddon()
const terminal = new Terminal({
convertEol: true
})
terminal.loadAddon(fitAddon)
const resizeTerminal = () =>
terminal.resize(terminal.cols, fitAddon.proposeDimensions().rows)
const resizeObserver = new ResizeObserver(debounce(resizeTerminal, 50))
const update = (entries: Array<LogEntry>, size?: TerminalSize) => {
if (size) {
terminal.resize(size.cols, fitAddon.proposeDimensions().rows)
}
terminal.write(entries.map((e) => e.m).join(''))
}
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
update(e.detail.entries, e.detail.size)
}
const loadLogEntries = async () => {
const logs = await api.getRawLogs()
update(logs.entries, logs.size)
}
const watchLogs = async () => {
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) {
await until(clientId).not.toBeNull()
}
api.subscribeLogs(true)
api.addEventListener('logs', logReceived)
}
onMounted(async () => {
terminal.open(terminalEl.value)
try {
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
// On older backends the endpoints wont exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
return
}
loading.value = false
resizeObserver.observe(terminalEl.value)
await watchLogs()
})
onUnmounted(() => {
if (api.clientId) {
api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
resizeObserver.disconnect()
})
</script>
<style scoped>
:deep(.p-terminal) .xterm {
overflow-x: auto;
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -0,0 +1,30 @@
<template>
<div class="relative h-full w-full bg-black" ref="rootEl">
<div class="p-terminal rounded-none h-full w-full p-2">
<div class="h-full terminal-host" ref="terminalEl"></div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, Ref } from 'vue'
import { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
const emit = defineEmits<{
created: [ReturnType<typeof useTerminal>, Ref<HTMLElement>]
}>()
const terminalEl = ref<HTMLElement>()
const rootEl = ref<HTMLElement>()
emit('created', useTerminal(terminalEl), rootEl)
</script>
<style scoped>
:deep(.p-terminal) .xterm {
overflow-x: auto;
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -0,0 +1,73 @@
<template>
<BaseTerminal @created="terminalCreated" />
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, Ref } from 'vue'
import type { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
import { electronAPI } from '@/utils/envUtil'
import { IDisposable } from '@xterm/xterm'
import BaseTerminal from './BaseTerminal.vue'
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
) => {
// TODO: use types from electron package
const terminalApi = electronAPI()['Terminal'] as {
onOutput(cb: (message: string) => void): () => void
resize(cols: number, rows: number): void
restore(): Promise<{
buffer: string[]
pos: { x: number; y: number }
size: { cols: number; rows: number }
}>
storePos(x: number, y: number): void
write(data: string): void
}
let offData: IDisposable
let offOutput: () => void
useAutoSize(root, true, true, () => {
// If we aren't visible, don't resize
if (!terminal.element?.offsetParent) return
terminalApi.resize(terminal.cols, terminal.rows)
})
onMounted(async () => {
offData = terminal.onData(async (message: string) => {
terminalApi.write(message)
})
offOutput = terminalApi.onOutput((message) => {
terminal.write(message)
})
const restore = await terminalApi.restore()
setTimeout(() => {
if (restore.buffer.length) {
terminal.resize(restore.size.cols, restore.size.rows)
terminal.write(restore.buffer.join(''))
}
}, 500)
})
onUnmounted(() => {
offData?.dispose()
offOutput?.()
})
}
</script>
<style scoped>
:deep(.p-terminal) .xterm {
overflow-x: auto;
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -0,0 +1,90 @@
<template>
<div class="bg-black h-full w-full">
<p v-if="errorMessage" class="p-4 text-center">{{ errorMessage }}</p>
<ProgressSpinner
v-else-if="loading"
class="relative inset-0 flex justify-center items-center h-full z-10"
/>
<BaseTerminal v-show="!loading" @created="terminalCreated" />
</div>
</template>
<script setup lang="ts">
import { onMounted, onUnmounted, Ref, ref } from 'vue'
import type { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
import { LogEntry, LogsWsMessage, TerminalSize } from '@/types/apiTypes'
import { api } from '@/scripts/api'
import { useExecutionStore } from '@/stores/executionStore'
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import BaseTerminal from './BaseTerminal.vue'
import ProgressSpinner from 'primevue/progressspinner'
const errorMessage = ref('')
const loading = ref(true)
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
) => {
useAutoSize(root, true, false)
const update = (entries: Array<LogEntry>, size?: TerminalSize) => {
if (size) {
terminal.resize(size.cols, terminal.rows)
}
terminal.write(entries.map((e) => e.m).join(''))
}
const logReceived = (e: CustomEvent<LogsWsMessage>) => {
update(e.detail.entries, e.detail.size)
}
const loadLogEntries = async () => {
const logs = await api.getRawLogs()
update(logs.entries, logs.size)
}
const watchLogs = async () => {
const { clientId } = storeToRefs(useExecutionStore())
if (!clientId.value) {
await until(clientId).not.toBeNull()
}
api.subscribeLogs(true)
api.addEventListener('logs', logReceived)
}
onMounted(async () => {
try {
await loadLogEntries()
} catch (err) {
console.error('Error loading logs', err)
// On older backends the endpoints wont exist
errorMessage.value =
'Unable to load logs, please ensure you have updated your ComfyUI backend.'
return
}
await watchLogs()
loading.value = false
})
onUnmounted(() => {
if (api.clientId) {
api.subscribeLogs(false)
}
api.removeEventListener('logs', logReceived)
})
}
</script>
<style scoped>
:deep(.p-terminal) .xterm {
overflow-x: auto;
}
:deep(.p-terminal) .xterm-screen {
background-color: black;
overflow-y: hidden;
}
</style>

View File

@@ -28,7 +28,14 @@
class="flex flex-row items-center gap-2"
v-if="status === 'in_progress' || status === 'paused'"
>
<ProgressBar class="flex-1" :value="downloadProgress" />
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
-->
<ProgressBar
class="flex-1"
:value="downloadProgress"
:show-value="downloadProgress > 10"
/>
<Button
class="file-action-button"

View File

@@ -0,0 +1,102 @@
<!-- A generalized form item for rendering in a form. -->
<template>
<div class="form-label flex flex-grow items-center">
<span class="text-[var(--p-text-muted-color)]">
<slot name="name-prefix"></slot>
{{ props.item.name }}
<i
v-if="props.item.tooltip"
class="pi pi-info-circle bg-transparent"
v-tooltip="props.item.tooltip"
/>
<slot name="name-suffix"></slot>
</span>
</div>
<div class="form-input flex justify-end">
<component
:is="markRaw(getFormComponent(props.item))"
:id="props.id"
v-model:modelValue="formValue"
v-bind="getFormAttrs(props.item)"
/>
</div>
</template>
<script setup lang="ts">
import { FormItem } from '@/types/settingTypes'
import { markRaw, type Component } from 'vue'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import ToggleSwitch from 'primevue/toggleswitch'
import CustomFormValue from '@/components/common/CustomFormValue.vue'
import InputSlider from '@/components/common/InputSlider.vue'
const formValue = defineModel<any>('formValue')
const props = withDefaults(
defineProps<{
item: FormItem
id: string | undefined
}>(),
{
id: undefined
}
)
function getFormAttrs(item: FormItem) {
const attrs = { ...(item.attrs || {}) }
const inputType = item.type
if (typeof inputType === 'function') {
attrs['renderFunction'] = () =>
inputType(
props.item.name,
(v: any) => (formValue.value = v),
formValue.value,
item.attrs
)
}
switch (item.type) {
case 'combo':
attrs['options'] =
typeof item.options === 'function'
? item.options(formValue.value)
: item.options
if (typeof item.options[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
break
}
return attrs
}
function getFormComponent(item: FormItem): Component {
if (typeof item.type === 'function') {
return CustomFormValue
}
switch (item.type) {
case 'boolean':
return ToggleSwitch
case 'number':
return InputNumber
case 'slider':
return InputSlider
case 'combo':
return Select
default:
return InputText
}
}
</script>
<style scoped>
.form-input :deep(.input-slider) .p-inputnumber input,
.form-input :deep(.input-slider) .slider-part {
@apply w-20;
}
.form-input :deep(.p-inputtext),
.form-input :deep(.p-select) {
@apply w-44;
}
</style>

View File

@@ -13,6 +13,7 @@
:modelValue="modelValue"
@update:modelValue="updateValue"
class="input-part"
:max-fraction-digits="3"
:class="inputClass"
:min="min"
:max="max"

View File

@@ -1,6 +1,6 @@
<template>
<Tree
class="tree-explorer p-2 2xl:p-4"
class="tree-explorer py-0 px-2 2xl:px-4"
:class="props.class"
v-model:expandedKeys="expandedKeys"
v-model:selectionKeys="selectionKeys"

View File

@@ -20,6 +20,7 @@
</template>
<div class="action-container">
<ReportIssueButton v-if="showSendError" :error="props.error" />
<FindIssueButton
:errorMessage="props.error.exception_message"
:repoOwner="repoOwner"
@@ -44,9 +45,11 @@ import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import ReportIssueButton from '@/components/dialog/content/error/ReportIssueButton.vue'
import type { ExecutionErrorWsMessage, SystemStats } from '@/types/apiTypes'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { isElectron } from '@/utils/envUtil'
const props = defineProps<{
error: ExecutionErrorWsMessage
@@ -59,6 +62,7 @@ const reportOpen = ref(false)
const showReport = () => {
reportOpen.value = true
}
const showSendError = isElectron()
const toast = useToast()
const { copy, isSupported } = useClipboard()

View File

@@ -71,6 +71,14 @@
</template>
</Suspense>
</TabPanel>
<TabPanel key="server-config" value="Server-Config">
<Suspense>
<ServerConfigPanel />
<template #fallback>
<div>Loading server config panel...</div>
</template>
</Suspense>
</TabPanel>
</TabPanels>
</Tabs>
</ScrollPanel>
@@ -93,6 +101,7 @@ import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
import AboutPanel from './setting/AboutPanel.vue'
import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
import { isElectron } from '@/utils/envUtil'
const KeybindingPanel = defineAsyncComponent(
() => import('./setting/KeybindingPanel.vue')
@@ -100,6 +109,9 @@ const KeybindingPanel = defineAsyncComponent(
const ExtensionPanel = defineAsyncComponent(
() => import('./setting/ExtensionPanel.vue')
)
const ServerConfigPanel = defineAsyncComponent(
() => import('./setting/ServerConfigPanel.vue')
)
interface ISettingGroup {
label: string
@@ -124,18 +136,33 @@ const extensionPanelNode: SettingTreeNode = {
children: []
}
const serverConfigPanelNode: SettingTreeNode = {
key: 'server-config',
label: 'Server-Config',
children: []
}
const extensionPanelNodeList = computed<SettingTreeNode[]>(() => {
const settingStore = useSettingStore()
const showExtensionPanel = settingStore.get('Comfy.Settings.ExtensionPanel')
return showExtensionPanel ? [extensionPanelNode] : []
})
/**
* Server config panel is only available in Electron. We might want to support
* it in the web version in the future.
*/
const serverConfigPanelNodeList = computed<SettingTreeNode[]>(() => {
return isElectron() ? [serverConfigPanelNode] : []
})
const settingStore = useSettingStore()
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const categories = computed<SettingTreeNode[]>(() => [
...(settingRoot.value.children || []),
keybindingPanelNode,
...extensionPanelNodeList.value,
...serverConfigPanelNodeList.value,
aboutPanelNode
])
const activeCategory = ref<SettingTreeNode | null>(null)

View File

@@ -0,0 +1,51 @@
<template>
<Button
@click="reportIssue"
:label="$t('reportIssue')"
:severity="submitted ? 'success' : 'secondary'"
:icon="icon"
:disabled="submitted"
v-tooltip="$t('reportIssueTooltip')"
>
</Button>
</template>
<script setup lang="ts">
import { computed, ref, defineProps } from 'vue'
import Button from 'primevue/button'
import { useToast } from 'primevue/usetoast'
import { ExecutionErrorWsMessage } from '@/types/apiTypes'
import { useI18n } from 'vue-i18n'
import { electronAPI } from '@/utils/envUtil'
const { error } = defineProps<{
error: ExecutionErrorWsMessage
}>()
const { t } = useI18n()
const toast = useToast()
const submitting = ref(false)
const submitted = ref(false)
const icon = computed(
() => `pi ${submitting.value ? 'pi-spin pi-spinner' : 'pi-send'}`
)
const reportIssue = async () => {
if (submitting.value) return
submitting.value = true
try {
await electronAPI().sendErrorToSentry(error.exception_message, {
stackTrace: error.traceback?.join('\n'),
nodeType: error.node_type
})
submitted.value = true
toast.add({
severity: 'success',
summary: t('reportSent'),
life: 3000
})
} finally {
submitting.value = false
}
}
</script>

View File

@@ -0,0 +1,43 @@
<template>
<div
v-for="([label, items], i) in Object.entries(serverConfigsByCategory)"
:key="label"
>
<Divider v-if="i > 0" />
<h3>{{ formatCamelCase(label) }}</h3>
<div v-for="item in items" :key="item.name" class="flex items-center mb-4">
<FormItem :item="item" v-model:formValue="item.value" />
</div>
</div>
</template>
<script setup lang="ts">
import Divider from 'primevue/divider'
import FormItem from '@/components/common/FormItem.vue'
import { formatCamelCase } from '@/utils/formatUtil'
import { useSettingStore } from '@/stores/settingStore'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import { storeToRefs } from 'pinia'
import { onMounted, watch } from 'vue'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
const settingStore = useSettingStore()
const serverConfigStore = useServerConfigStore()
const { serverConfigsByCategory, launchArgs, serverConfigValues } =
storeToRefs(serverConfigStore)
onMounted(() => {
serverConfigStore.loadServerConfig(
SERVER_CONFIG_ITEMS,
settingStore.get('Comfy.Server.ServerConfigValues')
)
})
watch(launchArgs, (newVal) => {
settingStore.set('Comfy.Server.LaunchArgs', newVal)
})
watch(serverConfigValues, (newVal) => {
settingStore.set('Comfy.Server.ServerConfigValues', newVal)
})
</script>

View File

@@ -7,45 +7,15 @@
:key="setting.id"
class="setting-item flex items-center mb-4"
>
<div class="setting-label flex flex-grow items-center">
<span class="text-[var(--p-text-muted-color)]">
<Tag v-if="setting.experimental" :value="$t('experimental')" />
<Tag
v-if="setting.deprecated"
:value="$t('deprecated')"
severity="danger" />
{{ setting.name }}
<i
v-if="setting.tooltip"
class="pi pi-info-circle bg-transparent"
v-tooltip="setting.tooltip"
/></span>
</div>
<div class="setting-input flex justify-end">
<component
:is="markRaw(getSettingComponent(setting))"
:id="setting.id"
:modelValue="settingStore.get(setting.id)"
@update:modelValue="updateSetting(setting, $event)"
v-bind="getSettingAttrs(setting)"
/>
</div>
<SettingItem :setting="setting" />
</div>
</div>
</template>
<script setup lang="ts">
import { useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import { markRaw, type Component } from 'vue'
import InputText from 'primevue/inputtext'
import InputNumber from 'primevue/inputnumber'
import Select from 'primevue/select'
import ToggleSwitch from 'primevue/toggleswitch'
import Divider from 'primevue/divider'
import Tag from 'primevue/tag'
import CustomSettingValue from '@/components/dialog/content/setting/CustomSettingValue.vue'
import InputSlider from '@/components/common/InputSlider.vue'
import SettingItem from '@/components/dialog/content/setting/SettingItem.vue'
import { SettingParams } from '@/types/settingTypes'
import { formatCamelCase } from '@/utils/formatUtil'
defineProps<{
@@ -55,67 +25,4 @@ defineProps<{
}
divider?: boolean
}>()
const settingStore = useSettingStore()
function getSettingAttrs(setting: SettingParams) {
const attrs = { ...(setting.attrs || {}) }
const settingType = setting.type
if (typeof settingType === 'function') {
attrs['renderFunction'] = () =>
settingType(
setting.name,
(v) => updateSetting(setting, v),
settingStore.get(setting.id),
setting.attrs
)
}
switch (setting.type) {
case 'combo':
attrs['options'] =
typeof setting.options === 'function'
? setting.options(settingStore.get(setting.id))
: setting.options
if (typeof setting.options[0] !== 'string') {
attrs['optionLabel'] = 'text'
attrs['optionValue'] = 'value'
}
break
}
return attrs
}
const updateSetting = (setting: SettingParams, value: any) => {
settingStore.set(setting.id, value)
}
function getSettingComponent(setting: SettingParams): Component {
if (typeof setting.type === 'function') {
return CustomSettingValue
}
switch (setting.type) {
case 'boolean':
return ToggleSwitch
case 'number':
return InputNumber
case 'slider':
return InputSlider
case 'combo':
return Select
default:
return InputText
}
}
</script>
<style scoped>
.setting-input :deep(.input-slider) .p-inputnumber input,
.setting-input :deep(.input-slider) .slider-part {
@apply w-20;
}
.setting-input :deep(.p-inputtext),
.setting-input :deep(.p-select) {
@apply w-44;
}
</style>

View File

@@ -0,0 +1,35 @@
<template>
<FormItem
:item="setting"
:id="setting.id"
:formValue="settingValue"
@update:formValue="updateSettingValue"
>
<template #name-prefix>
<Tag v-if="setting.experimental" :value="$t('experimental')" />
<Tag
v-if="setting.deprecated"
:value="$t('deprecated')"
severity="danger"
/>
</template>
</FormItem>
</template>
<script setup lang="ts">
import Tag from 'primevue/tag'
import FormItem from '@/components/common/FormItem.vue'
import { useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import { computed } from 'vue'
const props = defineProps<{
setting: SettingParams
}>()
const settingStore = useSettingStore()
const settingValue = computed(() => settingStore.get(props.setting.id))
const updateSettingValue = (value: any) => {
settingStore.set(props.setting.id, value)
}
</script>

View File

@@ -47,7 +47,8 @@ import {
DragAndScale,
LGraphCanvas,
ContextMenu,
LGraphBadge
LGraphBadge,
CanvasPointer
} from '@comfyorg/litegraph'
import type { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
import { useCanvasStore } from '@/stores/graphStore'
@@ -61,6 +62,7 @@ import { usePragmaticDroppable } from '@/hooks/dndHooks'
import { useWorkflowStore } from '@/stores/workflowStore'
import { setStorageValue } from '@/scripts/utils'
import { ChangeTracker } from '@/scripts/changeTracker'
import { api } from '@/scripts/api'
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
@@ -161,6 +163,28 @@ watchEffect(() => {
}
})
watchEffect(() => {
CanvasPointer.doubleClickTime = settingStore.get(
'Comfy.Pointer.DoubleClickTime'
)
})
watchEffect(() => {
CanvasPointer.bufferTime = settingStore.get('Comfy.Pointer.ClickBufferTime')
})
watchEffect(() => {
CanvasPointer.maxClickDrift = settingStore.get('Comfy.Pointer.ClickDrift')
})
watchEffect(() => {
LiteGraph.CANVAS_GRID_SIZE = settingStore.get('Comfy.SnapToGrid.GridSize')
})
watchEffect(() => {
LiteGraph.alwaysSnapToGrid = settingStore.get('pysssss.SnapToGrid')
})
watchEffect(() => {
if (!canvasStore.canvas) return
@@ -178,13 +202,26 @@ watchEffect(() => {
})
const workflowStore = useWorkflowStore()
const persistCurrentWorkflow = () => {
const workflow = JSON.stringify(comfyApp.serializeGraph())
localStorage.setItem('workflow', workflow)
if (api.clientId) {
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
}
}
watchEffect(() => {
if (workflowStore.activeWorkflow) {
const workflow = workflowStore.activeWorkflow
setStorageValue('Comfy.PreviousWorkflow', workflow.key)
// When the activeWorkflow changes, the graph has already been loaded.
// Saving the current state of the graph to the localStorage.
persistCurrentWorkflow()
}
})
api.addEventListener('graphChanged', persistCurrentWorkflow)
usePragmaticDroppable(() => canvasRef.value, {
onDrop: (event) => {
const loc = event.location.current.input
@@ -262,6 +299,7 @@ onMounted(async () => {
ChangeTracker.init(comfyApp)
await comfyApp.setup(canvasRef.value)
canvasStore.canvas = comfyApp.canvas
canvasStore.canvas.render_canvas_border = false
workspaceStore.spinner = false
window['app'] = comfyApp

View File

@@ -85,12 +85,14 @@ onMounted(async () => {
appData.value = paths.appData
appPath.value = paths.appPath
installPath.value = paths.defaultInstallPath
await validatePath(paths.defaultInstallPath)
})
const validatePath = async () => {
const validatePath = async (path: string) => {
try {
pathError.value = ''
const validation = await electron.validateInstallPath(installPath.value)
const validation = await electron.validateInstallPath(path)
if (!validation.isValid) {
pathError.value = validation.error
@@ -105,7 +107,7 @@ const browsePath = async () => {
const result = await electron.showDirectoryPicker()
if (result) {
installPath.value = result
await validatePath()
await validatePath(result)
}
} catch (error) {
pathError.value = t('install.failedToSelectDirectory')

View File

@@ -1,6 +1,11 @@
<template>
<div class="comfy-vue-node-search-container">
<div class="comfy-vue-node-preview-container" v-if="enableNodePreview">
<div
class="comfy-vue-node-search-container flex justify-center items-center w-full min-w-96 pointer-events-auto"
>
<div
class="comfy-vue-node-preview-container absolute left-[-350px] top-[50px]"
v-if="enableNodePreview"
>
<NodePreview
:nodeDef="hoveredSuggestion"
:key="hoveredSuggestion?.name || ''"
@@ -11,10 +16,10 @@
<Button
icon="pi pi-filter"
severity="secondary"
class="_filter-button"
class="filter-button z-10"
@click="nodeSearchFilterVisible = true"
/>
<Dialog v-model:visible="nodeSearchFilterVisible" class="_dialog">
<Dialog v-model:visible="nodeSearchFilterVisible" class="min-w-96">
<template #header>
<h3>Add node filter condition</h3>
</template>
@@ -25,7 +30,7 @@
<AutoCompletePlus
:model-value="props.filters"
class="comfy-vue-node-search-box"
class="comfy-vue-node-search-box z-10 flex-grow"
scrollHeight="40vh"
:placeholder="placeholder"
:input-id="inputId"
@@ -148,31 +153,3 @@ const setHoverSuggestion = (index: number) => {
hoveredSuggestion.value = value
}
</script>
<style scoped>
.comfy-vue-node-search-container {
@apply flex justify-center items-center w-full min-w-96;
}
.comfy-vue-node-search-container * {
pointer-events: auto;
}
.comfy-vue-node-preview-container {
position: absolute;
left: -350px;
top: 50px;
}
.comfy-vue-node-search-box {
@apply z-10 flex-grow;
}
._filter-button {
z-index: 10;
}
._dialog {
@apply min-w-96;
}
</style>

View File

@@ -31,7 +31,7 @@
<ElectronDownloadItems v-if="isElectron()" />
<TreeExplorer
class="model-lib-tree-explorer py-0"
class="model-lib-tree-explorer"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
>

View File

@@ -48,7 +48,7 @@
class="m-2"
/>
<TreeExplorer
class="node-lib-tree-explorer py-0"
class="node-lib-tree-explorer"
:roots="renderedRoot.children"
v-model:expandedKeys="expandedKeys"
>

View File

@@ -1,6 +1,18 @@
<template>
<SidebarTabTemplate :title="$t('sideToolbar.queue')">
<template #tool-buttons>
<Popover ref="outputFilterPopup">
<OutputFilters />
</Popover>
<Button
icon="pi pi-filter"
text
severity="secondary"
@click="outputFilterPopup.toggle($event)"
v-tooltip="$t(`sideToolbar.queueTab.filter`)"
:class="{ 'text-yellow-500': anyFilter }"
/>
<Button
:icon="
imageFit === 'cover'
@@ -99,6 +111,7 @@ import Button from 'primevue/button'
import ConfirmPopup from 'primevue/confirmpopup'
import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import Popover from 'primevue/popover'
import ProgressSpinner from 'primevue/progressspinner'
import TaskItem from './queue/TaskItem.vue'
import ResultGallery from './queue/ResultGallery.vue'
@@ -111,7 +124,9 @@ import { useSettingStore } from '@/stores/settingStore'
import { useCommandStore } from '@/stores/commandStore'
import { app } from '@/scripts/app'
const IMAGE_FIT = 'Comfy.Queue.ImageFit'
const SETTING_FIT = 'Comfy.Queue.ImageFit'
const SETTING_FLAT = 'Comfy.Queue.ShowFlatList'
const SETTING_FILTER = 'Comfy.Queue.Filter'
const confirm = useConfirm()
const toast = useToast()
const queueStore = useQueueStore()
@@ -120,7 +135,7 @@ const commandStore = useCommandStore()
const { t } = useI18n()
// Expanded view: show all outputs in a flat list.
const isExpanded = ref(false)
const isExpanded = computed<boolean>(() => settingStore.get(SETTING_FLAT))
const visibleTasks = ref<TaskItemImpl[]>([])
const scrollContainer = ref<HTMLElement | null>(null)
const loadMoreTrigger = ref<HTMLElement | null>(null)
@@ -128,10 +143,27 @@ const galleryActiveIndex = ref(-1)
// Folder view: only show outputs from a single selected task.
const folderTask = ref<TaskItemImpl | null>(null)
const isInFolderView = computed(() => folderTask.value !== null)
const imageFit = computed<string>(() => settingStore.get(IMAGE_FIT))
const imageFit = computed<string>(() => settingStore.get(SETTING_FIT))
const hideCached = computed<boolean>(
() => settingStore.get(SETTING_FILTER)?.hideCached
)
const hideCanceled = computed<boolean>(
() => settingStore.get(SETTING_FILTER)?.hideCanceled
)
const anyFilter = computed(() => hideCanceled.value || hideCached.value)
watch(hideCached, () => {
updateVisibleTasks()
})
watch(hideCanceled, () => {
updateVisibleTasks()
})
const outputFilterPopup = ref(null)
const ITEMS_PER_PAGE = 8
const SCROLL_THRESHOLD = 100 // pixels from bottom to trigger load
const MAX_LOAD_ITERATIONS = 10
const allTasks = computed(() =>
isInFolderView.value
@@ -149,21 +181,48 @@ const allGalleryItems = computed(() =>
})
)
const loadMoreItems = () => {
const filterTasks = (tasks: TaskItemImpl[]) =>
tasks.filter((t) => {
if (
hideCanceled.value &&
t.status?.messages?.at(-1)?.[0] === 'execution_interrupted'
) {
return false
}
if (
hideCached.value &&
t.flatOutputs?.length &&
t.flatOutputs.every((o) => o.cached)
) {
return false
}
return true
})
const loadMoreItems = (iteration: number) => {
const currentLength = visibleTasks.value.length
const newTasks = allTasks.value.slice(
const newTasks = filterTasks(allTasks.value).slice(
currentLength,
currentLength + ITEMS_PER_PAGE
)
visibleTasks.value.push(...newTasks)
// If we've added some items, check if we need to add more
// Prevent loading everything at once in case of render update issues
if (newTasks.length && iteration < MAX_LOAD_ITERATIONS) {
nextTick(() => {
checkAndLoadMore(iteration + 1)
})
}
}
const checkAndLoadMore = () => {
const checkAndLoadMore = (iteration: number) => {
if (!scrollContainer.value) return
const { scrollHeight, scrollTop, clientHeight } = scrollContainer.value
if (scrollHeight - scrollTop - clientHeight < SCROLL_THRESHOLD) {
loadMoreItems()
loadMoreItems(iteration)
}
}
@@ -171,7 +230,7 @@ useInfiniteScroll(
scrollContainer,
() => {
if (visibleTasks.value.length < allTasks.value.length) {
loadMoreItems()
loadMoreItems(0)
}
},
{ distance: SCROLL_THRESHOLD }
@@ -181,16 +240,20 @@ useInfiniteScroll(
// This is necessary as the sidebar tab can change size when user drags the splitter.
useResizeObserver(scrollContainer, () => {
nextTick(() => {
checkAndLoadMore()
checkAndLoadMore(0)
})
})
const updateVisibleTasks = () => {
visibleTasks.value = allTasks.value.slice(0, ITEMS_PER_PAGE)
visibleTasks.value = filterTasks(allTasks.value).slice(0, ITEMS_PER_PAGE)
nextTick(() => {
checkAndLoadMore(0)
})
}
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value
settingStore.set(SETTING_FLAT, !isExpanded.value)
updateVisibleTasks()
}
@@ -291,7 +354,10 @@ const exitFolderView = () => {
}
const toggleImageFit = () => {
settingStore.set(IMAGE_FIT, imageFit.value === 'cover' ? 'contain' : 'cover')
settingStore.set(
SETTING_FIT,
imageFit.value === 'cover' ? 'contain' : 'cover'
)
}
onMounted(() => {

View File

@@ -50,10 +50,13 @@
<template #node="{ node }">
<TreeExplorerTreeNode :node="node">
<template #before-label="{ node }">
<span v-if="node.data.isModified">*</span>
<span v-if="node.data.isModified || !node.data.isPersisted"
>*</span
>
</template>
<template #actions="{ node }">
<Button
class="close-workflow-button"
icon="pi pi-times"
text
:severity="

View File

@@ -16,9 +16,13 @@
class="mt-2 flex flex-row items-center gap-2"
v-if="['in_progress', 'paused', 'completed'].includes(download.status)"
>
<!-- Temporary fix for issue when % only comes into view only if the progress bar is large enough
https://comfy-organization.slack.com/archives/C07H3GLKDPF/p1731551013385499
-->
<ProgressBar
class="flex-1"
:value="Number((download.progress * 100).toFixed(1))"
:show-value="download.progress > 0.1"
/>
<Button

View File

@@ -1,6 +1,6 @@
<template>
<TreeExplorer
class="node-lib-bookmark-tree-explorer py-0"
class="node-lib-bookmark-tree-explorer"
ref="treeExplorerRef"
:roots="renderedBookmarkedRoot.children"
:expandedKeys="expandedKeys"

View File

@@ -0,0 +1,38 @@
<template>
<div class="flex flex-col gap-2">
<label class="flex items-center gap-2">
{{ $t('sideToolbar.queueTab.filters.hideCached') }}
<ToggleSwitch v-model="hideCached" />
</label>
<label class="flex items-center gap-2">
{{ $t('sideToolbar.queueTab.filters.hideCanceled') }}
<ToggleSwitch v-model="hideCanceled" />
</label>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import ToggleSwitch from 'primevue/toggleswitch'
import { useSettingStore } from '@/stores/settingStore'
const SETTING_FILTER = 'Comfy.Queue.Filter'
const { t } = useI18n()
const settingStore = useSettingStore()
const filter = settingStore.get(SETTING_FILTER) ?? {}
const createCompute = (k: string) =>
computed({
get() {
return filter[k]
},
set(value) {
filter[k] = value
settingStore.set(SETTING_FILTER, filter)
}
})
const hideCached = createCompute('hideCached')
const hideCanceled = createCompute('hideCanceled')
</script>

View File

@@ -31,16 +31,20 @@
<div class="task-item-details">
<div class="tag-wrapper status-tag-group">
<Tag v-if="isFlatTask && task.isHistory" class="node-name-tag">
<Tag v-if="isFlatTask && task.isHistory && node" class="node-name-tag">
<Button
class="task-node-link"
:label="`${node?.type} (#${node?.id})`"
:label="`${node.type} (#${node.id})`"
link
size="small"
@click="app.goToNode(node?.id)"
/>
</Tag>
<Tag :severity="taskTagSeverity(task.displayStatus)">
<Tag
:severity="taskTagSeverity(task.displayStatus)"
class="task-duration relative"
>
<i v-if="isCachedResult" class="pi pi-server task-cached-icon"></i>
<span v-html="taskStatusText(task.displayStatus)"></span>
<span v-if="task.isHistory" class="task-time">
{{ formatTime(task.executionTimeInSeconds) }}
@@ -90,6 +94,7 @@ const node: ComfyNode | null =
) ?? null
: null
const progressPreviewBlobUrl = ref('')
const isCachedResult = props.isFlatTask && coverResult?.cached
const emit = defineEmits<{
(
@@ -142,7 +147,7 @@ const taskStatusText = (status: TaskItemDisplayStatus) => {
case TaskItemDisplayStatus.Running:
return '<i class="pi pi-spin pi-spinner" style="font-weight: bold"></i> Running'
case TaskItemDisplayStatus.Completed:
return '<i class="pi pi-check" style="font-weight: bold"></i>'
return `<i class="pi pi-check${isCachedResult ? ' cached' : ''}" style="font-weight: bold"></i>`
case TaskItemDisplayStatus.Failed:
return 'Failed'
case TaskItemDisplayStatus.Cancelled:
@@ -226,4 +231,15 @@ are floating on top of images. */
object-fit: cover;
object-position: center;
}
.task-cached-icon {
color: #fff;
}
:deep(.pi-check.cached) {
font-size: 12px;
position: absolute;
left: 16px;
top: 12px;
}
</style>

View File

@@ -18,7 +18,10 @@
<div class="relative">
<span
class="status-indicator"
v-if="!workspaceStore.shiftDown && option.workflow.isModified"
v-if="
!workspaceStore.shiftDown &&
(option.workflow.isModified || !option.workflow.isPersisted)
"
>•</span
>
<Button

View File

@@ -59,7 +59,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 's',
ctrl: true
},
commandId: 'Comfy.ExportWorkflow'
commandId: 'Comfy.SaveWorkflow'
},
{
combo: {
@@ -74,13 +74,6 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
},
commandId: 'Comfy.ClearWorkflow'
},
{
combo: {
key: 'd',
ctrl: true
},
commandId: 'Comfy.LoadDefaultWorkflow'
},
{
combo: {
key: 'g',
@@ -173,7 +166,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: '`',
ctrl: true
},
commandId: 'Workspace.ToggleBottomPanelTab.integrated-terminal'
commandId: 'Workspace.ToggleBottomPanelTab.logs-terminal'
},
{
combo: {

View File

@@ -1,9 +1,6 @@
import type { Keybinding } from '@/types/keyBindingTypes'
import { NodeBadgeMode } from '@/types/nodeSource'
import {
LinkReleaseTriggerAction,
LinkReleaseTriggerMode
} from '@/types/searchBoxTypes'
import { LinkReleaseTriggerAction } from '@/types/searchBoxTypes'
import type { SettingParams } from '@/types/settingTypes'
import { LinkMarkerShape } from '@comfyorg/litegraph'
import { LiteGraph } from '@comfyorg/litegraph'
@@ -24,17 +21,9 @@ export const CORE_SETTINGS: SettingParams[] = [
options: ['default', 'litegraph (legacy)'],
defaultValue: 'default'
},
{
id: 'Comfy.NodeSearchBoxImpl.LinkReleaseTrigger',
category: ['Comfy', 'Node Search Box', 'LinkReleaseTrigger'],
name: 'Trigger on link release',
type: 'hidden',
options: Object.values(LinkReleaseTriggerMode),
defaultValue: LinkReleaseTriggerMode.ALWAYS,
deprecated: true
},
{
id: 'Comfy.LinkRelease.Action',
category: ['LiteGraph', 'LinkRelease', 'Action'],
name: 'Action on link release (No modifier)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
@@ -42,6 +31,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.LinkRelease.ActionShift',
category: ['LiteGraph', 'LinkRelease', 'ActionShift'],
name: 'Action on link release (Shift)',
type: 'combo',
options: Object.values(LinkReleaseTriggerAction),
@@ -81,7 +71,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Sidebar.Location',
category: ['Comfy', 'Sidebar', 'Location'],
category: ['Appearance', 'Sidebar', 'Location'],
name: 'Sidebar location',
type: 'combo',
options: ['left', 'right'],
@@ -89,7 +79,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Sidebar.Size',
category: ['Comfy', 'Sidebar', 'Size'],
category: ['Appearance', 'Sidebar', 'Size'],
name: 'Sidebar size',
type: 'combo',
options: ['normal', 'small'],
@@ -97,7 +87,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.TextareaWidget.FontSize',
category: ['Comfy', 'Node Widget', 'TextareaWidget', 'FontSize'],
category: ['Appearance', 'Node Widget', 'TextareaWidget', 'FontSize'],
name: 'Textarea widget font size',
type: 'slider',
defaultValue: 10,
@@ -121,7 +111,8 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Graph.CanvasInfo',
name: 'Show canvas info (fps, etc.)',
category: ['LiteGraph', 'Canvas', 'CanvasInfo'],
name: 'Show canvas info on bottom left corner (fps, etc.)',
type: 'boolean',
defaultValue: true
},
@@ -143,6 +134,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Node.Opacity',
category: ['Appearance', 'Node', 'Opacity'],
name: 'Node opacity',
type: 'slider',
defaultValue: 1,
@@ -167,6 +159,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Graph.ZoomSpeed',
category: ['LiteGraph', 'Canvas', 'ZoomSpeed'],
name: 'Canvas zoom speed',
type: 'slider',
defaultValue: 1.1,
@@ -205,8 +198,28 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: 'cover'
},
// Hidden setting used by the queue for if the results should be grouped by prompt
{
id: 'Comfy.Queue.ShowFlatList',
name: 'Queue show flat list',
type: 'hidden',
defaultValue: false
},
// Hidden setting used by the queue to filter certain results
{
id: 'Comfy.Queue.Filter',
name: 'Queue output filters',
type: 'hidden',
defaultValue: {
hideCanceled: false,
hideCached: false
},
versionAdded: '1.4.3'
},
{
id: 'Comfy.GroupSelectedNodes.Padding',
category: ['LiteGraph', 'Group', 'Padding'],
name: 'Group selected nodes padding',
type: 'slider',
defaultValue: 10,
@@ -217,12 +230,14 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Node.DoubleClickTitleToEdit',
category: ['LiteGraph', 'Node', 'DoubleClickTitleToEdit'],
name: 'Double click node title to edit',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Group.DoubleClickTitleToEdit',
category: ['LiteGraph', 'Group', 'DoubleClickTitleToEdit'],
name: 'Double click group title to edit',
type: 'boolean',
defaultValue: true
@@ -270,6 +285,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.NodeBadge.NodeSourceBadgeMode',
category: ['LiteGraph', 'Node', 'NodeSourceBadgeMode'],
name: 'Node source badge mode',
type: 'combo',
options: Object.values(NodeBadgeMode),
@@ -277,6 +293,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.NodeBadge.NodeIdBadgeMode',
category: ['LiteGraph', 'Node', 'NodeIdBadgeMode'],
name: 'Node ID badge mode',
type: 'combo',
options: [NodeBadgeMode.None, NodeBadgeMode.ShowAll],
@@ -284,6 +301,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.NodeBadge.NodeLifeCycleBadgeMode',
category: ['LiteGraph', 'Node', 'NodeLifeCycleBadgeMode'],
name: 'Node life cycle badge mode',
type: 'combo',
options: [NodeBadgeMode.None, NodeBadgeMode.ShowAll],
@@ -316,7 +334,7 @@ export const CORE_SETTINGS: SettingParams[] = [
*/
{
id: 'Comfy.PreviewFormat',
category: ['Comfy', 'Node Widget', 'PreviewFormat'],
category: ['LiteGraph', 'Node Widget', 'PreviewFormat'],
name: 'Preview image format',
tooltip:
'When displaying a preview in the image widget, convert it to a lightweight image, e.g. webp, jpeg, webp;50, etc.',
@@ -325,14 +343,14 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.DisableSliders',
category: ['Comfy', 'Node Widget', 'DisableSliders'],
category: ['LiteGraph', 'Node Widget', 'DisableSliders'],
name: 'Disable node widget sliders',
type: 'boolean',
defaultValue: false
},
{
id: 'Comfy.DisableFloatRounding',
category: ['Comfy', 'Node Widget', 'DisableFloatRounding'],
category: ['LiteGraph', 'Node Widget', 'DisableFloatRounding'],
name: 'Disable default float widget rounding.',
tooltip:
'(requires page reload) Cannot disable round when round is set by the node in the backend.',
@@ -341,7 +359,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.FloatRoundingPrecision',
category: ['Comfy', 'Node Widget', 'FloatRoundingPrecision'],
category: ['LiteGraph', 'Node Widget', 'FloatRoundingPrecision'],
name: 'Float widget rounding decimal places [0 = auto].',
tooltip: '(requires page reload)',
type: 'slider',
@@ -354,7 +372,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.EnableTooltips',
category: ['Comfy', 'Node', 'EnableTooltips'],
category: ['LiteGraph', 'Node', 'EnableTooltips'],
name: 'Enable Tooltips',
type: 'boolean',
defaultValue: true
@@ -395,6 +413,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Graph.CanvasMenu',
category: ['LiteGraph', 'Canvas', 'CanvasMenu'],
name: 'Show graph canvas menu',
type: 'boolean',
defaultValue: true
@@ -448,7 +467,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.LinkRenderMode',
category: ['Comfy', 'Graph', 'LinkRenderMode'],
category: ['LiteGraph', 'Graph', 'LinkRenderMode'],
name: 'Link Render Mode',
defaultValue: 2,
type: 'combo',
@@ -461,6 +480,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Node.AutoSnapLinkToSlot',
category: ['LiteGraph', 'Node', 'AutoSnapLinkToSlot'],
name: 'Auto snap link to node slot',
tooltip:
'When dragging a link over a node, the link automatically snap to a viable input slot on the node',
@@ -470,6 +490,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Node.SnapHighlightsNode',
category: ['LiteGraph', 'Node', 'SnapHighlightsNode'],
name: 'Snap highlights node',
tooltip:
'When dragging a link over a node with viable input slot, highlight the node',
@@ -479,6 +500,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Node.BypassAllLinksOnDelete',
category: ['LiteGraph', 'Node', 'BypassAllLinksOnDelete'],
name: 'Keep all links when deleting nodes',
tooltip:
'When deleting a node, attempt to reconnect all of its input and output links (bypassing the deleted node)',
@@ -488,6 +510,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Node.MiddleClickRerouteNode',
category: ['LiteGraph', 'Node', 'MiddleClickRerouteNode'],
name: 'Middle-click creates a new Reroute node',
type: 'boolean',
defaultValue: true,
@@ -495,6 +518,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.RerouteBeta',
category: ['LiteGraph', 'RerouteBeta'],
name: 'Opt-in to the reroute beta test',
tooltip:
'Enables the new native reroutes.\n\nReroutes can be added by holding alt and dragging from a link line, or on the link menu.\n\nDisabling this option is non-destructive - reroutes are hidden.',
@@ -505,6 +529,7 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.Graph.LinkMarkers',
category: ['LiteGraph', 'Link', 'LinkMarkers'],
name: 'Link midpoint markers',
defaultValue: LinkMarkerShape.Circle,
type: 'combo',
@@ -517,9 +542,105 @@ export const CORE_SETTINGS: SettingParams[] = [
},
{
id: 'Comfy.DOMClippingEnabled',
category: ['Comfy', 'Node', 'DOMClippingEnabled'],
category: ['LiteGraph', 'Node', 'DOMClippingEnabled'],
name: 'Enable DOM element clipping (enabling may reduce performance)',
type: 'boolean',
defaultValue: true
},
{
id: 'Comfy.Graph.CtrlShiftZoom',
category: ['LiteGraph', 'Canvas', 'CtrlShiftZoom'],
name: 'Enable fast-zoom shortcut (Ctrl + Shift + Drag)',
type: 'boolean',
defaultValue: true,
versionAdded: '1.4.0'
},
{
id: 'Comfy.Pointer.ClickDrift',
category: ['LiteGraph', 'Pointer', 'ClickDrift'],
name: 'Pointer click drift (maximum distance)',
tooltip:
'If the pointer moves more than this distance while holding a button down, it is considered dragging (rather than clicking).\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.',
experimental: true,
type: 'slider',
attrs: {
min: 0,
max: 20,
step: 1
},
defaultValue: 6,
versionAdded: '1.4.3'
},
{
id: 'Comfy.Pointer.ClickBufferTime',
category: ['LiteGraph', 'Pointer', 'ClickBufferTime'],
name: 'Pointer click drift delay',
tooltip:
'After pressing a pointer button down, this is the maximum time (in milliseconds) that pointer movement can be ignored for.\n\nHelps prevent objects from being unintentionally nudged if the pointer is moved whilst clicking.',
experimental: true,
type: 'slider',
attrs: {
min: 0,
max: 1000,
step: 25
},
defaultValue: 150,
versionAdded: '1.4.3'
},
{
id: 'Comfy.Pointer.DoubleClickTime',
category: ['LiteGraph', 'Pointer', 'DoubleClickTime'],
name: 'Double click interval (maximum)',
tooltip:
'The maximum time in milliseconds between the two clicks of a double-click. Increasing this value may assist if double-clicks are sometimes not registered.',
type: 'slider',
attrs: {
min: 100,
max: 1000,
step: 50
},
defaultValue: 300,
versionAdded: '1.4.3'
},
{
id: 'Comfy.SnapToGrid.GridSize',
category: ['LiteGraph', 'Canvas', 'GridSize'],
name: 'Snap to grid size',
type: 'slider',
attrs: {
min: 1,
max: 500
},
tooltip:
'When dragging and resizing nodes while holding shift they will be aligned to the grid, this controls the size of that grid.',
defaultValue: LiteGraph.CANVAS_GRID_SIZE
},
// Keep the 'pysssss.SnapToGrid' setting id so we don't need to migrate setting values.
// Using a new setting id can cause existing users to lose their existing settings.
{
id: 'pysssss.SnapToGrid',
category: ['LiteGraph', 'Canvas', 'AlwaysSnapToGrid'],
name: 'Always snap to grid',
type: 'boolean',
defaultValue: false,
versionAdded: '1.3.13'
},
{
id: 'Comfy.Server.ServerConfigValues',
name: 'Server config values for frontend display',
tooltip: 'Server config values used for frontend display only',
type: 'hidden',
// Mapping from server config id to value.
defaultValue: {} as Record<string, any>,
versionAdded: '1.4.8'
},
{
id: 'Comfy.Server.LaunchArgs',
name: 'Server launch arguments',
tooltip:
'These are the actual arguments that are passed to the server when it is launched.',
type: 'hidden',
defaultValue: {} as Record<string, string>,
versionAdded: '1.4.8'
}
]

View File

@@ -0,0 +1,432 @@
import { FormItem } from '@/types/settingTypes'
import {
LatentPreviewMethod,
LogLevel,
HashFunction,
AutoLaunch,
CudaMalloc,
FloatingPointPrecision,
CrossAttentionMethod,
VramManagement
} from '@/types/serverArgs'
export interface ServerConfig<T> extends FormItem {
id: string
defaultValue: T
category?: string[]
// Override the default value getter with a custom function.
getValue?: (value: T) => Record<string, any>
}
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<any>[] = [
// We only need these settings in the web version. Desktop app manages them already.
{
id: 'listen',
name: 'Host: The IP address to listen on',
category: ['Network'],
type: 'text',
defaultValue: '127.0.0.1'
},
{
id: 'port',
name: 'Port: The port to listen on',
category: ['Network'],
type: 'number',
defaultValue: 8188
}
]
export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
// Network settings
{
id: 'tls-keyfile',
name: 'TLS Key File: Path to TLS key file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: undefined
},
{
id: 'tls-certfile',
name: 'TLS Certificate File: Path to TLS certificate file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: undefined
},
{
id: 'enable-cors-header',
name: 'Enable CORS header: Use "*" for all origins or specify domain',
category: ['Network'],
type: 'text',
defaultValue: undefined
},
{
id: 'max-upload-size',
name: 'Maximum upload size (MB)',
category: ['Network'],
type: 'number',
defaultValue: 100
},
// Launch behavior
{
id: 'auto-launch',
name: 'Automatically opens in the browser on startup',
category: ['Launch'],
type: 'combo',
options: Object.values(AutoLaunch),
defaultValue: AutoLaunch.Auto,
getValue: (value: AutoLaunch) => {
switch (value) {
case AutoLaunch.Auto:
return {}
case AutoLaunch.Enable:
return {
['auto-launch']: true
}
case AutoLaunch.Disable:
return {
['disable-auto-launch']: true
}
}
}
},
// CUDA settings
{
id: 'cuda-device',
name: 'CUDA device index to use',
category: ['CUDA'],
type: 'number',
defaultValue: undefined
},
{
id: 'cuda-malloc',
name: 'Use CUDA malloc for memory allocation',
category: ['CUDA'],
type: 'combo',
options: Object.values(CudaMalloc),
defaultValue: CudaMalloc.Auto,
getValue: (value: CudaMalloc) => {
switch (value) {
case CudaMalloc.Auto:
return {}
case CudaMalloc.Enable:
return {
['cuda-malloc']: true
}
case CudaMalloc.Disable:
return {
['disable-cuda-malloc']: true
}
}
}
},
// Precision settings
{
id: 'global-precision',
name: 'Global floating point precision',
category: ['Inference'],
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP32,
FloatingPointPrecision.FP16
],
defaultValue: FloatingPointPrecision.AUTO,
tooltip: 'Global floating point precision',
getValue: (value: FloatingPointPrecision) => {
switch (value) {
case FloatingPointPrecision.AUTO:
return {}
case FloatingPointPrecision.FP32:
return {
['force-fp32']: true
}
case FloatingPointPrecision.FP16:
return {
['force-fp16']: true
}
default:
return {}
}
}
},
// UNET precision
{
id: 'unet-precision',
name: 'UNET precision',
category: ['Inference'],
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP16,
FloatingPointPrecision.BF16,
FloatingPointPrecision.FP8E4M3FN,
FloatingPointPrecision.FP8E5M2
],
defaultValue: FloatingPointPrecision.AUTO,
tooltip: 'UNET precision',
getValue: (value: FloatingPointPrecision) => {
switch (value) {
case FloatingPointPrecision.AUTO:
return {}
default:
return {
[`${value.toLowerCase()}-unet`]: true
}
}
}
},
// VAE settings
{
id: 'vae-precision',
name: 'VAE precision',
category: ['Inference'],
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP16,
FloatingPointPrecision.FP32,
FloatingPointPrecision.BF16
],
defaultValue: FloatingPointPrecision.AUTO,
tooltip: 'VAE precision',
getValue: (value: FloatingPointPrecision) => {
switch (value) {
case FloatingPointPrecision.AUTO:
return {}
default:
return {
[`${value.toLowerCase()}-vae`]: true
}
}
}
},
{
id: 'cpu-vae',
name: 'Run VAE on CPU',
category: ['Inference'],
type: 'boolean',
defaultValue: false
},
// Text Encoder settings
{
id: 'text-encoder-precision',
name: 'Text Encoder precision',
category: ['Inference'],
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP8E4M3FN,
FloatingPointPrecision.FP8E5M2,
FloatingPointPrecision.FP16,
FloatingPointPrecision.FP32
],
defaultValue: FloatingPointPrecision.AUTO,
tooltip: 'Text Encoder precision',
getValue: (value: FloatingPointPrecision) => {
switch (value) {
case FloatingPointPrecision.AUTO:
return {}
default:
return {
[`${value.toLowerCase()}-text-enc`]: true
}
}
}
},
// Memory and performance settings
{
id: 'force-channels-last',
name: 'Force channels-last memory format',
category: ['Memory'],
type: 'boolean',
defaultValue: false
},
{
id: 'directml',
name: 'DirectML device index',
category: ['Memory'],
type: 'number',
defaultValue: undefined
},
{
id: 'disable-ipex-optimize',
name: 'Disable IPEX optimization',
category: ['Memory'],
type: 'boolean',
defaultValue: false
},
// Preview settings
{
id: 'preview-method',
name: 'Method used for latent previews',
category: ['Preview'],
type: 'combo',
options: Object.values(LatentPreviewMethod),
defaultValue: LatentPreviewMethod.NoPreviews
},
{
id: 'preview-size',
name: 'Size of preview images',
category: ['Preview'],
type: 'slider',
defaultValue: 512,
attrs: {
min: 128,
max: 2048,
step: 128
}
},
// Cache settings
{
id: 'cache-classic',
name: 'Use classic cache system',
category: ['Cache'],
type: 'boolean',
defaultValue: false
},
{
id: 'cache-lru',
name: 'Use LRU caching with a maximum of N node results cached. (0 to disable).',
category: ['Cache'],
type: 'number',
defaultValue: 0,
tooltip: 'May use more RAM/VRAM.'
},
// Attention settings
{
id: 'cross-attention-method',
name: 'Cross attention method',
category: ['Attention'],
type: 'combo',
options: Object.values(CrossAttentionMethod),
defaultValue: CrossAttentionMethod.Auto,
getValue: (value: CrossAttentionMethod) => {
switch (value) {
case CrossAttentionMethod.Auto:
return {}
default:
return {
[`use-${value.toLowerCase()}-cross-attention`]: true
}
}
}
},
{
id: 'disable-xformers',
name: 'Disable xFormers optimization',
type: 'boolean',
defaultValue: false
},
{
id: 'force-upcast-attention',
name: 'Force attention upcast',
category: ['Attention'],
type: 'boolean',
defaultValue: false
},
{
id: 'dont-upcast-attention',
name: 'Prevent attention upcast',
category: ['Attention'],
type: 'boolean',
defaultValue: false
},
// VRAM management
{
id: 'vram-management',
name: 'VRAM management mode',
category: ['Memory'],
type: 'combo',
options: Object.values(VramManagement),
defaultValue: VramManagement.Auto,
getValue: (value: VramManagement) => {
switch (value) {
case VramManagement.Auto:
return {}
default:
return {
[value]: true
}
}
}
},
{
id: 'reserve-vram',
name: 'Reserved VRAM (GB)',
category: ['Memory'],
type: 'number',
defaultValue: undefined,
tooltip:
'Set the amount of vram in GB you want to reserve for use by your OS/other software. By default some amount is reverved depending on your OS.'
},
// Misc settings
{
id: 'default-hashing-function',
name: 'Default hashing function for model files',
type: 'combo',
options: Object.values(HashFunction),
defaultValue: HashFunction.SHA256
},
{
id: 'disable-smart-memory',
name: 'Force ComfyUI to agressively offload to regular ram instead of keeping models in vram when it can.',
category: ['Memory'],
type: 'boolean',
defaultValue: false
},
{
id: 'deterministic',
name: 'Make pytorch use slower deterministic algorithms when it can.',
type: 'boolean',
defaultValue: false,
tooltip: 'Note that this might not make images deterministic in all cases.'
},
{
id: 'fast',
name: 'Enable some untested and potentially quality deteriorating optimizations.',
type: 'boolean',
defaultValue: false
},
{
id: 'dont-print-server',
name: "Don't print server output to console.",
type: 'boolean',
defaultValue: false
},
{
id: 'disable-metadata',
name: 'Disable saving prompt metadata in files.',
type: 'boolean',
defaultValue: false
},
{
id: 'disable-all-custom-nodes',
name: 'Disable loading all custom nodes.',
type: 'boolean',
defaultValue: false
},
{
id: 'log-level',
name: 'Logging verbosity level',
type: 'combo',
options: Object.values(LogLevel),
defaultValue: LogLevel.INFO,
getValue: (value: LogLevel) => {
return {
verbose: value
}
}
}
]

View File

@@ -733,7 +733,7 @@ app.registerExtension({
app.ui.settings.addSetting({
id,
category: ['Comfy', 'ColorPalette'],
category: ['Appearance', 'ColorPalette'],
name: 'Color Palette',
type: (name, setter, value) => {
const options = [

View File

@@ -0,0 +1,147 @@
import { app } from '@/scripts/app'
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
;(async () => {
if (!isElectron()) return
const electronAPI = getElectronAPI()
const desktopAppVersion = await electronAPI.getElectronVersion()
const onChangeRestartApp = (newValue: string, oldValue: string) => {
// Add a delay to allow changes to take effect before restarting.
if (oldValue !== undefined && newValue !== oldValue) {
electronAPI.restartApp('Restart ComfyUI to apply changes.', 1500)
}
}
app.registerExtension({
name: 'Comfy.ElectronAdapter',
settings: [
{
id: 'Comfy-Desktop.AutoUpdate',
category: ['Comfy-Desktop', 'General', 'AutoUpdate'],
name: 'Automatically check for updates',
type: 'boolean',
defaultValue: true,
onChange: onChangeRestartApp
},
{
id: 'Comfy-Desktop.SendStatistics',
category: ['Comfy-Desktop', 'General', 'Send Statistics'],
name: 'Send anonymous crash reports',
type: 'boolean',
defaultValue: true,
onChange: onChangeRestartApp
}
],
commands: [
{
id: 'Comfy-Desktop.Folders.OpenLogsFolder',
label: 'Open Logs Folder',
icon: 'pi pi-folder-open',
function() {
electronAPI.openLogsFolder()
}
},
{
id: 'Comfy-Desktop.Folders.OpenModelsFolder',
label: 'Open Models Folder',
icon: 'pi pi-folder-open',
function() {
electronAPI.openModelsFolder()
}
},
{
id: 'Comfy-Desktop.Folders.OpenOutputsFolder',
label: 'Open Outputs Folder',
icon: 'pi pi-folder-open',
function() {
electronAPI.openOutputsFolder()
}
},
{
id: 'Comfy-Desktop.Folders.OpenInputsFolder',
label: 'Open Inputs Folder',
icon: 'pi pi-folder-open',
function() {
electronAPI.openInputsFolder()
}
},
{
id: 'Comfy-Desktop.Folders.OpenCustomNodesFolder',
label: 'Open Custom Nodes Folder',
icon: 'pi pi-folder-open',
function() {
electronAPI.openCustomNodesFolder()
}
},
{
id: 'Comfy-Desktop.Folders.OpenModelConfig',
label: 'Open extra_model_paths.yaml',
icon: 'pi pi-file',
function() {
electronAPI.openModelConfig()
}
},
{
id: 'Comfy-Desktop.OpenDevTools',
label: 'Open DevTools',
icon: 'pi pi-code',
function() {
electronAPI.openDevTools()
}
},
{
id: 'Comfy-Desktop.OpenFeedbackPage',
label: 'Feedback',
icon: 'pi pi-envelope',
function() {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
},
{
id: 'Comfy-Desktop.Reinstall',
label: 'Reinstall',
icon: 'pi pi-refresh',
function() {
// TODO(huchenlei): Add a confirmation dialog.
electronAPI.reinstall()
}
}
],
menuCommands: [
{
path: ['Help'],
commands: ['Comfy-Desktop.OpenFeedbackPage']
},
{
path: ['Help'],
commands: ['Comfy-Desktop.OpenDevTools']
},
{
path: ['Help', 'Open Folder'],
commands: [
'Comfy-Desktop.Folders.OpenLogsFolder',
'Comfy-Desktop.Folders.OpenModelsFolder',
'Comfy-Desktop.Folders.OpenOutputsFolder',
'Comfy-Desktop.Folders.OpenInputsFolder',
'Comfy-Desktop.Folders.OpenCustomNodesFolder',
'Comfy-Desktop.Folders.OpenModelConfig'
]
},
{
path: ['Help'],
commands: ['Comfy-Desktop.Reinstall']
}
],
aboutPageBadges: [
{
label: 'ComfyUI_Desktop ' + desktopAppVersion,
url: 'https://github.com/Comfy-Org/electron',
icon: 'pi pi-github'
}
]
})
})()

View File

@@ -13,6 +13,7 @@ import {
deserialiseAndCreate,
serialise
} from '@/extensions/core/vintageClipboard'
import type { ComfyNodeDef } from '@/types/apiTypes'
type GroupNodeWorkflowData = {
external: ComfyLink[]
@@ -56,7 +57,7 @@ const Workflow = {
class GroupNodeBuilder {
nodes: LGraphNode[]
nodeData: any
nodeData: GroupNodeWorkflowData
constructor(nodes: LGraphNode[]) {
this.nodes = nodes
@@ -175,7 +176,7 @@ export class GroupNodeConfig {
primitiveToWidget: {}
nodeInputs: {}
outputVisibility: any[]
nodeDef: any
nodeDef: ComfyNodeDef
inputs: any[]
linksFrom: {}
linksTo: {}
@@ -204,6 +205,7 @@ export class GroupNodeConfig {
output: [],
output_name: [],
output_is_list: [],
// @ts-expect-error Unused, doesn't exist
output_is_hidden: [],
name: source + SEPARATOR + this.name,
display_name: this.name,
@@ -695,11 +697,11 @@ export class GroupNodeConfig {
}
export class GroupNodeHandler {
node
node: LGraphNode
groupData
innerNodes: any
constructor(node) {
constructor(node: LGraphNode) {
this.node = node
this.groupData = node.constructor?.nodeData?.[GROUP]
@@ -774,6 +776,7 @@ export class GroupNodeHandler {
this.node.updateLink = (link) => {
// Replace the group node reference with the internal node
// @ts-expect-error Can this be removed? Or replaced with: LLink.create(link.asSerialisable())
link = { ...link }
const output = this.groupData.newToOldOutputMap[link.origin_slot]
let innerNode = this.innerNodes[output.node.index]
@@ -965,17 +968,20 @@ export class GroupNodeHandler {
app.canvas.emitBeforeChange()
const { newNodes, selectedIds } = addInnerNodes()
reconnectInputs(selectedIds)
reconnectOutputs(selectedIds)
app.graph.remove(this.node)
try {
const { newNodes, selectedIds } = addInnerNodes()
reconnectInputs(selectedIds)
reconnectOutputs(selectedIds)
app.graph.remove(this.node)
app.canvas.emitAfterChange()
return newNodes
return newNodes
} finally {
app.canvas.emitAfterChange()
}
}
const getExtraMenuOptions = this.node.getExtraMenuOptions
// @ts-expect-error Should pass patched return value getExtraMenuOptions
this.node.getExtraMenuOptions = function (_, options) {
getExtraMenuOptions?.apply(this, arguments)
@@ -988,6 +994,7 @@ export class GroupNodeHandler {
null,
{
content: 'Convert to nodes',
// @ts-expect-error
callback: () => {
return this.convertToNodes()
}
@@ -1148,6 +1155,7 @@ export class GroupNodeHandler {
if (
old.inputName !== 'image' &&
// @ts-expect-error Widget values
!widget.options.values.includes(widget.value)
) {
widget.value = widget.options.values[0]
@@ -1354,6 +1362,7 @@ export class GroupNodeHandler {
if (!originNode) continue // this node is in the group
originNode.connect(
originSlot,
// @ts-expect-error Valid - uses deprecated interface. Required check: if (graph.getNodeById(this.node.id) !== this.node) report()
this.node.id,
this.groupData.oldToNewInputMap[targetId][targetSlot]
)
@@ -1475,7 +1484,7 @@ function ungroupSelectedGroupNodes() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
for (const node of nodes) {
if (GroupNodeHandler.isGroupNode(node)) {
node['convertToNodes']?.()
node.convertToNodes?.()
}
}
}

View File

@@ -4,6 +4,7 @@ import { app } from '../../scripts/app'
import { LGraphCanvas } from '@comfyorg/litegraph'
import type { Positionable } from '@comfyorg/litegraph/dist/interfaces'
import type { LGraphNode } from '@comfyorg/litegraph'
import { useSettingStore } from '@/stores/settingStore'
function setNodeMode(node: LGraphNode, mode: number) {
node.mode = mode
@@ -11,7 +12,8 @@ function setNodeMode(node: LGraphNode, mode: number) {
}
function addNodesToGroup(group: LGraphGroup, items: Iterable<Positionable>) {
group.resizeTo([...group.children, ...items])
const padding = useSettingStore().get('Comfy.GroupSelectedNodes.Padding')
group.resizeTo([...group.children, ...items], padding)
}
app.registerExtension({
@@ -76,7 +78,10 @@ app.registerExtension({
content: 'Fit Group To Nodes',
callback: () => {
group.recomputeInsideNodes()
group.resizeTo(group.children)
const padding = useSettingStore().get(
'Comfy.GroupSelectedNodes.Padding'
)
group.resizeTo(group.children, padding)
this.graph.change()
}
})

View File

@@ -15,8 +15,9 @@ import './rerouteNode'
import './saveImageExtraOutput'
import './simpleTouchSupport'
import './slotDefaults'
import './snapToGrid'
import './uploadImage'
import './webcamCapture'
import './widgetInputs'
import './uploadAudio'
import './electronAdapter'
import './load3d'

View File

@@ -24,7 +24,7 @@ app.registerExtension({
}
app.ui.settings.addSetting({
id,
category: ['Comfy', 'Graph', 'InvertMenuScrolling'],
category: ['LiteGraph', 'Menu', 'InvertMenuScrolling'],
name: 'Invert Context Menu Scrolling',
type: 'boolean',
defaultValue: false,

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -44,7 +44,7 @@ app.registerExtension({
lastTouch = getMultiTouchCenter(e)
touchDist = getMultiTouchPos(e)
app.canvas.pointer_is_down = false
app.canvas.pointer.isDown = false
}
}
},
@@ -78,7 +78,7 @@ app.registerExtension({
touchTime = null
if (e.touches?.length === 2 && lastTouch && !e.ctrlKey && !e.shiftKey) {
e.preventDefault() // Prevent browser from zooming when two textareas are touched
app.canvas.pointer_is_down = false
app.canvas.pointer.isDown = false
touchZooming = true
LiteGraph.closeAllContextMenus(window)
@@ -137,7 +137,7 @@ LGraphCanvas.prototype.processMouseDown = function (e) {
if (touchZooming || touchCount) {
return
}
app.canvas.pointer_is_down = false // Prevent context menu from opening on second tap
app.canvas.pointer.isDown = false // Prevent context menu from opening on second tap
return processMouseDown.apply(this, arguments)
}

View File

@@ -1,202 +0,0 @@
// @ts-strict-ignore
import type { SettingParams } from '@/types/settingTypes'
import { app } from '../../scripts/app'
import {
LGraphCanvas,
LGraphNode,
LGraphGroup,
LiteGraph
} from '@comfyorg/litegraph'
// Shift + drag/resize to snap to grid
/** Rounds a Vector2 in-place to the current CANVAS_GRID_SIZE. */
function roundVectorToGrid(vec) {
vec[0] =
LiteGraph.CANVAS_GRID_SIZE * Math.round(vec[0] / LiteGraph.CANVAS_GRID_SIZE)
vec[1] =
LiteGraph.CANVAS_GRID_SIZE * Math.round(vec[1] / LiteGraph.CANVAS_GRID_SIZE)
return vec
}
app.registerExtension({
name: 'Comfy.SnapToGrid',
init() {
// Add setting to control grid size
app.ui.settings.addSetting({
id: 'Comfy.SnapToGrid.GridSize',
category: ['Comfy', 'Graph', 'GridSize'],
name: 'Snap to grid size',
type: 'slider',
attrs: {
min: 1,
max: 500
},
tooltip:
'When dragging and resizing nodes while holding shift they will be aligned to the grid, this controls the size of that grid.',
defaultValue: LiteGraph.CANVAS_GRID_SIZE,
onChange(value) {
LiteGraph.CANVAS_GRID_SIZE = +value || 10
}
})
// Keep the 'pysssss.SnapToGrid' setting id so we don't need to migrate setting values.
// Using a new setting id can cause existing users to lose their existing settings.
const alwaysSnapToGrid = app.ui.settings.addSetting({
id: 'pysssss.SnapToGrid',
category: ['Comfy', 'Graph', 'AlwaysSnapToGrid'],
name: 'Always snap to grid',
type: 'boolean',
defaultValue: false,
versionAdded: '1.3.13'
} as SettingParams)
const shouldSnapToGrid = () => app.shiftDown || alwaysSnapToGrid.value
// After moving a node, if the shift key is down align it to grid
const onNodeMoved = app.canvas.onNodeMoved
app.canvas.onNodeMoved = function (node) {
const r = onNodeMoved?.apply(this, arguments)
if (shouldSnapToGrid()) {
// Ensure all selected nodes are realigned
for (const id in this.selected_nodes) {
this.selected_nodes[id].alignToGrid()
}
}
return r
}
// When a node is added, add a resize handler to it so we can fix align the size with the grid
const onNodeAdded = app.graph.onNodeAdded
app.graph.onNodeAdded = function (node) {
const onResize = node.onResize
node.onResize = function () {
if (shouldSnapToGrid()) {
roundVectorToGrid(node.size)
}
return onResize?.apply(this, arguments)
}
return onNodeAdded?.apply(this, arguments)
}
// Draw a preview of where the node will go if holding shift and the node is selected
const origDrawNode = LGraphCanvas.prototype.drawNode
LGraphCanvas.prototype.drawNode = function (node, ctx) {
if (
shouldSnapToGrid() &&
this.node_dragged &&
node.id in this.selected_nodes
) {
const [x, y] = roundVectorToGrid([...node.pos])
const shiftX = x - node.pos[0]
let shiftY = y - node.pos[1]
let w, h
if (node.flags.collapsed) {
w = node._collapsed_width
h = LiteGraph.NODE_TITLE_HEIGHT
shiftY -= LiteGraph.NODE_TITLE_HEIGHT
} else {
w = node.size[0]
h = node.size[1]
const titleMode = node.constructor.title_mode
if (
titleMode !== LiteGraph.TRANSPARENT_TITLE &&
titleMode !== LiteGraph.NO_TITLE
) {
h += LiteGraph.NODE_TITLE_HEIGHT
shiftY -= LiteGraph.NODE_TITLE_HEIGHT
}
}
const f = ctx.fillStyle
ctx.fillStyle = 'rgba(100, 100, 100, 0.5)'
ctx.fillRect(shiftX, shiftY, w, h)
ctx.fillStyle = f
}
return origDrawNode.apply(this, arguments)
}
/**
* The currently moving, selected group only. Set after the `selected_group` has actually started
* moving.
*/
let selectedAndMovingGroup: LGraphGroup | null = null
/**
* Handles moving a group; tracking when a group has been moved (to show the ghost in `drawGroups`
* below) as well as handle the last move call from LiteGraph's `processMouseUp`.
*/
const groupMove = LGraphGroup.prototype.move
LGraphGroup.prototype.move = function (deltax, deltay, ignore_nodes) {
const v = groupMove.apply(this, arguments)
// When we've started moving, set `selectedAndMovingGroup` as LiteGraph sets `selected_group`
// too eagerly and we don't want to behave like we're moving until we get a delta.
if (
!selectedAndMovingGroup &&
app.canvas.selected_group === this &&
(deltax || deltay)
) {
selectedAndMovingGroup = this
}
// LiteGraph will call group.move both on mouse-move as well as mouse-up though we only want
// to snap on a mouse-up which we can determine by checking if `app.canvas.last_mouse_dragging`
// has been set to `false`. Essentially, this check here is the equivalent to calling an
// `LGraphGroup.prototype.onNodeMoved` if it had existed.
if (app.canvas.last_mouse_dragging === false && shouldSnapToGrid()) {
// After moving a group (while shouldSnapToGrid()), snap all the child nodes and, finally,
// align the group itself.
this.recomputeInsideNodes()
for (const node of this.nodes) {
node.alignToGrid()
}
LGraphNode.prototype.alignToGrid.apply(this)
}
return v
}
/**
* Handles drawing a group when, snapping the size when one is actively being resized tracking and/or
* drawing a ghost box when one is actively being moved. This mimics the node snapping behavior for
* both.
*/
const drawGroups = LGraphCanvas.prototype.drawGroups
LGraphCanvas.prototype.drawGroups = function (canvas, ctx) {
if (this.selected_group && shouldSnapToGrid()) {
if (this.selected_group_resizing) {
roundVectorToGrid(this.selected_group.size)
} else if (selectedAndMovingGroup) {
const [x, y] = roundVectorToGrid([...selectedAndMovingGroup.pos])
const f = ctx.fillStyle
const s = ctx.strokeStyle
ctx.fillStyle = 'rgba(100, 100, 100, 0.33)'
ctx.strokeStyle = 'rgba(100, 100, 100, 0.66)'
ctx.rect(x, y, ...(selectedAndMovingGroup.size as [number, number]))
ctx.fill()
ctx.stroke()
ctx.fillStyle = f
ctx.strokeStyle = s
}
} else if (!this.selected_group) {
selectedAndMovingGroup = null
}
return drawGroups.apply(this, arguments)
}
/** Handles adding a group in a snapping-enabled state. */
const onGroupAdd = LGraphCanvas.onGroupAdd
LGraphCanvas.onGroupAdd = function () {
const v = onGroupAdd.apply(app.canvas, arguments)
if (shouldSnapToGrid()) {
const lastGroup = app.graph.groups[app.graph.groups.length - 1]
if (lastGroup) {
roundVectorToGrid(lastGroup.pos)
roundVectorToGrid(lastGroup.size)
}
}
return v
}
}
})

View File

@@ -69,51 +69,54 @@ export function deserialiseAndCreate(data: string, canvas: LGraphCanvas): void {
const { graph, graph_mouse } = canvas
canvas.emitBeforeChange()
graph.beforeChange()
try {
graph.beforeChange()
const deserialised = JSON.parse(data)
const deserialised = JSON.parse(data)
// Find the top left point of the boundary of all pasted nodes
const topLeft = [Infinity, Infinity]
for (const { pos } of deserialised.nodes) {
if (topLeft[0] > pos[0]) topLeft[0] = pos[0]
if (topLeft[1] > pos[1]) topLeft[1] = pos[1]
// Find the top left point of the boundary of all pasted nodes
const topLeft = [Infinity, Infinity]
for (const { pos } of deserialised.nodes) {
if (topLeft[0] > pos[0]) topLeft[0] = pos[0]
if (topLeft[1] > pos[1]) topLeft[1] = pos[1]
}
// Silent default instead of throw
if (!Number.isFinite(topLeft[0]) || !Number.isFinite(topLeft[1])) {
topLeft[0] = graph_mouse[0]
topLeft[1] = graph_mouse[1]
}
// Create nodes
const nodes: LGraphNode[] = []
for (const info of deserialised.nodes) {
const node = LiteGraph.createNode(info.type)
if (!node) continue
node.configure(info)
// Paste to the bottom right of pointer
node.pos[0] += graph_mouse[0] - topLeft[0]
node.pos[1] += graph_mouse[1] - topLeft[1]
graph.add(node, true)
nodes.push(node)
}
// Create links
for (const info of deserialised.links) {
const relativeId = info[0]
const outNode = relativeId != null ? nodes[relativeId] : undefined
const inNode = nodes[info[2]]
if (outNode && inNode) outNode.connect(info[1], inNode, info[3])
else console.warn('Warning, nodes missing on pasting')
}
canvas.selectNodes(nodes)
graph.afterChange()
} finally {
canvas.emitAfterChange()
}
// Silent default instead of throw
if (!Number.isFinite(topLeft[0]) || !Number.isFinite(topLeft[1])) {
topLeft[0] = graph_mouse[0]
topLeft[1] = graph_mouse[1]
}
// Create nodes
const nodes: LGraphNode[] = []
for (const info of deserialised.nodes) {
const node = LiteGraph.createNode(info.type)
if (!node) continue
node.configure(info)
// Paste to the bottom right of pointer
node.pos[0] += graph_mouse[0] - topLeft[0]
node.pos[1] += graph_mouse[1] - topLeft[1]
graph.add(node, true)
nodes.push(node)
}
// Create links
for (const info of deserialised.links) {
const relativeId = info[0]
const outNode = relativeId != null ? nodes[relativeId] : undefined
const inNode = nodes[info[2]]
if (outNode && inNode) outNode.connect(info[1], inNode, info[3])
else console.warn('Warning, nodes missing on pasting')
}
canvas.selectNodes(nodes)
graph.afterChange()
canvas.emitAfterChange()
}

View File

@@ -894,7 +894,7 @@ app.registerExtension({
// Not a widget input or already handled input
if (
!(input.type in ComfyWidgets) &&
!(input.widget[GET_CONFIG]?.()?.[0] instanceof Array)
!(input.widget?.[GET_CONFIG]?.()?.[0] instanceof Array)
) {
return r //also Not a ComfyWidgets input or combo (do nothing)
}

View File

@@ -1,14 +0,0 @@
import { useI18n } from 'vue-i18n'
import { markRaw } from 'vue'
import IntegratedTerminal from '@/components/bottomPanel/tabs/IntegratedTerminal.vue'
import { BottomPanelExtension } from '@/types/extensionTypes'
export const useIntegratedTerminalTab = (): BottomPanelExtension => {
const { t } = useI18n()
return {
id: 'integrated-terminal',
title: t('terminal'),
component: markRaw(IntegratedTerminal),
type: 'vue'
}
}

View File

@@ -0,0 +1,25 @@
import { useI18n } from 'vue-i18n'
import { markRaw } from 'vue'
import { BottomPanelExtension } from '@/types/extensionTypes'
import LogsTerminal from '@/components/bottomPanel/tabs/terminal/LogsTerminal.vue'
import CommandTerminal from '@/components/bottomPanel/tabs/terminal/CommandTerminal.vue'
export const useLogsTerminalTab = (): BottomPanelExtension => {
const { t } = useI18n()
return {
id: 'logs-terminal',
title: t('logs'),
component: markRaw(LogsTerminal),
type: 'vue'
}
}
export const useCommandTerminalTab = (): BottomPanelExtension => {
const { t } = useI18n()
return {
id: 'command-terminal',
title: t('terminal'),
component: markRaw(CommandTerminal),
type: 'vue'
}
}

View File

@@ -0,0 +1,71 @@
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import { debounce } from 'lodash'
import { markRaw, onMounted, onUnmounted, Ref } from 'vue'
import '@xterm/xterm/css/xterm.css'
export function useTerminal(element: Ref<HTMLElement>) {
const fitAddon = new FitAddon()
const terminal = markRaw(
new Terminal({
convertEol: true
})
)
terminal.loadAddon(fitAddon)
onMounted(async () => {
terminal.open(element.value)
})
onUnmounted(() => {
terminal.dispose()
})
return {
terminal,
useAutoSize(
root: Ref<HTMLElement>,
autoRows: boolean = true,
autoCols: boolean = true,
onResize?: () => void
) {
const ensureValidRows = (rows: number | undefined) => {
if (rows == null || isNaN(rows)) {
return root.value?.clientHeight / 20
}
return rows
}
const ensureValidCols = (cols: number | undefined): number => {
if (cols == null || isNaN(cols)) {
// Sometimes this is NaN if so, estimate.
return root.value?.clientWidth / 8
}
return cols
}
const resize = () => {
const dims = fitAddon.proposeDimensions()
// Sometimes propose returns NaN, so we may need to estimate.
terminal.resize(
autoCols ? ensureValidCols(dims?.cols) : terminal.cols,
autoRows ? ensureValidRows(dims?.rows) : terminal.rows
)
onResize?.()
}
const resizeObserver = new ResizeObserver(debounce(resize, 25))
onMounted(async () => {
resizeObserver.observe(root.value)
resize()
})
onUnmounted(() => {
resizeObserver.disconnect()
})
return { resize }
}
}
}

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