Compare commits

...

57 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
83 changed files with 3324 additions and 1361 deletions

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

@@ -1,7 +1,5 @@
if [[ "$OS" == "Windows_NT" ]]; then
npx.cmd lint-staged
npm.cmd run typecheck
else
npx lint-staged
npm run typecheck
fi

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)

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

@@ -526,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()
@@ -557,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()
@@ -735,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

@@ -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

@@ -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 ({

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.

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

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(' ')}`]
}

40
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "comfyui-frontend",
"version": "1.4.2",
"version": "1.4.9",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.4.2",
"version": "1.4.9",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.2.16",
"@comfyorg/litegraph": "^0.8.27",
"@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",
@@ -1917,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.27",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.27.tgz",
"integrity": "sha512-EMQ3jsny+3gUQL4+vSVwJAFxrLq4IpuyjCvAiErLY4wLZZu2Mi+7cELmhrNS0MajhZqfN1M0GPmdcBRwSWbarw==",
"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": {
@@ -2460,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": {
@@ -5851,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",
@@ -10126,10 +10127,11 @@
"license": "MIT"
},
"node_modules/micromatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.7.tgz",
"integrity": "sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==",
"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"

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.4.2",
"version": "1.4.9",
"type": "module",
"scripts": {
"dev": "vite",
@@ -72,8 +72,8 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.2.16",
"@comfyorg/litegraph": "^0.8.27",
"@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",
@@ -93,10 +93,5 @@
"vue-router": "^4.4.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.3.0"
},
"lint-staged": {
"./**/*.{js,ts,tsx,vue}": [
"prettier --write"
]
}
}

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

@@ -7,7 +7,7 @@
</template>
<script setup lang="ts">
import { ref, defineEmits, Ref } from 'vue'
import { ref, Ref } from 'vue'
import { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
const emit = defineEmits<{

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

@@ -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'
@@ -162,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

View File

@@ -85,6 +85,8 @@ onMounted(async () => {
appData.value = paths.appData
appPath.value = paths.appPath
installPath.value = paths.defaultInstallPath
await validatePath(paths.defaultInstallPath)
})
const validatePath = async (path: string) => {

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

@@ -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

@@ -198,6 +198,25 @@ 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'],
@@ -535,5 +554,93 @@ export const CORE_SETTINGS: SettingParams[] = [
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

@@ -5,6 +5,14 @@ import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
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: [
@@ -14,14 +22,15 @@ import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
name: 'Automatically check for updates',
type: 'boolean',
defaultValue: true,
onChange(newValue, oldValue) {
if (oldValue !== undefined && newValue !== oldValue) {
electronAPI.restartApp(
'Restart ComfyUI to apply changes.',
1500 // add delay to allow changes to take effect before restarting.
)
}
}
onChange: onChangeRestartApp
},
{
id: 'Comfy-Desktop.SendStatistics',
category: ['Comfy-Desktop', 'General', 'Send Statistics'],
name: 'Send anonymous crash reports',
type: 'boolean',
defaultValue: true,
onChange: onChangeRestartApp
}
],

View File

@@ -15,7 +15,6 @@ import './rerouteNode'
import './saveImageExtraOutput'
import './simpleTouchSupport'
import './slotDefaults'
import './snapToGrid'
import './uploadImage'
import './webcamCapture'
import './widgetInputs'

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: ['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,
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: ['LiteGraph', 'Canvas', '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

@@ -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 +1,16 @@
import { FitAddon } from '@xterm/addon-fit'
import { Terminal } from '@xterm/xterm'
import { debounce } from 'lodash'
import { onMounted, onUnmounted, Ref } from 'vue'
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 = new Terminal({
convertEol: true
})
const terminal = markRaw(
new Terminal({
convertEol: true
})
)
terminal.loadAddon(fitAddon)
onMounted(async () => {

View File

@@ -15,7 +15,7 @@ const messages = {
failedToSelectDirectory: 'Failed to select directory',
pathValidationFailed: 'Failed to validate path',
installLocationDescription:
"Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~5GB) left.",
"Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~15GB) left.",
installLocationTooltip:
"ComfyUI's user data directory. Stores:\n- Python Environment\n- Models\n- Custom nodes\n",
appDataLocationTooltip:
@@ -33,24 +33,38 @@ const messages = {
'Configure how ComfyUI behaves on your desktop. You can change these settings later.',
settings: {
autoUpdate: 'Automatic Updates',
allowMetrics: 'Usage Analytics',
allowMetrics: 'Crash Reports',
autoUpdateDescription:
"Automatically download and install updates when they become available. You'll always be notified before updates are installed.",
allowMetricsDescription:
'Help improve ComfyUI by sending anonymous usage data. No personal information or workflow content will be collected.',
'Help improve ComfyUI by sending anonymous crash reports. No personal information or workflow content will be collected. This can be disabled at any time in the settings menu.',
learnMoreAboutData: 'Learn more about data collection',
dataCollectionDialog: {
title: 'About Data Collection',
whatWeCollect: 'What we collect:',
whatWeDoNotCollect: "What we don't collect:",
errorReports: 'Error reports',
systemInfo: 'Operating system and app version',
errorReports: 'Error message and stack trace',
systemInfo: 'Hardware, OS type, and app version',
personalInformation: 'Personal information',
workflowContent: 'Workflow content',
fileSystemInformation: 'File system information'
fileSystemInformation: 'File system information',
workflowContents: 'Workflow contents',
customNodeConfigurations: 'Custom node configurations'
}
}
},
serverStart: {
reinstall: 'Reinstall',
reportIssue: 'Report Issue',
openLogs: 'Open Logs',
process: {
'initial-state': 'Loading...',
'python-setup': 'Setting up Python Environment...',
'starting-server': 'Starting ComfyUI server...',
ready: 'Finishing...',
error: 'Unable to start ComfyUI'
}
},
firstTimeUIMessage:
'This is the first time you use the new UI. Choose "Menu > Use New Menu > Disabled" to restore the old UI.',
download: 'Download',
@@ -83,6 +97,9 @@ const messages = {
error: 'Error',
loading: 'Loading',
findIssues: 'Find Issues',
reportIssue: 'Send Report',
reportIssueTooltip: 'Submit the error report to Comfy Org',
reportSent: 'Report Submitted',
copyToClipboard: 'Copy to Clipboard',
openNewIssue: 'Open New Issue',
showReport: 'Show Report',
@@ -126,7 +143,12 @@ const messages = {
backToAllTasks: 'Back to All Tasks',
containImagePreview: 'Fill Image Preview',
coverImagePreview: 'Fit Image Preview',
clearPendingTasks: 'Clear Pending Tasks'
clearPendingTasks: 'Clear Pending Tasks',
filter: 'Filter Outputs',
filters: {
hideCached: 'Hide Cached',
hideCanceled: 'Hide Canceled'
}
}
},
menu: {
@@ -143,8 +165,8 @@ const messages = {
'The workflow will be queued instantly after a generation finishes',
change: 'On Change',
changeTooltip: 'The workflow will be queued once a change is made',
queueWorkflow: 'Queue workflow',
queueWorkflowFront: 'Queue workflow (Insert at Front)',
queueWorkflow: 'Queue workflow (Shift to queue at front)',
queueWorkflowFront: 'Queue workflow at front',
queue: 'Queue',
interrupt: 'Cancel current run',
refresh: 'Refresh node definitions',
@@ -270,8 +292,8 @@ const messages = {
instantTooltip: '工作流将会在生成完成后立即执行',
change: '变动',
changeTooltip: '工作流将会在改变后执行',
queueWorkflow: '执行工作流',
queueWorkflowFront: '执行工作流 (队列首)',
queueWorkflow: '执行 (Shift 执行到队列首)',
queueWorkflowFront: '执行队列首',
queue: '队列',
interrupt: '取消当前任务',
refresh: '刷新节点',
@@ -391,7 +413,7 @@ const messages = {
change: 'При изменении',
changeTooltip:
'Рабочий процесс будет поставлен в очередь после внесения изменений',
queueWorkflow: 'Очередь рабочего процесса',
queueWorkflow: 'Очередь рабочего процесса (Shift для вставки спереди)',
queueWorkflowFront: 'Очередь рабочего процесса (Вставка спереди)',
queue: 'Очередь',
interrupt: 'Отменить текущее выполнение',

View File

@@ -8,7 +8,9 @@ import {
import LayoutDefault from '@/views/layouts/LayoutDefault.vue'
import { isElectron } from './utils/envUtil'
const isFileProtocol = () => window.location.protocol === 'file:'
const isFileProtocol = window.location.protocol === 'file:'
const basePath = isElectron() ? '/' : window.location.pathname
const guardElectronAccess = (
to: RouteLocationNormalized,
from: RouteLocationNormalized,
@@ -22,12 +24,12 @@ const guardElectronAccess = (
}
const router = createRouter({
history: isFileProtocol()
history: isFileProtocol
? createWebHashHistory()
: // Base path must be specified to ensure correct relative paths
// Example: For URL 'http://localhost:7801/ComfyBackendDirect',
// we need this base path or assets will incorrectly resolve from 'http://localhost:7801/'
createWebHistory(window.location.pathname),
createWebHistory(basePath),
routes: [
{
path: '/',

View File

@@ -1089,7 +1089,6 @@ export class ComfyApp {
'dragover',
(e) => {
this.canvas.adjustMouseEvent(e)
// @ts-expect-error: canvasX and canvasY are added by adjustMouseEvent in litegraph
const node = this.graph.getNodeOnPos(e.canvasX, e.canvasY)
if (node) {
// @ts-expect-error This is not a standard event. TODO fix it.

View File

@@ -10,15 +10,7 @@ import _ from 'lodash'
import * as jsondiffpatch from 'jsondiffpatch'
import log from 'loglevel'
function clone(obj: any) {
try {
if (typeof structuredClone !== 'undefined') {
return structuredClone(obj)
}
} catch (error) {
// structuredClone is stricter than using JSON.parse/stringify so fallback to that
}
function clone<T>(obj: T): T {
return JSON.parse(JSON.stringify(obj))
}
@@ -69,7 +61,7 @@ export class ChangeTracker {
if (this.restoringState) return
logger.debug('Reset State')
this.activeState = state ?? this.activeState
if (state) this.activeState = clone(state)
this.initialState = clone(this.activeState)
}
@@ -91,6 +83,10 @@ export class ChangeTracker {
}
updateModified() {
api.dispatchEvent(
new CustomEvent('graphChanged', { detail: this.activeState })
)
// Get the workflow from the store as ChangeTracker is raw object, i.e.
// `this.workflow` is not reactive.
const workflow = useWorkflowStore().getWorkflowByPath(this.workflow.path)
@@ -112,9 +108,9 @@ export class ChangeTracker {
checkState() {
if (!this.app.graph || this.changeCount) return
// @ts-expect-error zod types issue. Will be fixed after we enable ts-strict
const currentState = this.app.graph.serialize() as ComfyWorkflowJSON
const currentState = clone(this.app.graph.serialize()) as ComfyWorkflowJSON
if (!this.activeState) {
this.activeState = clone(currentState)
this.activeState = currentState
return
}
if (!ChangeTracker.graphEqual(this.activeState, currentState)) {
@@ -124,11 +120,8 @@ export class ChangeTracker {
}
logger.debug('Diff detected. Undo queue length:', this.undoQueue.length)
this.activeState = clone(currentState)
this.activeState = currentState
this.redoQueue.length = 0
api.dispatchEvent(
new CustomEvent('graphChanged', { detail: this.activeState })
)
this.updateModified()
}
}
@@ -136,7 +129,7 @@ export class ChangeTracker {
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
const prevState = source.pop()
if (prevState) {
target.push(this.activeState!)
target.push(this.activeState)
this.restoringState = true
try {
await this.app.loadGraphData(prevState, false, false, this.workflow, {

View File

@@ -63,17 +63,36 @@ export const workflowService = {
const newPath = workflow.directory + '/' + appendJsonExt(newFilename)
const newKey = newPath.substring(ComfyWorkflow.basePath.length)
const workflowStore = useWorkflowStore()
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
if (existingWorkflow) {
const res = (await ComfyAsyncDialog.prompt({
title: 'Overwrite existing file?',
message: `"${newPath}" already exists. Do you want to overwrite it?`,
actions: ['Yes', 'No']
})) as 'Yes' | 'No'
if (res === 'No') return
if (existingWorkflow.path === workflow.path) {
await this.saveWorkflow(workflow)
return
}
const deleted = await this.deleteWorkflow(existingWorkflow)
if (!deleted) return
}
if (workflow.isTemporary) {
await this.renameWorkflow(workflow, newPath)
await useWorkflowStore().saveWorkflow(workflow)
await workflowStore.saveWorkflow(workflow)
} else {
const tempWorkflow = useWorkflowStore().createTemporary(
const tempWorkflow = workflowStore.createTemporary(
newKey,
workflow.activeState as ComfyWorkflowJSON
)
await this.openWorkflow(tempWorkflow)
await useWorkflowStore().saveWorkflow(tempWorkflow)
await workflowStore.saveWorkflow(tempWorkflow)
}
},
@@ -131,9 +150,9 @@ export const workflowService = {
async closeWorkflow(
workflow: ComfyWorkflow,
options: { warnIfUnsaved: boolean } = { warnIfUnsaved: true }
): Promise<void> {
): Promise<boolean> {
if (!workflow.isLoaded) {
return
return true
}
if (workflow.isModified && options.warnIfUnsaved) {
@@ -146,7 +165,7 @@ export const workflowService = {
if (res === 'Yes') {
await this.saveWorkflow(workflow)
} else if (res === 'Cancel') {
return
return false
}
}
@@ -161,18 +180,26 @@ export const workflowService = {
}
await workflowStore.closeWorkflow(workflow)
return true
},
async renameWorkflow(workflow: ComfyWorkflow, newPath: string) {
await useWorkflowStore().renameWorkflow(workflow, newPath)
},
async deleteWorkflow(workflow: ComfyWorkflow) {
/**
* Delete a workflow
* @param workflow The workflow to delete
* @returns true if the workflow was deleted, false if the user cancelled
*/
async deleteWorkflow(workflow: ComfyWorkflow): Promise<boolean> {
const workflowStore = useWorkflowStore()
if (workflowStore.isOpen(workflow)) {
await this.closeWorkflow(workflow)
const closed = await this.closeWorkflow(workflow)
if (!closed) return false
}
await workflowStore.deleteWorkflow(workflow)
return true
},
/**

View File

@@ -493,6 +493,24 @@ export const useCommandStore = defineStore('command', () => {
function: () => {
useWorkspaceStore().toggleFocusMode()
}
},
{
id: 'Comfy.Graph.FitGroupToContents',
icon: 'pi pi-expand',
label: 'Fit Group To Contents',
versionAdded: '1.4.9',
function: () => {
for (const group of app.canvas.selectedItems) {
if (group instanceof LGraphGroup) {
group.recomputeInsideNodes()
const padding = useSettingStore().get(
'Comfy.GroupSelectedNodes.Padding'
)
group.resizeTo(group.children, padding)
app.graph.change()
}
}
}
}
]

View File

@@ -1,13 +1,16 @@
import { ref } from 'vue'
import { defineStore } from 'pinia'
import { isElectron, electronAPI } from '@/utils/envUtil'
import type {
DownloadState,
DownloadStatus
} from '@comfyorg/comfyui-electron-types'
export interface ElectronDownload {
url: string
status: 'paused' | 'in_progress' | 'cancelled'
progress: number
savePath: string
filename: string
export interface ElectronDownload
extends Pick<DownloadState, 'url' | 'filename'> {
progress?: number
savePath?: string
status?: DownloadStatus
}
/** Electron donwloads store handler */
@@ -20,15 +23,14 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
const initialize = async () => {
if (isElectron()) {
const allDownloads: ElectronDownload[] =
(await DownloadManager.getAllDownloads()) as unknown as ElectronDownload[]
const allDownloads = await DownloadManager.getAllDownloads()
for (const download of allDownloads) {
downloads.value.push(download)
}
// ToDO: replace with ElectronDownload type
DownloadManager.onDownloadProgress((data: any) => {
DownloadManager.onDownloadProgress((data) => {
if (!findByUrl(data.url)) {
downloads.value.push(data)
}
@@ -51,8 +53,11 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
url,
savePath,
filename
}: Pick<ElectronDownload, 'url' | 'savePath' | 'filename'>) =>
DownloadManager.startDownload(url, savePath, filename)
}: {
url: string
savePath: string
filename: string
}) => DownloadManager.startDownload(url, savePath, filename)
const pause = (url: string) => DownloadManager.pauseDownload(url)
const resume = (url: string) => DownloadManager.resumeDownload(url)
const cancel = (url: string) => DownloadManager.cancelDownload(url)

View File

@@ -2,7 +2,7 @@ import { defineStore } from 'pinia'
import { computed, Ref, ref, toRaw } from 'vue'
import { Keybinding, KeyCombo } from '@/types/keyBindingTypes'
import { useSettingStore } from './settingStore'
import { CORE_KEYBINDINGS } from './coreKeybindings'
import { CORE_KEYBINDINGS } from '@/constants/coreKeybindings'
import type { ComfyExtension } from '@/types/comfy'
export class KeybindingImpl implements Keybinding {
@@ -121,8 +121,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
const keybindingByKeyCombo = computed<Record<string, KeybindingImpl>>(() => {
const result: Record<string, KeybindingImpl> = {
...defaultKeybindings.value,
...userKeybindings.value
...defaultKeybindings.value
}
for (const keybinding of Object.values(userUnsetKeybindings.value)) {
@@ -131,7 +130,11 @@ export const useKeybindingStore = defineStore('keybinding', () => {
delete result[serializedCombo]
}
}
return result
return {
...result,
...userKeybindings.value
}
})
const keybindings = computed<KeybindingImpl[]>(() =>
@@ -218,7 +221,10 @@ export const useKeybindingStore = defineStore('keybinding', () => {
function unsetKeybinding(keybinding: KeybindingImpl) {
const serializedCombo = keybinding.combo.serialize()
if (!(serializedCombo in keybindingByKeyCombo.value)) {
throw new Error(`Keybinding on ${keybinding.combo} does not exist`)
console.warn(
`Trying to unset non-exist keybinding: ${JSON.stringify(keybinding)}`
)
return
}
if (userKeybindings.value[serializedCombo]?.equals(keybinding)) {
@@ -231,7 +237,7 @@ export const useKeybindingStore = defineStore('keybinding', () => {
return
}
throw new Error(`NOT_REACHED`)
throw new Error(`Unknown keybinding: ${JSON.stringify(keybinding)}`)
}
/**

View File

@@ -30,6 +30,7 @@ export class ResultItemImpl {
filename: string
subfolder: string
type: string
cached: boolean
nodeId: NodeId
// 'audio' | 'images' | ...
@@ -43,6 +44,7 @@ export class ResultItemImpl {
this.filename = obj.filename ?? ''
this.subfolder = obj.subfolder ?? ''
this.type = obj.type ?? ''
this.cached = obj.cached
this.nodeId = obj.nodeId
this.mediaType = obj.mediaType
@@ -149,6 +151,13 @@ export class TaskItemImpl {
if (!this.outputs) {
return []
}
const cachedEntries = new Set(
this.status?.messages?.find((message) => {
return message[0] === 'execution_cached'
})?.[1].nodes
)
return Object.entries(this.outputs).flatMap(([nodeId, nodeOutputs]) =>
Object.entries(nodeOutputs).flatMap(([mediaType, items]) =>
(items as ResultItem[]).map(
@@ -156,7 +165,8 @@ export class TaskItemImpl {
new ResultItemImpl({
...item,
nodeId,
mediaType
mediaType,
cached: cachedEntries.has(nodeId)
})
)
)

View File

@@ -0,0 +1,73 @@
import { ServerConfig } from '@/constants/serverConfig'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export type ServerConfigWithValue<T> = ServerConfig<T> & {
value: T
}
export const useServerConfigStore = defineStore('serverConfig', () => {
const serverConfigById = ref<Record<string, ServerConfigWithValue<any>>>({})
const serverConfigs = computed(() => {
return Object.values(serverConfigById.value)
})
const serverConfigsByCategory = computed<
Record<string, ServerConfigWithValue<any>[]>
>(() => {
return serverConfigs.value.reduce(
(acc, config) => {
const category = config.category?.[0] ?? 'General'
acc[category] = acc[category] || []
acc[category].push(config)
return acc
},
{} as Record<string, ServerConfigWithValue<any>[]>
)
})
const serverConfigValues = computed<Record<string, any>>(() => {
return Object.fromEntries(
serverConfigs.value.map((config) => {
return [
config.id,
config.value === config.defaultValue || !config.value
? undefined
: config.value
]
})
)
})
const launchArgs = computed<Record<string, string>>(() => {
return Object.assign(
{},
...serverConfigs.value.map((config) => {
if (config.value === config.defaultValue || !config.value) {
return {}
}
return config.getValue
? config.getValue(config.value)
: { [config.id]: config.value }
})
)
})
function loadServerConfig(
configs: ServerConfig<any>[],
values: Record<string, any>
) {
for (const config of configs) {
serverConfigById.value[config.id] = {
...config,
value: values[config.id] ?? config.defaultValue
}
}
}
return {
serverConfigById,
serverConfigs,
serverConfigsByCategory,
serverConfigValues,
launchArgs,
loadServerConfig
}
})

View File

@@ -16,7 +16,7 @@ import type { SettingParams } from '@/types/settingTypes'
import type { TreeNode } from 'primevue/treenode'
import type { ComfyExtension } from '@/types/comfy'
import { buildTree } from '@/utils/treeUtil'
import { CORE_SETTINGS } from '@/stores/coreSettings'
import { CORE_SETTINGS } from '@/constants/coreSettings'
export interface SettingTreeNode extends TreeNode {
data?: SettingParams

View File

@@ -454,6 +454,11 @@ const zNodeBadgeMode = z.enum(
Object.values(NodeBadgeMode) as [string, ...string[]]
)
const zQueueFilter = z.object({
hideCached: z.boolean(),
hideCanceled: z.boolean()
})
const zSettings = z.record(z.any()).and(
z
.object({
@@ -506,6 +511,8 @@ const zSettings = z.record(z.any()).and(
'Comfy.Validation.Workflows': z.boolean(),
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),
'Comfy.Queue.ShowFlatList': z.boolean(),
'Comfy.Queue.Filter': zQueueFilter.passthrough(),
'Comfy.Workflow.WorkflowTabsPosition': z.enum(['Sidebar', 'Topbar']),
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
'Comfy.Window.UnloadConfirmation': z.boolean(),
@@ -519,7 +526,9 @@ const zSettings = z.record(z.any()).and(
'Comfy.Settings.ExtensionPanel': z.boolean(),
'Comfy.LinkRenderMode': z.number(),
'Comfy.Node.AutoSnapLinkToSlot': z.boolean(),
'Comfy.Node.SnapHighlightsNode': z.boolean()
'Comfy.Node.SnapHighlightsNode': z.boolean(),
'Comfy.Server.ServerConfigValues': z.record(z.string(), z.any()),
'Comfy.Server.LaunchArgs': z.record(z.string(), z.string())
})
.optional()
)

65
src/types/serverArgs.ts Normal file
View File

@@ -0,0 +1,65 @@
export enum LatentPreviewMethod {
NoPreviews = 'none',
Auto = 'auto',
Latent2RGB = 'latent2rgb',
TAESD = 'taesd'
}
export enum LogLevel {
DEBUG = 'DEBUG',
INFO = 'INFO',
WARNING = 'WARNING',
ERROR = 'ERROR',
CRITICAL = 'CRITICAL'
}
export enum HashFunction {
MD5 = 'md5',
SHA1 = 'sha1',
SHA256 = 'sha256',
SHA512 = 'sha512'
}
export enum AutoLaunch {
// Let server decide whether to auto launch based on the current environment
Auto = 'auto',
// Disable auto launch
Disable = 'disable',
// Enable auto launch
Enable = 'enable'
}
export enum CudaMalloc {
// Let server decide whether to use CUDA malloc based on the current environment
Auto = 'auto',
// Disable CUDA malloc
Disable = 'disable',
// Enable CUDA malloc
Enable = 'enable'
}
export enum FloatingPointPrecision {
AUTO = 'auto',
FP32 = 'fp32',
FP16 = 'fp16',
BF16 = 'bf16',
FP8E4M3FN = 'fp8_e4m3fn',
FP8E5M2 = 'fp8_e5m2'
}
export enum CrossAttentionMethod {
Auto = 'auto',
Split = 'split',
Quad = 'quad',
Pytorch = 'pytorch'
}
export enum VramManagement {
Auto = 'auto',
GPUOnly = 'gpu-only',
HighVram = 'highvram',
NormalVram = 'normalvram',
LowVram = 'lowvram',
NoVram = 'novram',
CPU = 'cpu'
}

View File

@@ -29,15 +29,10 @@ export interface Setting {
render: () => HTMLElement
}
export interface SettingParams {
export interface SettingParams extends FormItem {
id: keyof Settings
name: string
type: SettingInputType | SettingCustomRenderer
defaultValue: any
onChange?: (newValue: any, oldValue?: any) => void
attrs?: any
tooltip?: string
options?: Array<string | SettingOption> | ((value: any) => SettingOption[])
// By default category is id.split('.'). However, changing id to assign
// new category has poor backward compatibility. Use this field to overwrite
// default category from id.
@@ -52,3 +47,14 @@ export interface SettingParams {
// Version of the setting when it was last modified
versionModified?: string
}
/**
* The base form item for rendering in a form.
*/
export interface FormItem {
name: string
type: SettingInputType | SettingCustomRenderer
tooltip?: string
attrs?: Record<string, any>
options?: Array<string | SettingOption> | ((value: any) => SettingOption[])
}

View File

@@ -2,43 +2,85 @@
<div
class="font-sans flex flex-col justify-center items-center h-screen m-0 text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto"
>
<h2 class="text-2xl font-bold">{{ ProgressMessages[status] }}</h2>
<LogTerminal :fetch-logs="fetchLogs" :fetch-interval="500" />
<h2 class="text-2xl font-bold">{{ t(`serverStart.process.${status}`) }}</h2>
<div
v-if="status == ProgressStatus.ERROR"
class="flex items-center my-4 gap-2"
>
<Button
icon="pi pi-flag"
severity="secondary"
:label="t('serverStart.reportIssue')"
@click="reportIssue"
/>
<Button
icon="pi pi-file"
severity="secondary"
:label="t('serverStart.openLogs')"
@click="openLogs"
/>
<Button
icon="pi pi-refresh"
:label="t('serverStart.reinstall')"
@click="reinstall"
/>
</div>
<BaseTerminal @created="terminalCreated" />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import LogTerminal from '@/components/common/LogTerminal.vue'
import {
ProgressStatus,
ProgressMessages
} from '@comfyorg/comfyui-electron-types'
import Button from 'primevue/button'
import { ref, onMounted, Ref } from 'vue'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
import { ProgressStatus } from '@comfyorg/comfyui-electron-types'
import { electronAPI } from '@/utils/envUtil'
import type { useTerminal } from '@/hooks/bottomPanelTabs/useTerminal'
import { Terminal } from '@xterm/xterm'
import { useI18n } from 'vue-i18n'
const electron = electronAPI()
const { t } = useI18n()
const status = ref<ProgressStatus>(ProgressStatus.INITIAL_STATE)
const logs = ref<string[]>([])
let xterm: Terminal | undefined
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
status.value = newStatus
logs.value = [] // Clear logs when status changes
xterm?.clear()
}
const addLogMessage = (message: string) => {
logs.value = [...logs.value, message]
const terminalCreated = (
{ terminal, useAutoSize }: ReturnType<typeof useTerminal>,
root: Ref<HTMLElement>
) => {
xterm = terminal
useAutoSize(root, true, true)
electron.onLogMessage((message: string) => {
terminal.write(message)
})
terminal.options.cursorBlink = false
terminal.options.disableStdin = true
terminal.options.cursorInactiveStyle = 'block'
}
const fetchLogs = async () => {
return logs.value.join('\n')
const reinstall = () => electron.reinstall()
const reportIssue = () => {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
const openLogs = () => electron.openLogsFolder()
onMounted(() => {
electron.sendReady()
electron.onProgressUpdate(updateProgress)
electron.onLogMessage((message: string) => {
addLogMessage(message)
})
})
</script>
<style scoped>
:deep(.xterm-helper-textarea) {
/* Hide this as it moves all over when uv is running */
display: none;
}
</style>

View File

@@ -0,0 +1,189 @@
import { setActivePinia, createPinia } from 'pinia'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import { ServerConfig } from '@/constants/serverConfig'
import type { FormItem } from '@/types/settingTypes'
const dummyFormItem: FormItem = {
name: '',
type: 'text'
}
describe('useServerConfigStore', () => {
let store: ReturnType<typeof useServerConfigStore>
beforeEach(() => {
setActivePinia(createPinia())
store = useServerConfigStore()
})
it('should initialize with empty configs', () => {
expect(store.serverConfigs).toHaveLength(0)
expect(Object.keys(store.serverConfigById)).toHaveLength(0)
expect(Object.keys(store.serverConfigsByCategory)).toHaveLength(0)
expect(Object.keys(store.serverConfigValues)).toHaveLength(0)
expect(Object.keys(store.launchArgs)).toHaveLength(0)
})
it('should load server configs with default values', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1',
category: ['Test']
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {})
expect(store.serverConfigs).toHaveLength(2)
expect(store.serverConfigById['test.config1'].value).toBe('default1')
expect(store.serverConfigById['test.config2'].value).toBe('default2')
})
it('should load server configs with provided values', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1',
category: ['Test']
}
]
store.loadServerConfig(configs, {
'test.config1': 'custom1'
})
expect(store.serverConfigs).toHaveLength(1)
expect(store.serverConfigById['test.config1'].value).toBe('custom1')
})
it('should organize configs by category', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1',
category: ['Test']
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2',
category: ['Other']
},
{
...dummyFormItem,
id: 'test.config3',
defaultValue: 'default3'
}
]
store.loadServerConfig(configs, {})
expect(Object.keys(store.serverConfigsByCategory)).toHaveLength(3)
expect(store.serverConfigsByCategory['Test']).toHaveLength(1)
expect(store.serverConfigsByCategory['Other']).toHaveLength(1)
expect(store.serverConfigsByCategory['General']).toHaveLength(1)
})
it('should generate server config values excluding defaults', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1'
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {
'test.config1': 'custom1',
'test.config2': 'default2'
})
expect(Object.keys(store.serverConfigValues)).toHaveLength(2)
expect(store.serverConfigValues['test.config1']).toBe('custom1')
expect(store.serverConfigValues['test.config2']).toBeUndefined()
})
it('should generate launch arguments with custom getValue function', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1',
getValue: (value: string) => ({ customArg: value })
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {
'test.config1': 'custom1',
'test.config2': 'custom2'
})
expect(Object.keys(store.launchArgs)).toHaveLength(2)
expect(store.launchArgs['customArg']).toBe('custom1')
expect(store.launchArgs['test.config2']).toBe('custom2')
})
it('should not include default values in launch arguments', () => {
const configs: ServerConfig<any>[] = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1'
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {
'test.config1': 'custom1',
'test.config2': 'default2'
})
expect(Object.keys(store.launchArgs)).toHaveLength(1)
expect(store.launchArgs['test.config1']).toBe('custom1')
expect(store.launchArgs['test.config2']).toBeUndefined()
})
it('should not include nullish values in launch arguments', () => {
const configs: ServerConfig<any>[] = [
{ ...dummyFormItem, id: 'test.config1', defaultValue: 'default1' },
{ ...dummyFormItem, id: 'test.config2', defaultValue: 'default2' },
{ ...dummyFormItem, id: 'test.config3', defaultValue: 'default3' }
]
store.loadServerConfig(configs, {
'test.config1': undefined,
'test.config2': null,
'test.config3': ''
})
expect(Object.keys(store.launchArgs)).toHaveLength(0)
expect(Object.keys(store.serverConfigValues)).toEqual([
'test.config1',
'test.config2',
'test.config3'
])
})
})

View File

@@ -9,6 +9,9 @@ module.exports = async function () {
const { nop } = require('../utils/nopProxy')
global.enableWebGLCanvas = nop
global.window.HTMLElement.prototype.hasPointerCapture = jest.fn(() => true)
global.window.HTMLElement.prototype.setPointerCapture = jest.fn()
HTMLCanvasElement.prototype.getContext = nop
localStorage['Comfy.Settings.Comfy.Logging.Enabled'] = 'false'

View File

@@ -126,14 +126,14 @@ describe('useKeybindingStore', () => {
expect(store.getKeybinding(keybinding2.combo)).toEqual(keybinding2)
})
it('should throw an error when unsetting non-existent keybindings', () => {
it('should not throw an error when unsetting non-existent keybindings', () => {
const store = useKeybindingStore()
const keybinding = new KeybindingImpl({
commandId: 'test.command',
combo: { key: 'H', alt: true, shift: true }
})
expect(() => store.unsetKeybinding(keybinding)).toThrow()
expect(() => store.unsetKeybinding(keybinding)).not.toThrow()
})
it('should remove unset keybinding when adding back a default keybinding', () => {
@@ -160,4 +160,76 @@ describe('useKeybindingStore', () => {
defaultKeybinding
)
})
it('Should accept same keybinding from default and user', () => {
const store = useKeybindingStore()
const keybinding = new KeybindingImpl({
commandId: 'test.command',
combo: { key: 'J', ctrl: true }
})
// Add default keybinding.
// This can happen when we change default keybindings.
store.addDefaultKeybinding(keybinding)
// Add user keybinding.
store.addUserKeybinding(keybinding)
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(keybinding.combo)).toEqual(keybinding)
})
it('Should keep previously customized keybindings after default keybindings change', () => {
// Initially command 'foo' was bound to 'K, Ctrl'. User unset it and bound the
// command to 'A, Ctrl'.
// Now we change the default keybindings of 'foo' to 'A, Ctrl'.
// The user customized keybinding should be kept.
const store = useKeybindingStore()
const userUnsetKeybindings = [
new KeybindingImpl({
commandId: 'foo',
combo: { key: 'K', ctrl: true }
})
]
const userNewKeybindings = [
new KeybindingImpl({
commandId: 'foo',
combo: { key: 'A', ctrl: true }
})
]
const newCoreKeybindings = [
new KeybindingImpl({
commandId: 'foo',
combo: { key: 'A', ctrl: true }
})
]
for (const keybinding of newCoreKeybindings) {
store.addDefaultKeybinding(keybinding)
}
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(userNewKeybindings[0].combo)).toEqual(
userNewKeybindings[0]
)
for (const keybinding of userUnsetKeybindings) {
store.unsetKeybinding(keybinding)
}
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(userNewKeybindings[0].combo)).toEqual(
userNewKeybindings[0]
)
for (const keybinding of userNewKeybindings) {
store.addUserKeybinding(keybinding)
}
expect(store.keybindings).toHaveLength(1)
expect(store.getKeybinding(userNewKeybindings[0].combo)).toEqual(
userNewKeybindings[0]
)
})
})

View File

@@ -20,7 +20,7 @@ const mockElectronAPI: Plugin = {
Promise.resolve({
appData: 'C:/Users/username/AppData/Roaming',
appPath: 'C:/Program Files/comfyui-electron/resources/app',
defaultInstallPath: 'C:/Users/username/comfyui-electron'
defaultInstallPath: 'bad'
}),
validateInstallPath: (path) => {
if (path === 'bad') {
@@ -42,7 +42,12 @@ const mockElectronAPI: Plugin = {
}
return { isValid: true }
},
showDirectoryPicker: () => Promise.resolve('C:/Users/username/comfyui-electron/1')
showDirectoryPicker: () => Promise.resolve('C:/Users/username/comfyui-electron/1'),
DownloadManager: {
getAllDownloads: () => Promise.resolve([]),
onDownloadProgress: () => {}
},
getElectronVersion: () => Promise.resolve('1.0.0')
};`
}
]