Compare commits

...

58 Commits

Author SHA1 Message Date
Chenlei Hu
d232e38c33 1.2.33 (#609) 2024-08-23 18:53:48 -04:00
Chenlei Hu
1a3cf4c3f3 Show node name and node id on flattened task outputs (#608)
* wip

* Show node name and node id
2024-08-23 18:52:45 -04:00
Chenlei Hu
31d172d4d9 Fix hotkeys triggering while editing properties panel values (#606) (#607)
* Fix hotkeys triggering while editing properties panel values (#606)

* Add properties panel inputs to key handler ignore

* Add properties panel test

* Update test expectations [skip ci]

---------

Co-authored-by: bymyself <abolkonsky.rem@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2024-08-23 16:46:40 -04:00
Chenlei Hu
92ce064ebf Fix github request too long issue (#605)
* Fix github request too long issue

* Remove life on error message
2024-08-23 13:52:53 -04:00
bymyself
3d041dd742 Fix settings dialog on mobile (#602) 2024-08-23 09:43:45 -04:00
Alex "mcmonkey" Goodwin
af378262f4 Model downloader dialog (#569)
* API core for model downloader

* initial basic dialog for missing models

* app.ts handling for missing models

* don't explode if getModels is a 404

* actually track downloads in progress

* overall pile of improvements to the missing models view

* minor fixes

* add setting to disable missing models warning

* temporarily remove 'models' entry from default graph

to avoid missing model dialog causing issues. Also because ckpt autodownloading shouldn't be allowed

* swap the url to a title

* add model directory to display

* match settingStore commit

* check setting before scanning models list

ie avoid redundant calcs when setting is disabled anyway
2024-08-23 09:43:20 -04:00
Chenlei Hu
57c5a78af3 Fix signature of listUserData (#601) 2024-08-22 20:26:23 -04:00
Chenlei Hu
233fd1347e Move error handling out of api.ts to workflows.ts (#600) 2024-08-22 20:21:31 -04:00
Chenlei Hu
60221254d9 1.2.32 (#599) 2024-08-22 19:48:04 -04:00
Chenlei Hu
7434691bed Disable flaky test (#598) 2024-08-22 19:42:38 -04:00
Chenlei Hu
fbdc9d430b Improve node search matching algorithm (#597) 2024-08-22 19:40:58 -04:00
Chenlei Hu
3e457f812d Execution Error Dialog Revamp (One click issue searching and filing) (#595)
* Add basic error dialog

* 2 level error report

* Add find issue button

* nit

* Add file issue button

* Single dialog

* nit

* Fix long text wrapping

* Merge component

* Test execution error dialog
2024-08-22 15:55:38 -04:00
bymyself
0466c79725 Fix right-click save image in gallery (firefox) (#588) 2024-08-21 21:28:12 -04:00
Chenlei Hu
8b989c6415 Add a wait to prevent searchbox popup (#589)
* Add a wait to prevent searchbox popup

* nit
2024-08-21 21:27:54 -04:00
Chenlei Hu
2e51122778 Add browser tests for litegraph changes (#580) (#585) (#587)
* Add browser tests for litegraph changes (#580) (#585)

* Zoom speed tests

* Merge

* Prompt dialog test

* Update test expectations [skip ci]

---------

Co-authored-by: bymyself <abolkonsky.rem@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2024-08-21 20:39:30 -04:00
Chenlei Hu
8f8eac038a 1.2.31 (#586) 2024-08-21 19:03:40 -04:00
Tobias Menzi
4b3bad4bc5 Change broken relative font paths (#584) 2024-08-21 19:01:02 -04:00
Chenlei Hu
17c7f57d8f Update litegraph (Zoom speed adjustment) (#580) 2024-08-21 17:16:06 -04:00
Chenlei Hu
5542845710 Task output folder view (#579)
* Change to button

* Folder view
2024-08-21 16:47:30 -04:00
Chenlei Hu
f2de9b0d3c Revert "Add custom sort function to improve search matching (#574)" (#578)
This reverts commit 77ba201367.
2024-08-21 14:25:20 -04:00
Chenlei Hu
71fa71e82c Prefer saved media over preview media as task cover media (#576) 2024-08-21 14:19:27 -04:00
Chenlei Hu
77ba201367 Add custom sort function to improve search matching (#574) 2024-08-21 13:54:29 -04:00
Chenlei Hu
6edfc9bc1b Use settingStore to access setting (#572) 2024-08-21 11:42:12 -04:00
Chenlei Hu
743dc4879a Add setting to disable missing nodes dialog (#571)
* Add setting to disable missing nodes dialog

* nit

* nit
2024-08-21 10:31:07 -04:00
Alex "mcmonkey" Goodwin
2c1bd662e1 minor typo fix (#567) 2024-08-21 09:51:15 -04:00
Björn Söderqvist
7051d86ba7 Fix incorrect type paths (#568) 2024-08-21 09:41:06 -04:00
Chenlei Hu
7487c565c8 1.2.30 (#566) 2024-08-20 17:48:31 -04:00
Chenlei Hu
a86d10b02d Fix node library searchbox background color (#565) 2024-08-20 17:41:59 -04:00
Chenlei Hu
3d89c245e5 Add experimental/deprecated tags to search result / node library (#564) 2024-08-20 17:35:23 -04:00
Chenlei Hu
9dd6da3dc2 Support node deprecated/experimental flag (#563)
* Add deprecated field

* Hide deprecated nodes

* Add experimental node show/hide

* Add setting tooltips

* nit

* nit

* nit
2024-08-20 17:00:47 -04:00
Chenlei Hu
269e468425 i18n node searchbox placeholder (#561) 2024-08-20 15:20:00 -04:00
Chenlei Hu
c3ef716d53 Reduce search highlight padding (#560) 2024-08-20 15:12:30 -04:00
Chenlei Hu
bd7bbd9e95 Reduce debounce delay in node searchbox (#559) 2024-08-20 15:10:06 -04:00
ruucm
447d1c95ef enable cmd shortcuts for mac (mute & bypass) (#557) 2024-08-20 11:01:17 -04:00
Chenlei Hu
c4bc0e8430 Auto expand tree on search in node library tab (#558)
* Add custom nodelib searchbox

* Auto expand on search

* Support alphabetical sort in filtered tree
2024-08-20 11:01:05 -04:00
Chenlei Hu
f3ab9cfb8e Fix multiuser selection screen (#554) 2024-08-19 22:59:14 -04:00
Chenlei Hu
52c8c8194e Add browser_tests README (#553) 2024-08-19 22:45:06 -04:00
Chenlei Hu
9dbc114ae9 Remove win32 expectations (#551) 2024-08-19 21:59:05 -04:00
Chenlei Hu
556edea299 1.2.29 (#550) 2024-08-19 21:49:58 -04:00
Chenlei Hu
d5584a1d39 Fix node source chip alignment (#549) 2024-08-19 21:45:49 -04:00
Chenlei Hu
628f2afc34 Remove double reroute pin (#548) 2024-08-19 21:38:41 -04:00
Alex "mcmonkey" Goodwin
ea01fde607 [Experimental] hide/show logic improvement (#475)
* experimental hide/show logic improvement

for #470

* minor early out fix

not sure this is strictly needed (doesn't seem to be from a test, seems draw stops being called when hidden?) but better safe than sorry

* use null

* persist collapsed state
2024-08-19 21:22:48 -04:00
Chenlei Hu
b8a3e6b1ad Reduce padding on searchbox result item (#547)
* Reduce search result padding

* nit
2024-08-19 21:19:34 -04:00
Chenlei Hu
cfad3cd918 Add setting to hide node category in search result (#546) 2024-08-19 21:00:15 -04:00
Alex "mcmonkey" Goodwin
339e201920 Dom widget playwright tests (#540)
* gitignore win32 browser_test files

just so i can local dev with playwright without git cluttering up

* experimental add test for dom widget node toggle open/closed

* Update test expectations [skip ci]

* vs code extension lied to me, manually fix loc

* Update test expectations [skip ci]

* okay that time was my fault

* Update test expectations [skip ci]

* yknow what dont expect exactly default after actually

* Update test expectations [skip ci]

* test for multiple far panning

* Update test expectations [skip ci]

* oops, flip that

* Update test expectations [skip ci]

* more stable pan coords

* Update test expectations [skip ci]

* 'move' is not strictly relative, so compensate accordingly

* Update test expectations [skip ci]

* test to zoom very far out

* Update test expectations [skip ci]

* add hackaround for node search menu popup

* Update test expectations [skip ci]

* make zoom work better

* Update test expectations [skip ci]

* fix preexisting typo

this function is never called so it's fine

* dom widget toggle needs a delay

otherwise it's a double click

* Update test expectations [skip ci]

---------

Co-authored-by: github-actions <github-actions@github.com>
2024-08-19 20:54:07 -04:00
bymyself
c227a8af9a Use DPI to calculate screen center (#544) 2024-08-19 20:51:24 -04:00
Chenlei Hu
6b47162606 Update README.md (#545) 2024-08-19 18:18:28 -04:00
Chenlei Hu
a4c5a2a3d1 Update README.md (#543) 2024-08-19 17:26:12 -04:00
Chenlei Hu
45a47be7c0 Ctrl+Shift+Drag zoom on mouse position (#538) (#541)
* Ctrl+Shift+Drag zoom on mouse position (#538)

* Update test expectations [skip ci]

---------

Co-authored-by: bymyself <abolkonsky.rem@gmail.com>
Co-authored-by: github-actions <github-actions@github.com>
2024-08-19 17:08:50 -04:00
Chenlei Hu
2252f0a134 Resize large image to fit into window for Image Gallery (#539) 2024-08-19 16:50:40 -04:00
pythongosssss
0dfbcfb2d6 Support clicking on library entries to toggle expand/insert node (#511)
* Support clicking on library entries to toggle expand/insert node

* Fix type
2024-08-19 14:09:34 -04:00
bymyself
b46036f25d Fix escape not resetting activeIndex in gallery (#535)
* Fix escape not resetting activeIndex in gallery

* Use handleVisibilityChange for consistency
2024-08-19 14:00:47 -04:00
Chenlei Hu
f5ce42d5d5 Add setting to show/hide canvas info (#533)
* Add setting to show/hide canvas info

* nit
2024-08-19 12:12:37 -04:00
Chenlei Hu
ce75a29202 Update README.md (Sheild badges) (#532) 2024-08-19 11:36:14 -04:00
Chenlei Hu
6a8a68a240 Image failed to load placeholder (#531)
* Image failed to load placeholder

* Use broken image placeholder in gallery

* nit
2024-08-19 11:16:35 -04:00
Chenlei Hu
f9adaadc7d Use toast on reconnection message (#530) 2024-08-19 10:18:09 -04:00
Chenlei Hu
727992048e Fix type check on TaskItemImpl (#529) 2024-08-19 09:47:33 -04:00
Chenlei Hu
dd1e3f087d Update README.md (#523) 2024-08-18 22:16:46 -04:00
103 changed files with 1842 additions and 284 deletions

View File

@@ -27,6 +27,11 @@ jobs:
with:
repository: "Comfy-Org/ComfyUI_frontend"
path: "ComfyUI_frontend"
- name: Checkout ComfyUI_devtools
uses: actions/checkout@v4
with:
repository: "Comfy-Org/ComfyUI_devtools"
path: "ComfyUI/custom_nodes/ComfyUI_devtools"
- name: Get commit message
id: commit-message
run: echo "::set-output name=message::$(git log -1 --pretty=%B)"

1
.gitignore vendored
View File

@@ -34,6 +34,7 @@ tests-ui/workflows/examples
/playwright-report/
/blob-report/
/playwright/.cache/
browser_tests/*/*-win32.png
.env

View File

@@ -1,37 +1,62 @@
<div align="center">
# ComfyUI_frontend
Front-end of [ComfyUI](https://github.com/comfyanonymous/ComfyUI) modernized. This repo is fully compatible with the existing extension system.
**Official front-end implementation of [ComfyUI](https://github.com/comfyanonymous/ComfyUI).**
## How To Use
[![Website][website-shield]][website-url]
[![Discord][discord-shield]][discord-url]
[![Matrix][matrix-shield]][matrix-url]
<br>
[![][github-release-shield]][github-release-link]
[![][github-release-date-shield]][github-release-link]
[![][github-downloads-shield]][github-downloads-link]
[![][github-downloads-latest-shield]][github-downloads-link]
Add command line argument `--front-end-version Comfy-Org/ComfyUI_frontend@latest` to your
ComfyUI launch script.
For Windows stand-alone build users, please edit the `run_cpu.bat` / `run_nvidia_gpu.bat` file as following
[github-release-shield]: https://img.shields.io/github/v/release/Comfy-Org/ComfyUI_frontend?style=flat&sort=semver
[github-release-link]: https://github.com/Comfy-Org/ComfyUI_frontend/releases
[github-release-date-shield]: https://img.shields.io/github/release-date/Comfy-Org/ComfyUI_frontend?style=flat
[github-downloads-shield]: https://img.shields.io/github/downloads/Comfy-Org/ComfyUI_frontend/total?style=flat
[github-downloads-latest-shield]: https://img.shields.io/github/downloads/Comfy-Org/ComfyUI_frontend/latest/total?style=flat&label=downloads%40latest
[github-downloads-link]: https://github.com/Comfy-Org/ComfyUI_frontend/releases
[matrix-shield]: https://img.shields.io/badge/Matrix-000000?style=flat&logo=matrix&logoColor=white
[matrix-url]: https://app.element.io/#/room/%23comfyui_space%3Amatrix.org
[website-shield]: https://img.shields.io/badge/ComfyOrg-4285F4?style=flat
[website-url]: https://www.comfy.org/
[discord-shield]: https://img.shields.io/discord/1218270712402415686?style=flat&logo=discord&logoColor=white&label=Discord
[discord-url]: https://www.comfy.org/discord
</div>
## Release Schedule
### Nightly Release
Nightly releases are published daily at [https://github.com/Comfy-Org/ComfyUI_frontend/releases](https://github.com/Comfy-Org/ComfyUI_frontend/releases).
To use the latest nightly release, add the following command line argument to your ComfyUI launch script:
```
--front-end-version Comfy-Org/ComfyUI_frontend@latest
```
#### For Windows Stand-alone Build Users
Edit your `run_cpu.bat` or `run_nvidia_gpu.bat` file as follows:
```bat
.\python_embeded\python.exe -s ComfyUI\main.py --windows-standalone-build --front-end-version Comfy-Org/ComfyUI_frontend@latest
pause
```
## Trouble Shooting
<details>
<summary>Empty white screen (Fixed by https://github.com/comfyanonymous/ComfyUI/pull/4211)</summary>
### Stable Release
### Behavior
After you enable the new frontend in the command line, and open ComfyUI in the browser, you see a blank screen. If you toggle dev tools with F12, you can observe `litegraph.core.js:1` 404 in console messages.
Stable releases are published weekly in the ComfyUI main repository, aligned with ComfyUI backend's stable release schedule.
### Cause
The browser is caching the `index.html` file previously served from `localhost:8188`.
#### Feature Freeze
### How to fix
Step 1: Disable cache in devtools
![image](https://github.com/user-attachments/assets/c0cec519-93b7-49f8-aea1-7adb0aa5b073)
Step 2: Refresh your browser
</details>
There will be a 2-day feature freeze before each stable release. During this period, no new major features will be merged.
## Release Summary
@@ -190,7 +215,3 @@ This repo is using litegraph package hosted on https://github.com/Comfy-Org/lite
- Option 1: Set `DEPLOY_COMFYUI_DIR` in `.env` and run `npm run deploy`.
- Option 2: Copy everything under `dist/` to `ComfyUI/web/` in your ComfyUI checkout manually.
## Breaking changes
- api.api_url now adds a prefix `api/` to every url going through the method. If the custom node registers a new api endpoint but does not offer the `api/` prefixed alt endpoint, it will have issue. Luckily there aren't many extensions that do that. We can perform an audit before launching to resolve this issue.

View File

@@ -124,6 +124,7 @@ export class ComfyPage {
// Buttons
public readonly resetViewButton: Locator
public readonly queueButton: Locator
// Inputs
public readonly workflowUploadInput: Locator
@@ -137,6 +138,7 @@ export class ComfyPage {
this.canvas = page.locator('#graph-canvas')
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.workflowUploadInput = page.locator('#comfy-file-input')
this.searchBox = new ComfyNodeSearchBox(page)
this.menu = new ComfyMenu(page)
@@ -185,7 +187,13 @@ export class ComfyPage {
)
}
async realod() {
async getSetting(settingId: string) {
return await this.page.evaluate(async (id) => {
return await window['app'].ui.settings.getSettingValue(id)
}, settingId)
}
async reload() {
await this.page.reload({ timeout: 15000 })
await this.setup()
}
@@ -200,6 +208,10 @@ export class ComfyPage {
})
}
async delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}
async loadWorkflow(workflowName: string) {
await this.workflowUploadInput.setInputFiles(
`./browser_tests/assets/${workflowName}.json`
@@ -226,6 +238,16 @@ export class ComfyPage {
await this.nextFrame()
}
async clickTextEncodeNodeToggler() {
await this.canvas.click({
position: {
x: 430,
y: 171
}
})
await this.nextFrame()
}
async clickTextEncodeNode2() {
await this.canvas.click({
position: {
@@ -295,16 +317,22 @@ export class ComfyPage {
await this.nextFrame()
}
async zoom(deltaY: number) {
async zoom(deltaY: number, steps: number = 1) {
await this.page.mouse.move(10, 10)
await this.page.mouse.wheel(0, deltaY)
for (let i = 0; i < steps; i++) {
await this.page.mouse.wheel(0, deltaY)
}
await this.nextFrame()
}
async pan(offset: Position) {
await this.page.mouse.move(10, 10)
async pan(offset: Position, safeSpot?: Position) {
safeSpot = safeSpot || { x: 10, y: 10 }
await this.page.mouse.move(safeSpot.x, safeSpot.y)
await this.page.mouse.down()
await this.page.mouse.move(offset.x, offset.y)
// 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()
}

41
browser_tests/README.md Normal file
View File

@@ -0,0 +1,41 @@
# Playwright Testing for ComfyUI_frontend
This document outlines the setup and usage of Playwright for testing the ComfyUI_frontend project.
## Setup
Ensure you have Node.js v20 or later installed. Then, set up the Chromium test driver:
```bash
npx playwright install chromium --with-deps
```
## Running Tests
There are two ways to run the tests:
1. **Headless mode with report generation:**
```bash
npx playwright test
```
This runs all tests without a visible browser and generates a comprehensive test report.
2. **UI mode for interactive testing:**
```bash
npx playwright test --ui
```
This opens a user interface where you can select specific tests to run and inspect the test execution timeline.
![Playwright UI Mode](https://github.com/user-attachments/assets/6a1ebef0-90eb-4157-8694-f5ee94d03755)
## Screenshot Expectations
Due to variations in system font rendering, screenshot expectations are platform-specific. Please note:
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment.
- To set new test expectations:
1. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch.
2. Add the `New Browser Test Expectation` tag to your pull request.
3. This will trigger a GitHub action to update the screenshot expectations automatically.
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.

View File

@@ -0,0 +1,82 @@
{
"last_node_id": 17,
"last_link_id": 15,
"nodes": [
{
"id": 14,
"type": "PreviewImage",
"pos": [
858,
-41
],
"size": {
"0": 213.8594970703125,
"1": 50.65289306640625
},
"flags": {},
"order": 1,
"mode": 0,
"inputs": [
{
"name": "images",
"type": "IMAGE",
"link": 15
}
],
"properties": {
"Node name for S&R": "PreviewImage"
}
},
{
"id": 17,
"type": "DevToolsErrorRaiseNode",
"pos": [
477,
-40
],
"size": {
"0": 210,
"1": 26
},
"flags": {},
"order": 0,
"mode": 0,
"outputs": [
{
"name": "IMAGE",
"type": "IMAGE",
"links": [
15
],
"slot_index": 0,
"shape": 3
}
],
"properties": {
"Node name for S&R": "DevToolsErrorRaiseNode"
}
}
],
"links": [
[
15,
17,
0,
14,
0,
"IMAGE"
]
],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1.2100000000000006,
"offset": [
-266.1038310281165,
337.94335447664554
]
}
},
"version": 0.4
}

View File

@@ -0,0 +1,61 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "UNKNOWN NODE",
"pos": [
48,
86
],
"size": {
"0": 358.80780029296875,
"1": 314.7989501953125
},
"flags": {},
"order": 0,
"mode": 0,
"inputs": [
{
"name": "image",
"type": "IMAGE",
"link": null,
"slot_index": 0
}
],
"outputs": [
{
"name": "STRING",
"type": "STRING",
"links": [],
"slot_index": 0,
"shape": 6
}
],
"properties": {
"Node name for S&R": "UNKNOWN NODE"
},
"widgets_values": [
"wd-v1-4-moat-tagger-v2",
0.35,
0.85,
false,
false,
""
]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"scale": 1,
"offset": [
0, 0
]
}
},
"version": 0.4
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,27 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Load workflow warning', () => {
test('Should display a warning when loading a workflow with missing nodes', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('missing_nodes')
// Wait for the element with the .comfy-missing-nodes selector to be visible
const missingNodesWarning = comfyPage.page.locator('.comfy-missing-nodes')
await expect(missingNodesWarning).toBeVisible()
})
})
test.describe('Execution error', () => {
test('Should display an error message when an execution error occurs', async ({
comfyPage
}) => {
await comfyPage.loadWorkflow('execution_error')
await comfyPage.queueButton.click()
// Wait for the element with the .comfy-execution-error selector to be visible
const executionError = comfyPage.page.locator('.comfy-error-report')
await expect(executionError).toBeVisible()
})
})

View File

@@ -1,5 +1,5 @@
import { expect } from '@playwright/test'
import { ComfyPage, comfyPageFixture as test } from './ComfyPage'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Node Interaction', () => {
test('Can enter prompt', async ({ comfyPage }) => {
@@ -100,6 +100,38 @@ test.describe('Node Interaction', () => {
'batch-disconnect-links-disconnected.png'
)
})
test('Can toggle dom widget node open/closed', async ({ comfyPage }) => {
await expect(comfyPage.canvas).toHaveScreenshot('default.png')
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-off.png'
)
await comfyPage.delay(1000)
await comfyPage.clickTextEncodeNodeToggler()
await expect(comfyPage.canvas).toHaveScreenshot(
'text-encode-toggled-back-open.png'
)
})
test('Can close prompt dialog with canvas click', async ({ comfyPage }) => {
await comfyPage.canvas.click({
position: {
x: 724,
y: 645
}
})
await expect(comfyPage.canvas).toHaveScreenshot('prompt-dialog-opened.png')
// Wait for 1s so that it does not trigger the search box by double click.
await comfyPage.page.waitForTimeout(1000)
await comfyPage.canvas.click({
position: {
x: 10,
y: 10
}
})
await expect(comfyPage.canvas).toHaveScreenshot('prompt-dialog-closed.png')
})
})
test.describe('Canvas Interaction', () => {
@@ -110,8 +142,78 @@ test.describe('Canvas Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out.png')
})
test('Can zoom very far out', async ({ comfyPage }) => {
await comfyPage.zoom(100, 12)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-very-far-out.png')
await comfyPage.zoom(-100, 12)
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-back-in.png')
})
test('Can zoom in/out with ctrl+shift+vertical-drag', async ({
comfyPage
}) => {
await comfyPage.page.keyboard.down('Control')
await comfyPage.page.keyboard.down('Shift')
await comfyPage.dragAndDrop({ x: 10, y: 100 }, { x: 10, y: 40 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-in-ctrl-shift.png')
await comfyPage.dragAndDrop({ x: 10, y: 40 }, { x: 10, y: 160 })
await expect(comfyPage.canvas).toHaveScreenshot('zoomed-out-ctrl-shift.png')
await comfyPage.dragAndDrop({ x: 10, y: 280 }, { x: 10, y: 220 })
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-default-ctrl-shift.png'
)
await comfyPage.page.keyboard.up('Control')
await comfyPage.page.keyboard.up('Shift')
})
test('Can zoom in/out after decreasing canvas zoom speed setting', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.05)
await comfyPage.zoom(-100, 4)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-in-low-zoom-speed.png'
)
await comfyPage.zoom(100, 8)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-out-low-zoom-speed.png'
)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
})
test('Can zoom in/out after increasing canvas zoom speed', async ({
comfyPage
}) => {
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.5)
await comfyPage.zoom(-100, 4)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-in-high-zoom-speed.png'
)
await comfyPage.zoom(100, 8)
await expect(comfyPage.canvas).toHaveScreenshot(
'zoomed-out-high-zoom-speed.png'
)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
})
test('Can pan', async ({ comfyPage }) => {
await comfyPage.pan({ x: 200, y: 200 })
await expect(comfyPage.canvas).toHaveScreenshot('panned.png')
})
test('Can pan very far and back', async ({ comfyPage }) => {
// intentionally slice the edge of where the clip text encode dom widgets are
await comfyPage.pan({ x: -800, y: -300 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-step-one.png')
await comfyPage.pan({ x: -200, y: 0 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-step-two.png')
await comfyPage.pan({ x: -2200, y: -2200 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-far-away.png')
await comfyPage.pan({ x: 2200, y: 2200 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-from-far.png')
await comfyPage.pan({ x: 200, y: 0 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-two.png')
await comfyPage.pan({ x: 800, y: 300 }, { x: 1000, y: 10 })
await expect(comfyPage.canvas).toHaveScreenshot('panned-back-to-one.png')
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 75 KiB

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

View File

@@ -3,12 +3,7 @@ import { comfyPageFixture as test } from './ComfyPage'
test.describe('Menu', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.page.evaluate(async () => {
await window['app'].ui.settings.setSettingValueAsync(
'Comfy.UseNewMenu',
'Top'
)
})
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.afterEach(async ({ comfyPage }) => {
@@ -16,15 +11,11 @@ test.describe('Menu', () => {
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
await comfyPage.page.evaluate(async () => {
await window['app'].ui.settings.setSettingValueAsync(
'Comfy.UseNewMenu',
'Disabled'
)
})
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test('Toggle theme', async ({ comfyPage }) => {
// Skip reason: Flaky.
test.skip('Toggle theme', async ({ comfyPage }) => {
test.setTimeout(30000)
expect(await comfyPage.menu.getThemeId()).toBe('dark')
@@ -92,4 +83,17 @@ test.describe('Menu', () => {
// Verify the node is added to the canvas
expect(await comfyPage.getGraphNodesCount()).toBe(count + 1)
})
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const [defaultSpeed, maxSpeed] = [1.1, 2.5]
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(
defaultSpeed
)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
await comfyPage.page.reload()
await comfyPage.setup()
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', defaultSpeed)
})
})

View File

@@ -37,7 +37,7 @@ test.describe('Node search box', () => {
await comfyPage.disconnectEdge()
// Select the second item as the first item is always reroute
await comfyPage.searchBox.fillAndSelectFirstNode('CLIPTextEncode', {
suggestionIndex: 1
suggestionIndex: 0
})
await expect(comfyPage.canvas).toHaveScreenshot('auto-linked-node.png')
})
@@ -59,7 +59,7 @@ test.describe('Node search box', () => {
// Select the second item as the first item is always reroute
await comfyPage.searchBox.fillAndSelectFirstNode('Load Checkpoint', {
suggestionIndex: 1
suggestionIndex: 0
})
await expect(comfyPage.canvas).toHaveScreenshot(
'auto-linked-node-batch.png'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 91 KiB

View File

@@ -0,0 +1,24 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './ComfyPage'
test.describe('Properties Panel', () => {
test('Can change property value', async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.rightClickEmptyLatentNode()
await comfyPage.page.getByText('Properties Panel').click()
await comfyPage.nextFrame()
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-properties-panel.png'
)
const propertyInput = comfyPage.page.locator('span.property_value').first()
// Ensure no keybinds are triggered while typing
await propertyInput.pressSequentially('abcdefghijklmnopqrstuvwxyz')
await expect(comfyPage.canvas).toHaveScreenshot(
'right-click-node-properties-panel-property-changed.png'
)
await propertyInput.fill('Empty Latent Image')
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 86 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

58
package-lock.json generated
View File

@@ -1,18 +1,19 @@
{
"name": "comfyui-frontend",
"version": "1.2.28",
"version": "1.2.33",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.2.28",
"version": "1.2.33",
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.47",
"@comfyorg/litegraph": "^0.7.48",
"@primevue/themes": "^4.0.0-rc.2",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.4",
"class-transformer": "^0.5.1",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",
@@ -1880,9 +1881,9 @@
"dev": true
},
"node_modules/@comfyorg/litegraph": {
"version": "0.7.47",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.47.tgz",
"integrity": "sha512-MCN2cF6XK2tMFLad0OQhDT1kGSO99ixGLpDszh6TVbZLGawq/2kjL6bnynOv/tGoBzVTFVZZnI7JkmlDijm9MA==",
"version": "0.7.48",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.7.48.tgz",
"integrity": "sha512-BmR91huOjoMvVvdQ8Pw+L/Iez+ZIxHXA/ApfLwUTOVFa+SvwlFY76qD6C0Hw64jOx9fous1jIQUp35X0xF0RGw==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -4485,8 +4486,7 @@
"node_modules/asynckit": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
"dev": true
"integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
},
"node_modules/autoprefixer": {
"version": "10.4.19",
@@ -4525,6 +4525,17 @@
"postcss": "^8.1.0"
}
},
"node_modules/axios": {
"version": "1.7.4",
"resolved": "https://registry.npmjs.org/axios/-/axios-1.7.4.tgz",
"integrity": "sha512-DukmaFRnY6AzAALSH4J2M3k6PkaC+MfaAGdEERRWcC9q3/TWQwLpHR8ZRLKTdQ3aBDL64EdluRDjJqKw+BPZEw==",
"license": "MIT",
"dependencies": {
"follow-redirects": "^1.15.6",
"form-data": "^4.0.0",
"proxy-from-env": "^1.1.0"
}
},
"node_modules/babel-jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz",
@@ -5155,7 +5166,6 @@
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
"integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
"dev": true,
"dependencies": {
"delayed-stream": "~1.0.0"
},
@@ -5451,7 +5461,6 @@
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
"integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
"dev": true,
"engines": {
"node": ">=0.4.0"
}
@@ -6271,6 +6280,26 @@
"dev": true,
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.15.6",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz",
"integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"license": "MIT",
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/foreground-child": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.2.1.tgz",
@@ -6303,7 +6332,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dev": true,
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
@@ -9285,7 +9313,6 @@
"version": "1.52.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"dev": true,
"engines": {
"node": ">= 0.6"
}
@@ -9294,7 +9321,6 @@
"version": "2.1.35",
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dev": true,
"dependencies": {
"mime-db": "1.52.0"
},
@@ -10067,6 +10093,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"license": "MIT"
},
"node_modules/psl": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.2.28",
"version": "1.2.33",
"type": "module",
"scripts": {
"dev": "vite",
@@ -56,10 +56,11 @@
},
"dependencies": {
"@atlaskit/pragmatic-drag-and-drop": "^1.2.1",
"@comfyorg/litegraph": "^0.7.47",
"@comfyorg/litegraph": "^0.7.48",
"@primevue/themes": "^4.0.0-rc.2",
"@vitejs/plugin-vue": "^5.0.5",
"@vueuse/core": "^11.0.0",
"axios": "^1.7.4",
"class-transformer": "^0.5.1",
"dotenv": "^16.4.5",
"fuse.js": "^7.0.0",

File diff suppressed because one or more lines are too long

View File

@@ -29,6 +29,8 @@ import GlobalToast from './components/toast/GlobalToast.vue'
import { api } from './scripts/api'
import { StatusWsMessageStatus } from './types/apiTypes'
import { useQueuePendingTaskCountStore } from './stores/queueStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
const isLoading = computed<boolean>(() => useWorkspaceStore().spinner)
const theme = computed<string>(() =>
@@ -86,8 +88,28 @@ const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
const onStatus = (e: CustomEvent<StatusWsMessageStatus>) =>
queuePendingTaskCountStore.update(e)
const toast = useToast()
const reconnectingMessage: ToastMessageOptions = {
severity: 'error',
summary: t('reconnecting')
}
const onReconnecting = () => {
toast.remove(reconnectingMessage)
toast.add(reconnectingMessage)
}
const onReconnected = () => {
toast.remove(reconnectingMessage)
toast.add({
severity: 'success',
summary: t('reconnected'),
life: 2000
})
}
onMounted(() => {
api.addEventListener('status', onStatus)
api.addEventListener('reconnecting', onReconnecting)
api.addEventListener('reconnected', onReconnected)
try {
init()
} catch (e) {
@@ -97,6 +119,8 @@ onMounted(() => {
onUnmounted(() => {
api.removeEventListener('status', onStatus)
api.removeEventListener('reconnecting', onReconnecting)
api.removeEventListener('reconnected', onReconnected)
})
</script>

View File

@@ -0,0 +1,58 @@
<!-- A image with placeholder fallback on error -->
<template>
<img
:src="src"
@error="handleImageError"
:class="[{ 'broken-image': imageBroken }, ...classArray]"
/>
<div v-if="imageBroken" class="broken-image-placeholder">
<i class="pi pi-image"></i>
<span>{{ $t('imageFailedToLoad') }}</span>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
const props = defineProps<{
src: string
class?: string | string[] | object
}>()
const imageBroken = ref(false)
const handleImageError = (e: Event) => {
imageBroken.value = true
}
const classArray = computed(() => {
if (Array.isArray(props.class)) {
return props.class
} else if (typeof props.class === 'string') {
return props.class.split(' ')
} else if (typeof props.class === 'object') {
return Object.keys(props.class).filter((key) => props.class[key])
}
return []
})
</script>
<style scoped>
.broken-image {
display: none;
}
.broken-image-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
margin: 2rem;
}
.broken-image-placeholder i {
font-size: 3rem;
margin-bottom: 0.5rem;
}
</style>

View File

@@ -44,6 +44,7 @@ defineEmits(['action'])
.no-results-placeholder :deep(.p-card) {
background-color: var(--surface-ground);
text-align: center;
box-shadow: unset;
}
.no-results-placeholder h3 {

View File

@@ -1,35 +1,45 @@
<template>
<IconField :class="props.class">
<InputIcon class="pi pi-search" />
<InputIcon :class="props.icon" />
<InputText
class="search-box-input"
@input="handleInput"
:modelValue="props.modelValue"
:placeholder="$t('searchSettings') + '...'"
:placeholder="props.placeholder"
/>
</IconField>
</template>
<script setup lang="ts">
import { debounce } from 'lodash'
import IconField from 'primevue/iconfield'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext'
import { debounce } from 'lodash'
const props = defineProps<{
interface Props {
class?: string
modelValue: string
}>()
const emit = defineEmits(['update:modelValue', 'search'])
const emitSearch = debounce((event: KeyboardEvent) => {
const target = event.target as HTMLInputElement
emit('search', target.value)
}, 300)
placeholder?: string
icon?: string
debounceTime?: number
}
const handleInput = (event: KeyboardEvent) => {
const props = withDefaults(defineProps<Props>(), {
placeholder: 'Search...',
icon: 'pi pi-search',
debounceTime: 300
})
const emit = defineEmits(['update:modelValue', 'search'])
const emitSearch = debounce((value: string) => {
emit('search', value)
}, props.debounceTime)
const handleInput = (event: Event) => {
const target = event.target as HTMLInputElement
emit('update:modelValue', target.value)
emitSearch(event)
emitSearch(target.value)
}
</script>

View File

@@ -6,7 +6,7 @@
closable
closeOnEscape
dismissableMask
:maximizable="dialogStore.props.maximizable ?? false"
:maximizable="maximizable"
@hide="dialogStore.closeDialog"
@maximize="maximized = true"
@unmaximize="maximized = false"
@@ -19,20 +19,20 @@
<h3 v-else>{{ dialogStore.title || ' ' }}</h3>
</template>
<component
:is="dialogStore.component"
v-bind="dialogStore.props"
:maximized="maximized"
/>
<component :is="dialogStore.component" v-bind="contentProps" />
</Dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
import Dialog from 'primevue/dialog'
const dialogStore = useDialogStore()
const maximizable = dialogStore.props.maximizable ?? false
const maximized = ref(false)
const contentProps = computed(() => ({
...dialogStore.props,
...(dialogStore.props.maximizable ? { maximized } : {})
}))
</script>

View File

@@ -0,0 +1,184 @@
<template>
<NoResultsPlaceholder
icon="pi pi-exclamation-circle"
:title="props.error.node_type"
:message="props.error.exception_message"
/>
<div class="comfy-error-report">
<Button
v-show="!reportOpen"
:label="$t('showReport')"
@click="showReport"
text
/>
<template v-if="reportOpen">
<Divider />
<ScrollPanel style="width: 100%; height: 400px; max-width: 80vw">
<pre class="wrapper-pre">{{ reportContent }}</pre>
</ScrollPanel>
<Divider />
</template>
<div class="action-container">
<FindIssueButton
:errorMessage="props.error.exception_message"
:repoOwner="repoOwner"
:repoName="repoName"
/>
<Button
:label="$t('openNewIssue')"
icon="pi pi-github"
@click="openNewGithubIssue"
class="p-button-secondary"
/>
<Button
v-if="reportOpen"
:label="$t('copyToClipboard')"
icon="pi pi-copy"
@click="copyReportToClipboard"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useClipboard } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import FindIssueButton from '@/components/dialog/content/error/FindIssueButton.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import type { ExecutionErrorWsMessage, SystemStats } from '@/types/apiTypes'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
const props = defineProps<{
error: ExecutionErrorWsMessage
}>()
const repoOwner = 'comfyanonymous'
const repoName = 'ComfyUI'
const reportContent = ref('')
const reportOpen = ref(false)
const showReport = () => {
reportOpen.value = true
}
const toast = useToast()
const { copy, isSupported } = useClipboard()
onMounted(async () => {
generateReport(await api.getSystemStats())
})
const generateReport = (systemStats: SystemStats) => {
// The default JSON workflow has about 3000 characters.
const MAX_JSON_LENGTH = 20000
const workflowJSONString = JSON.stringify(app.graph.serialize())
const workflowText =
workflowJSONString.length > MAX_JSON_LENGTH
? 'Workflow too large. Please manually upload the workflow from local file system.'
: workflowJSONString
reportContent.value = `
# ComfyUI Error Report
## Error Details
- **Node Type:** ${props.error.node_type}
- **Exception Type:** ${props.error.exception_type}
- **Exception Message:** ${props.error.exception_message}
## Stack Trace
\`\`\`
${props.error.traceback.join('\n')}
\`\`\`
## System Information
- **OS:** ${systemStats.system.os}
- **Python Version:** ${systemStats.system.python_version}
- **Embedded Python:** ${systemStats.system.embedded_python}
## Devices
${systemStats.devices
.map(
(device) => `
- **Name:** ${device.name}
- **Type:** ${device.type}
- **VRAM Total:** ${device.vram_total}
- **VRAM Free:** ${device.vram_free}
- **Torch VRAM Total:** ${device.torch_vram_total}
- **Torch VRAM Free:** ${device.torch_vram_free}
`
)
.join('\n')}
## Attached Workflow
Please make sure that workflow does not contain any sensitive information such as API keys or passwords.
\`\`\`
${workflowText}
\`\`\`
## Additional Context
(Please add any additional context or steps to reproduce the error here)
`
}
const copyReportToClipboard = async () => {
if (isSupported) {
try {
await copy(reportContent.value)
toast.add({
severity: 'success',
summary: 'Success',
detail: 'Report copied to clipboard',
life: 3000
})
} catch (err) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to copy report'
})
}
} else {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Clipboard API not supported in your browser'
})
}
}
const openNewGithubIssue = async () => {
await copyReportToClipboard()
const issueTitle = encodeURIComponent(
`[Bug]: ${props.error.exception_type} in ${props.error.node_type}`
)
const issueBody = encodeURIComponent(
'The report has been copied to the clipboard. Please paste it here.'
)
const url = `https://github.com/${repoOwner}/${repoName}/issues/new?title=${issueTitle}&body=${issueBody}`
window.open(url, '_blank')
}
</script>
<style scoped>
.comfy-error-report {
display: flex;
flex-direction: column;
gap: 1rem;
}
.action-container {
display: flex;
gap: 1rem;
justify-content: flex-end;
}
.wrapper-pre {
white-space: pre-wrap;
word-wrap: break-word;
}
.no-results-placeholder {
padding-top: 0;
}
</style>

View File

@@ -0,0 +1,262 @@
<template>
<div class="comfy-missing-models">
<h4 class="warning-title">Warning: Missing Models</h4>
<p class="warning-description">
When loading the graph, the following models were not found:
</p>
<ListBox
:options="missingModels"
optionLabel="label"
scrollHeight="100%"
:class="'missing-models-list' + (props.maximized ? ' maximized' : '')"
:pt="{
list: { class: 'border-none' }
}"
>
<template #option="slotProps">
<div class="missing-model-item" :style="{ '--progress': `${slotProps.option.progress}%` }">
<div class="model-info">
<div class="model-details">
<span class="model-type" :title=slotProps.option.hint>{{ slotProps.option.label }}</span>
</div>
<div v-if="slotProps.option.error" class="model-error">{{ slotProps.option.error }}</div>
</div>
<div class="model-action">
<Button
v-if="slotProps.option.action && !slotProps.option.downloading && !slotProps.option.completed && !slotProps.option.error"
@click="slotProps.option.action.callback"
:label="slotProps.option.action.text"
class="p-button-sm p-button-outlined model-action-button"
/>
<div v-if="slotProps.option.downloading" class="download-progress">
<span class="progress-text">{{ slotProps.option.progress.toFixed(2) }}%</span>
</div>
<div v-if="slotProps.option.completed" class="download-complete">
<i class="pi pi-check" style="color: var(--green-500);"></i>
</div>
<div v-if="slotProps.option.error" class="download-error">
<i class="pi pi-times" style="color: var(--red-600);"></i>
</div>
</div>
</div>
</template>
</ListBox>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import ListBox from 'primevue/listbox'
import Button from 'primevue/button'
import { api } from '@/scripts/api'
import { DownloadModelStatus } from '@/types/apiTypes'
const allowedSources = ['https://civitai.com/', 'https://huggingface.co/']
interface ModelInfo {
name: string
directory: string
directory_invalid?: boolean
url: string
downloading?: boolean
completed?: boolean
progress?: number
error?: string
}
const props = defineProps<{
missingModels: ModelInfo[]
maximized: boolean
}>()
const modelDownloads = ref<Record<string, ModelInfo>>({})
let lastModel: string | null = null
const handleDownloadProgress = (detail: DownloadModelStatus) => {
if (detail.status === 'in_progress') {
const model = detail.message.split(' ', 2)[1] // TODO: better way to track which model is being downloaded?
lastModel = model
const progress = detail.progress_percentage
modelDownloads.value[model] = { ...modelDownloads.value[model], downloading: true, progress, completed: false }
} else if (detail.status === 'pending') {
const model = detail.message.split(' ', 4)[3]
lastModel = model
modelDownloads.value[model] = { ...modelDownloads.value[model], downloading: true, progress: 0, completed: false }
} else if (detail.status === 'completed') {
const model = detail.message.split(' ', 3)[2]
lastModel = model
modelDownloads.value[model] = { ...modelDownloads.value[model], downloading: false, progress: 100, completed: true }
} else if (detail.status === 'error') {
if (lastModel) {
modelDownloads.value[lastModel] = { ...modelDownloads.value[lastModel], downloading: false, progress: 0, error: detail.message, completed: false }
}
}
// TODO: other statuses?
}
const triggerDownload = async (url: string, directory: string, filename: string) => {
modelDownloads.value[filename] = { name: filename, directory, url, downloading: true, progress: 0 }
const download = await api.internalDownloadModel(url, directory, filename, 1)
handleDownloadProgress(download)
}
api.addEventListener('download_progress', (event) => {
handleDownloadProgress(event.detail)
})
const missingModels = computed(() => {
return props.missingModels
.map((model) => {
const downloadInfo = modelDownloads.value[model.name]
if (!allowedSources.some((source) => model.url.startsWith(source))) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
error: 'Download not allowed from this source'
}
}
if (model.directory_invalid) {
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
error: 'Invalid directory specified (does this require custom nodes?)'
}
}
return {
label: `${model.directory} / ${model.name}`,
hint: model.url,
downloading: downloadInfo?.downloading ?? false,
completed: downloadInfo?.completed ?? false,
progress: downloadInfo?.progress ?? 0,
error: downloadInfo?.error,
action: {
text: 'Download',
callback: () => triggerDownload(model.url, model.directory, model.name)
}
}
})
})
</script>
<style>
:root {
--red-600: #dc3545;
--green-500: #28a745;
}
</style>
<style scoped>
.comfy-missing-models {
font-family: monospace;
color: var(--red-600);
padding: 1.5rem;
background-color: var(--surface-ground);
border-radius: var(--border-radius);
box-shadow: var(--card-shadow);
}
.warning-title {
margin-top: 0;
margin-bottom: 1rem;
}
.warning-description {
margin-bottom: 1rem;
}
.missing-models-list {
max-height: 300px;
overflow-y: auto;
}
.missing-models-list.maximized {
max-height: unset;
}
.missing-model-item {
display: flex;
align-items: flex-start;
padding: 0.5rem;
position: relative;
overflow: hidden;
width: 100%;
}
.missing-model-item::before {
content: '';
position: absolute;
top: 0;
left: 0;
height: 100%;
width: var(--progress);
background-color: var(--green-500);
opacity: 0.2;
transition: width 0.3s ease;
}
.model-info {
flex: 1;
min-width: 0;
z-index: 1;
display: flex;
flex-direction: column;
margin-right: 1rem;
overflow: hidden;
}
.model-details {
display: flex;
align-items: center;
flex-wrap: wrap;
}
.model-type {
font-weight: 600;
color: var(--text-color);
margin-right: 0.5rem;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-hint {
font-style: italic;
color: var(--text-color-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.model-error {
color: var(--red-600);
font-size: 0.8rem;
margin-top: 0.25rem;
}
.model-action {
display: flex;
align-items: center;
justify-content: flex-end;
z-index: 1;
}
.model-action-button {
min-width: 80px;
}
.download-progress, .download-complete, .download-error {
display: flex;
align-items: center;
justify-content: center;
min-width: 80px;
}
.progress-text {
font-size: 0.8rem;
color: var(--text-color);
}
.download-complete i, .download-error i {
font-size: 1.2rem;
}
</style>

View File

@@ -1,10 +1,11 @@
<template>
<div class="settings-container">
<div class="settings-sidebar">
<SettingSearchBox
<SearchBox
class="settings-search-box"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchSettings') + '...'"
/>
<Listbox
v-model="activeCategory"
@@ -66,7 +67,7 @@ import Divider from 'primevue/divider'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import SettingGroup from './setting/SettingGroup.vue'
import SettingSearchBox from './setting/SettingSearchBox.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
@@ -197,4 +198,15 @@ const tabValue = computed(() =>
.settings-content::-webkit-scrollbar-thumb {
background-color: transparent;
}
@media (max-width: 768px) {
.settings-container {
flex-direction: column;
height: auto;
}
.settings-sidebar {
width: 100%;
}
}
</style>

View File

@@ -0,0 +1,55 @@
<template>
<Button
@click="openGitHubIssues"
:label="buttonLabel"
severity="secondary"
icon="pi pi-github"
:badge="issueCount.toString()"
>
</Button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useAsyncState } from '@vueuse/core'
import axios from 'axios'
import Button from 'primevue/button'
import { useI18n } from 'vue-i18n'
const props = defineProps<{
errorMessage: string
repoOwner: string
repoName: string
}>()
const GITHUB_API_URL = 'https://api.github.com/search/issues'
const queryString = computed(() => props.errorMessage + ' is:issue')
const getIssueCount = async () => {
const query = `${queryString.value} repo:${props.repoOwner}/${props.repoName}`
const response = await axios.get(GITHUB_API_URL, {
params: {
q: query,
per_page: 1
}
})
return response.data.total_count
}
const {
state: issueCount,
isLoading,
execute
} = useAsyncState(getIssueCount, 0)
const { t } = useI18n()
const buttonLabel = computed(() => {
return isLoading.value ? 'Loading...' : t('findIssues')
})
const openGitHubIssues = () => {
const query = encodeURIComponent(queryString.value)
const url = `https://github.com/${props.repoOwner}/${props.repoName}/issues?q=${query}`
window.open(url, '_blank')
}
</script>

View File

@@ -16,7 +16,7 @@ import SideToolbar from '@/components/sidebar/SideToolbar.vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import { ref, computed, onUnmounted, watch, onMounted } from 'vue'
import { ref, computed, onUnmounted, watch, onMounted, watchEffect } from 'vue'
import { app as comfyApp } from '@/scripts/app'
import { useSettingStore } from '@/stores/settingStore'
import { dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'
@@ -36,6 +36,7 @@ import {
const emit = defineEmits(['ready'])
const canvasRef = ref<HTMLCanvasElement | null>(null)
const settingStore = useSettingStore()
const nodeDefStore = useNodeDefStore()
const workspaceStore = useWorkspaceStore()
const betaMenuEnabled = computed(
@@ -52,6 +53,35 @@ watch(
},
{ immediate: true }
)
const canvasInfoEnabled = computed<boolean>(() =>
settingStore.get('Comfy.Graph.CanvasInfo')
)
watch(
canvasInfoEnabled,
(newVal) => {
if (comfyApp.canvas) comfyApp.canvas.show_info = newVal
},
{ immediate: true }
)
const zoomSpeed = computed(() => settingStore.get('Comfy.Graph.ZoomSpeed'))
watch(
zoomSpeed,
(newVal: number) => {
if (comfyApp.canvas) comfyApp.canvas['zoom_speed'] = newVal
},
{ immediate: true }
)
watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
})
watchEffect(() => {
nodeDefStore.showExperimental = settingStore.get(
'Comfy.Node.ShowExperimental'
)
})
let dropTargetCleanup = () => {}
@@ -72,6 +102,8 @@ onMounted(async () => {
workspaceStore.spinner = true
await comfyApp.setup(canvasRef.value)
comfyApp.canvas.allow_searchbox = !nodeSearchEnabled.value
comfyApp.canvas.show_info = canvasInfoEnabled.value
comfyApp.canvas['zoom_speed'] = zoomSpeed.value
workspaceStore.spinner = false
window['app'] = comfyApp
@@ -87,7 +119,7 @@ onMounted(async () => {
const comfyNodeName = event.source.element.getAttribute(
'data-comfy-node-name'
)
const nodeDef = useNodeDefStore().nodeDefsByName[comfyNodeName]
const nodeDef = nodeDefStore.nodeDefsByName[comfyNodeName]
comfyApp.addNodeOnGraph(nodeDef, { pos })
}
})

View File

@@ -17,6 +17,7 @@
append-to="self"
:suggestions="suggestions"
:min-length="0"
:delay="100"
@complete="search($event.query)"
@option-select="emit('addNode', $event.value)"
@focused-option-changed="setHoverSuggestion($event)"
@@ -31,14 +32,26 @@
<span
v-html="highlightQuery(option.display_name, currentQuery)"
></span>
<div v-if="showCategory" class="option-category">
{{ option.category.replaceAll('/', ' > ') }}
</div>
</div>
<div class="option-badges">
<Tag
v-if="option.experimental"
:value="$t('experimental')"
severity="primary"
/>
<Tag
v-if="option.deprecated"
:value="$t('deprecated')"
severity="danger"
/>
<NodeSourceChip
v-if="option.python_module !== undefined"
:python_module="option.python_module"
/>
</div>
<div class="option-category">
{{ option.category.replaceAll('/', ' > ') }}
</div>
</div>
</template>
<!-- FilterAndValue -->
@@ -59,17 +72,24 @@ import { computed, onMounted, ref } from 'vue'
import AutoCompletePlus from '@/components/primevueOverride/AutoCompletePlus.vue'
import Chip from 'primevue/chip'
import Badge from 'primevue/badge'
import Tag from 'primevue/tag'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import NodeSourceChip from '@/components/node/NodeSourceChip.vue'
import { type FilterAndValue } from '@/services/nodeSearchService'
import NodePreview from '@/components/node/NodePreview.vue'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useI18n } from 'vue-i18n'
const settingStore = useSettingStore()
const { t } = useI18n()
const enableNodePreview = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.NodePreview')
)
const showCategory = computed(() =>
settingStore.get('Comfy.NodeSearchBoxImpl.ShowCategory')
)
const props = defineProps({
filters: {
@@ -78,11 +98,6 @@ const props = defineProps({
searchLimit: {
type: Number,
default: 64
},
// TODO: Find a more flexible mechanism to add pinned nodes
includeReroute: {
type: Boolean,
default: false
}
})
@@ -91,15 +106,12 @@ const suggestions = ref<ComfyNodeDefImpl[]>([])
const hoveredSuggestion = ref<ComfyNodeDefImpl | null>(null)
const currentQuery = ref('')
const placeholder = computed(() => {
return props.filters.length === 0 ? 'Search for nodes' : ''
return props.filters.length === 0 ? t('searchNodes') + '...' : ''
})
const search = (query: string) => {
currentQuery.value = query
suggestions.value = [
...(props.includeReroute
? [useNodeDefStore().nodeDefsByName['Reroute']]
: []),
...useNodeDefStore().nodeSearchService.searchNode(query, props.filters, {
limit: props.searchLimit
})
@@ -163,15 +175,15 @@ const setHoverSuggestion = (index: number) => {
}
.option-container {
@apply flex flex-col px-4 py-2 cursor-pointer overflow-hidden w-full;
@apply flex justify-between items-center px-2 py-0 cursor-pointer overflow-hidden w-full;
}
.option-display-name {
@apply font-semibold;
@apply font-semibold flex flex-col;
}
.option-category {
@apply text-sm text-gray-400 overflow-hidden text-ellipsis;
@apply font-light text-sm text-gray-400 overflow-hidden text-ellipsis;
/* Keeps the text on a single line by default */
white-space: nowrap;
}
@@ -197,7 +209,7 @@ const setHoverSuggestion = (index: number) => {
color: var(--p-primary-contrast-color);
font-weight: bold;
border-radius: 0.25rem;
padding: 0.125rem 0.25rem;
padding: 0rem 0.125rem;
margin: -0.125rem 0.125rem;
}
</style>

View File

@@ -20,7 +20,6 @@
<template #container>
<NodeSearchBox
:filters="nodeFilters"
:includeReroute="includeReroute"
@add-filter="addFilter"
@remove-filter="removeFilter"
@add-node="addNode"
@@ -62,7 +61,6 @@ const getNewNodeLocation = (): [number, number] => {
return [originalEvent.canvasX, originalEvent.canvasY]
}
const nodeFilters = reactive([])
const includeReroute = ref(false)
const addFilter = (filter: FilterAndValue) => {
nodeFilters.push(filter)
}
@@ -102,7 +100,6 @@ const linkReleaseTriggerMode = computed(() => {
})
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
includeReroute.value = false
const shiftPressed = (e.detail.originalEvent as KeyboardEvent).shiftKey
if (e.detail.subType === 'empty-release') {
@@ -126,7 +123,6 @@ const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
)
const dataType = firstLink.type
addFilter([filter, dataType])
includeReroute.value = true
}
triggerEvent.value = e
visible.value = true

View File

@@ -14,16 +14,30 @@
</ToggleButton>
</template>
<template #body>
<SearchBox
class="node-lib-search-box"
v-model:modelValue="searchQuery"
@search="handleSearch"
:placeholder="$t('searchNodes') + '...'"
/>
<TreePlus
class="node-lib-tree"
v-model:expandedKeys="expandedKeys"
selectionMode="single"
:value="renderedRoot.children"
:filter="true"
filterMode="lenient"
dragSelector=".p-tree-node-leaf"
:pt="{
nodeLabel: 'node-lib-tree-node-label',
nodeContent: ({ props }) => ({
onClick: () => {
if (!props.node.key) return
if (props.node.type === 'folder') {
toggleNode(props.node.key)
} else {
insertNode(props.node.data)
}
}
}),
nodeChildren: ({ props }) => ({
'data-comfy-node-name': props.node?.data?.name,
onMouseenter: (event: MouseEvent) =>
@@ -31,6 +45,12 @@
onMouseleave: () => {
hoveredComfyNodeName = null
}
}),
nodeToggleButton: () => ({
onClick: (e: MouseEvent) => {
// Prevent toggle action as the node controls it
e.stopImmediatePropagation()
}
})
}"
>
@@ -43,6 +63,16 @@
></Badge>
</template>
<template #node="{ node }">
<Tag
v-if="node.data.experimental"
:value="$t('experimental')"
severity="primary"
/>
<Tag
v-if="node.data.deprecated"
:value="$t('deprecated')"
severity="danger"
/>
<span class="node-label">{{ node.label }}</span>
</template>
</TreePlus>
@@ -63,14 +93,18 @@
<script setup lang="ts">
import Badge from 'primevue/badge'
import Tag from 'primevue/tag'
import ToggleButton from 'primevue/togglebutton'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { computed, ref, nextTick } from 'vue'
import type { TreeNode } from 'primevue/treenode'
import TreePlus from '@/components/primevueOverride/TreePlus.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import { useSettingStore } from '@/stores/settingStore'
import { app } from '@/scripts/app'
import { buildTree, sortedTree } from '@/utils/treeUtil'
const nodeDefStore = useNodeDefStore()
const alphabeticalSort = ref(false)
@@ -83,20 +117,23 @@ const hoveredComfyNode = computed<ComfyNodeDefImpl | null>(() => {
return nodeDefStore.nodeDefsByName[hoveredComfyNodeName.value] || null
})
const previewRef = ref<InstanceType<typeof NodePreview> | null>(null)
const searchQuery = ref<string>('')
const settingStore = useSettingStore()
const sidebarLocation = computed<'left' | 'right'>(() =>
settingStore.get('Comfy.Sidebar.Location')
)
const nodePreviewStyle = ref<Record<string, string>>({
position: 'absolute',
top: '0px',
left: '0px'
})
const root = computed(() =>
alphabeticalSort.value ? nodeDefStore.sortedNodeTree : nodeDefStore.nodeTree
)
const root = computed(() => {
const root = filteredRoot.value || nodeDefStore.nodeTree
return alphabeticalSort.value ? sortedTree(root) : root
})
const renderedRoot = computed(() => {
return fillNodeInfo(root.value)
})
@@ -136,7 +173,8 @@ const handleNodeHover = async (
const previewHeight = previewRef.value?.$el.offsetHeight || 0
const availableSpaceBelow = window.innerHeight - targetRect.bottom
nodePreviewStyle.value.top = previewHeight > availableSpaceBelow
nodePreviewStyle.value.top =
previewHeight > availableSpaceBelow
? `${Math.max(0, targetRect.top - (previewHeight - availableSpaceBelow) - 20)}px`
: `${targetRect.top - 40}px`
if (sidebarLocation.value === 'left') {
@@ -145,4 +183,63 @@ const handleNodeHover = async (
nodePreviewStyle.value.left = `${targetRect.left - 400}px`
}
}
const toggleNode = (id: string) => {
if (id in expandedKeys.value) {
delete expandedKeys.value[id]
} else {
expandedKeys.value[id] = true
}
}
const insertNode = (nodeDef: ComfyNodeDefImpl) => {
app.addNodeOnGraph(nodeDef, { pos: app.getCanvasCenter() })
}
const filteredRoot = ref<TreeNode | null>(null)
const handleSearch = (query: string) => {
if (query.length < 3) {
filteredRoot.value = null
expandedKeys.value = {}
return
}
const matchedNodes = nodeDefStore.nodeSearchService.searchNode(query, [], {
limit: 64
})
filteredRoot.value = buildTree(matchedNodes, (nodeDef: ComfyNodeDefImpl) => [
...nodeDef.category.split('/'),
nodeDef.display_name
])
expandNode(filteredRoot.value)
}
const expandNode = (node: TreeNode) => {
if (node.children && node.children.length) {
expandedKeys.value[node.key] = true
for (let child of node.children) {
expandNode(child)
}
}
}
</script>
<style>
.node-lib-tree-node-label {
display: flex;
align-items: center;
margin-left: var(--p-tree-node-gap);
}
</style>
<style scoped>
:deep(.node-lib-search-box) {
@apply mx-4 mt-4;
}
:deep(.comfy-vue-side-bar-body) {
background: var(--p-tree-background);
}
</style>

View File

@@ -1,21 +1,32 @@
<template>
<SideBarTabTemplate :title="$t('sideToolbar.queue')">
<SidebarTabTemplate :title="$t('sideToolbar.queue')">
<template #tool-buttons>
<Button
:icon="isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
v-if="isInFolderView"
icon="pi pi-arrow-left"
text
severity="secondary"
@click="toggleExpanded"
class="toggle-expanded-button"
v-tooltip="$t('sideToolbar.queueTab.showFlatList')"
/>
<Button
icon="pi pi-trash"
text
severity="primary"
@click="confirmRemoveAll($event)"
class="clear-all-button"
@click="exitFolderView"
class="back-button"
v-tooltip="$t('sideToolbar.queueTab.backToAllTasks')"
/>
<template v-else>
<Button
:icon="isExpanded ? 'pi pi-chevron-up' : 'pi pi-chevron-down'"
text
severity="secondary"
@click="toggleExpanded"
class="toggle-expanded-button"
v-tooltip="$t('sideToolbar.queueTab.showFlatList')"
/>
<Button
icon="pi pi-trash"
text
severity="primary"
@click="confirmRemoveAll($event)"
class="clear-all-button"
/>
</template>
</template>
<template #body>
<div
@@ -28,9 +39,10 @@
v-for="task in visibleTasks"
:key="task.key"
:task="task"
:isFlatTask="isExpanded"
:isFlatTask="isExpanded || isInFolderView"
@contextmenu="handleContextMenu"
@preview="handlePreview"
@taskOutputLengthClicked="enterFolderView($event)"
/>
</div>
<div ref="loadMoreTrigger" style="height: 1px" />
@@ -43,7 +55,7 @@
/>
</div>
</template>
</SideBarTabTemplate>
</SidebarTabTemplate>
<ConfirmPopup />
<ContextMenu ref="menu" :model="menuItems" />
<ResultGallery
@@ -64,7 +76,7 @@ import ContextMenu from 'primevue/contextmenu'
import type { MenuItem } from 'primevue/menuitem'
import TaskItem from './queue/TaskItem.vue'
import ResultGallery from './queue/ResultGallery.vue'
import SideBarTabTemplate from './SidebarTabTemplate.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { TaskItemImpl, useQueueStore } from '@/stores/queueStore'
import { api } from '@/scripts/api'
@@ -74,17 +86,27 @@ const toast = useToast()
const queueStore = useQueueStore()
const { t } = useI18n()
// Expanded view: show all outputs in a flat list.
const isExpanded = ref(false)
const visibleTasks = ref<TaskItemImpl[]>([])
const scrollContainer = ref<HTMLElement | null>(null)
const loadMoreTrigger = ref<HTMLElement | null>(null)
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 ITEMS_PER_PAGE = 8
const SCROLL_THRESHOLD = 100 // pixels from bottom to trigger load
const allTasks = computed(() =>
isExpanded.value ? queueStore.flatTasks : queueStore.tasks
isInFolderView.value
? folderTask.value
? folderTask.value.flatten()
: []
: isExpanded.value
? queueStore.flatTasks
: queueStore.tasks
)
const allGalleryItems = computed(() =>
allTasks.value.flatMap((task: TaskItemImpl) => {
@@ -129,9 +151,13 @@ useResizeObserver(scrollContainer, () => {
})
})
const updateVisibleTasks = () => {
visibleTasks.value = allTasks.value.slice(0, ITEMS_PER_PAGE)
}
const toggleExpanded = () => {
isExpanded.value = !isExpanded.value
visibleTasks.value = allTasks.value.slice(0, ITEMS_PER_PAGE)
updateVisibleTasks()
}
const removeTask = (task: TaskItemImpl) => {
@@ -173,7 +199,7 @@ const confirmRemoveAll = (event: Event) => {
const onStatus = async () => {
await queueStore.update()
visibleTasks.value = allTasks.value.slice(0, ITEMS_PER_PAGE)
updateVisibleTasks()
}
const menu = ref(null)
@@ -182,7 +208,8 @@ const menuItems = computed<MenuItem[]>(() => [
{
label: t('delete'),
icon: 'pi pi-trash',
command: () => menuTargetTask.value && removeTask(menuTargetTask.value)
command: () => menuTargetTask.value && removeTask(menuTargetTask.value),
disabled: isExpanded.value || isInFolderView.value
},
{
label: t('loadWorkflow'),
@@ -208,6 +235,16 @@ const handlePreview = (task: TaskItemImpl) => {
)
}
const enterFolderView = (task: TaskItemImpl) => {
folderTask.value = task
updateVisibleTasks()
}
const exitFolderView = () => {
folderTask.value = null
updateVisibleTasks()
}
onMounted(() => {
api.addEventListener('status', onStatus)
queueStore.update()
@@ -225,7 +262,7 @@ watch(
visibleTasks.value.length === 0 ||
visibleTasks.value.length > newTasks.length
) {
visibleTasks.value = newTasks.slice(0, ITEMS_PER_PAGE)
updateVisibleTasks()
}
nextTick(() => {

View File

@@ -13,7 +13,7 @@
:showThumbnails="false"
>
<template #item="{ item }">
<img :src="item.url" alt="gallery item" class="galleria-image" />
<ComfyImage :key="item.url" :src="item.url" class="galleria-image" />
</template>
</Galleria>
</template>
@@ -22,6 +22,7 @@
import { defineProps, ref, watch, onMounted, onUnmounted } from 'vue'
import Galleria from 'primevue/galleria'
import { ResultItemImpl } from '@/stores/queueStore'
import ComfyImage from '@/components/common/ComfyImage.vue'
const galleryVisible = ref(false)
@@ -65,6 +66,7 @@ const handleKeyDown = (event: KeyboardEvent) => {
break
case 'Escape':
galleryVisible.value = false
handleVisibilityChange(false)
break
}
}
@@ -85,12 +87,17 @@ onUnmounted(() => {
})
</script>
<style scoped>
.galleria-image {
max-width: 100%;
max-height: 100%;
<style>
/* PrimeVue's galleria teleports the fullscreen gallery out of subtree so we
cannot use scoped style here. */
img.galleria-image {
max-width: 100vw;
max-height: 100vh;
object-fit: contain;
}
.p-galleria-close-button {
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
z-index: -1;
z-index: 1;
}
</style>

View File

@@ -11,7 +11,7 @@
rounded
/>
</div>
<img :src="result.url" class="task-output-image" />
<ComfyImage :src="result.url" class="task-output-image" />
</template>
<!-- TODO: handle more media types -->
<div v-else class="task-result-preview">
@@ -23,6 +23,7 @@
<script setup lang="ts">
import { ResultItemImpl } from '@/stores/queueStore'
import ComfyImage from '@/components/common/ComfyImage.vue'
import Button from 'primevue/button'
import { onMounted, ref } from 'vue'
@@ -31,7 +32,7 @@ const props = defineProps<{
}>()
const emit = defineEmits<{
(e: 'preview', ResultItemImpl): void
(e: 'preview', result: ResultItemImpl): void
}>()
const resultContainer = ref<HTMLElement | null>(null)
@@ -57,7 +58,7 @@ onMounted(() => {
align-items: center;
}
.task-output-image {
:deep(.task-output-image) {
width: 100%;
height: 100%;
object-fit: cover;

View File

@@ -4,7 +4,7 @@
<template v-if="task.displayStatus === TaskItemDisplayStatus.Completed">
<ResultItem
v-if="flatOutputs.length"
:result="task.previewOutput || flatOutputs[0]"
:result="coverResult"
@preview="handlePreview"
/>
</template>
@@ -26,7 +26,10 @@
</div>
<div class="task-item-details">
<div class="tag-wrapper">
<div class="tag-wrapper status-tag-group">
<Tag v-if="isFlatTask && task.isHistory" class="node-name-tag">
{{ node?.type }} (#{{ node?.id }})
</Tag>
<Tag :severity="taskTagSeverity(task.displayStatus)">
<span v-html="taskStatusText(task.displayStatus)"></span>
<span v-if="task.isHistory" class="task-time">
@@ -38,18 +41,24 @@
</Tag>
</div>
<div class="tag-wrapper">
<Tag v-if="task.isHistory && flatOutputs.length > 1">
<span>{{ flatOutputs.length }}</span>
</Tag>
<Button
v-if="task.isHistory && flatOutputs.length > 1"
outlined
@click="handleOutputLengthClick"
>
<span style="font-weight: bold">{{ flatOutputs.length }}</span>
</Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Tag from 'primevue/tag'
import ResultItem from './ResultItem.vue'
import { TaskItemDisplayStatus, type TaskItemImpl } from '@/stores/queueStore'
import { ComfyNode } from '@/types/comfyWorkflow'
const props = defineProps<{
task: TaskItemImpl
@@ -57,10 +66,20 @@ const props = defineProps<{
}>()
const flatOutputs = props.task.flatOutputs
const coverResult = flatOutputs.length
? props.task.previewOutput || flatOutputs[0]
: null
// Using `==` instead of `===` because NodeId can be a string or a number
const node: ComfyNode | null = flatOutputs.length
? props.task.workflow.nodes.find(
(n: ComfyNode) => n.id == coverResult.nodeId
) ?? null
: null
const emit = defineEmits<{
(e: 'contextmenu', value: { task: TaskItemImpl; event: MouseEvent }): void
(e: 'preview', value: TaskItemImpl): void
(e: 'task-output-length-clicked', value: TaskItemImpl): void
}>()
const handleContextMenu = (e: MouseEvent) => {
@@ -71,6 +90,10 @@ const handlePreview = () => {
emit('preview', props.task)
}
const handleOutputLengthClick = () => {
emit('task-output-length-clicked', props.task)
}
const taskTagSeverity = (status: TaskItemDisplayStatus) => {
switch (status) {
case TaskItemDisplayStatus.Pending:
@@ -139,6 +162,7 @@ const formatTime = (time?: number) => {
padding: 0.6rem;
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
}
@@ -149,4 +173,13 @@ are floating on top of images. */
border-radius: 6px;
display: inline-flex;
}
.node-name-tag {
word-break: break-all;
}
.status-tag-group {
display: flex;
flex-direction: column;
}
</style>

View File

@@ -27,7 +27,12 @@ app.registerExtension({
}
const target = event.composedPath()[0]
if (['INPUT', 'TEXTAREA'].includes(target.tagName)) {
if (
target.tagName === 'TEXTAREA' ||
target.tagName === 'INPUT' ||
(target.tagName === 'SPAN' &&
target.classList.contains('property_value'))
) {
return
}

View File

@@ -24,7 +24,7 @@ app.registerExtension({
app.ui.settings.addSetting({
id: 'Comfy.SnapToGrid.GridSize',
category: ['Comfy', 'Graph', 'GridSize'],
name: 'Snap to gird size',
name: 'Snap to grid size',
type: 'slider',
attrs: {
min: 1,

View File

@@ -2,11 +2,20 @@ import { createI18n } from 'vue-i18n'
const messages = {
en: {
findIssues: 'Find Issues',
copyToClipboard: 'Copy to Clipboard',
openNewIssue: 'Open New Issue',
showReport: 'Show Report',
imageFailedToLoad: 'Image failed to load',
reconnecting: 'Reconnecting',
reconnected: 'Reconnected',
delete: 'Delete',
experimental: 'BETA',
deprecated: 'DEPR',
loadWorkflow: 'Load Workflow',
settings: 'Settings',
searchSettings: 'Search Settings',
searchNodes: 'Search Nodes',
noResultsFound: 'No Results Found',
searchFailedMessage:
"We couldn't find any settings matching your search. Try adjusting your search terms.",
@@ -20,15 +29,21 @@ const messages = {
sortOrder: 'Sort Order'
},
queueTab: {
showFlatList: 'Show Flat List'
showFlatList: 'Show Flat List',
backToAllTasks: 'Back to All Tasks'
}
}
},
zh: {
showReport: '显示报告',
imageFailedToLoad: '图像加载失败',
reconnecting: '重新连接中',
reconnected: '已重新连接',
delete: '删除',
loadWorkflow: '加载工作流',
settings: '设置',
searchSettings: '搜索设置',
searchNodes: '搜索节点',
noResultsFound: '未找到结果',
noTasksFound: '未找到任务',
noTasksFoundMessage: '队列中没有任务。',
@@ -42,7 +57,8 @@ const messages = {
sortOrder: '排序顺序'
},
queueTab: {
showFlatList: '平铺结果'
showFlatList: '平铺结果',
backToAllTasks: '返回'
}
}
}

View File

@@ -1,5 +1,6 @@
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
import {
DownloadModelStatus,
HistoryTaskItem,
PendingTaskItem,
RunningTaskItem,
@@ -216,6 +217,11 @@ class ComfyApi extends EventTarget {
new CustomEvent('execution_cached', { detail: msg.data })
)
break
case 'download_progress':
this.dispatchEvent(
new CustomEvent('download_progress', { detail: msg.data })
)
break
default:
if (this.#registered.has(msg.type)) {
this.dispatchEvent(
@@ -319,6 +325,47 @@ class ComfyApi extends EventTarget {
return await res.json()
}
/**
* Gets a list of models in the specified folder
* @param {string} folder The folder to list models from, such as 'checkpoints'
* @returns The list of model filenames within the specified folder
*/
async getModels(folder: string) {
const res = await this.fetchApi(`/models/${folder}`)
if (res.status === 404) {
return null
}
return await res.json()
}
/**
* Tells the server to download a model from the specified URL to the specified directory and filename
* @param {string} url The URL to download the model from
* @param {string} model_directory The main directory (eg 'checkpoints') to save the model to
* @param {string} model_filename The filename to save the model as
* @param {number} progress_interval The interval in seconds at which to report download progress (via 'download_progress' event)
*/
async internalDownloadModel(
url: string,
model_directory: string,
model_filename: string,
progress_interval: number
): Promise<DownloadModelStatus> {
const res = await this.fetchApi('/internal/models/download', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
url,
model_directory,
model_filename,
progress_interval
})
})
return await res.json()
}
/**
* Loads a list of items (queue or history)
* @param {string} type The type of items to load, queue or history
@@ -541,11 +588,7 @@ class ComfyApi extends EventTarget {
const resp = await this.fetchApi(`/userdata/${encodeURIComponent(file)}`, {
method: 'DELETE'
})
if (resp.status !== 204) {
throw new Error(
`Error removing user data file '${file}': ${resp.status} ${resp.statusText}`
)
}
return resp
}
/**
@@ -585,13 +628,13 @@ class ComfyApi extends EventTarget {
*/
async listUserData(
dir: string,
recurse: true,
split?: boolean
recurse: boolean,
split?: true
): Promise<string[][]>
async listUserData(
dir: string,
recurse: false,
split?: boolean
recurse: boolean,
split?: false
): Promise<string[]>
async listUserData(dir, recurse, split) {
const resp = await this.fetchApi(

View File

@@ -43,10 +43,15 @@ import {
} from '@/stores/nodeDefStore'
import { Vector2 } from '@comfyorg/litegraph'
import _ from 'lodash'
import { showLoadWorkflowWarning } from '@/services/dialogService'
import {
showExecutionErrorDialog,
showLoadWorkflowWarning,
showMissingModelsWarning
} from '@/services/dialogService'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
export const ANIM_PREVIEW_WIDGET = '$$comfy_animation_preview'
@@ -130,6 +135,7 @@ export class ComfyApp {
bodyBottom: HTMLElement
canvasContainer: HTMLElement
menu: ComfyAppMenu
modelsInFolderCache: Record<string, string[]>
constructor() {
this.vueAppReady = false
@@ -144,6 +150,7 @@ export class ComfyApp {
parent: document.body
})
this.menu = new ComfyAppMenu(this)
this.modelsInFolderCache = {}
/**
* List of extensions that are registered with the app
@@ -1245,8 +1252,8 @@ export class ComfyApp {
let scale = startScale - deltaY / 100
this.ds.changeScale(scale, [
this.ds.element.width / 2,
this.ds.element.height / 2
self.zoom_drag_start[0],
self.zoom_drag_start[1]
])
this.graph.change()
@@ -1298,7 +1305,7 @@ export class ComfyApp {
if (e.type == 'keydown' && !e.repeat) {
// Ctrl + M mute/unmute
if (e.key === 'm' && e.ctrlKey) {
if (e.key === 'm' && (e.metaKey || e.ctrlKey)) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 2) {
@@ -1313,7 +1320,7 @@ export class ComfyApp {
}
// Ctrl + B bypass
if (e.key === 'b' && e.ctrlKey) {
if (e.key === 'b' && (e.metaKey || e.ctrlKey)) {
if (this.selected_nodes) {
for (var i in this.selected_nodes) {
if (this.selected_nodes[i].mode === 4) {
@@ -1598,14 +1605,6 @@ export class ComfyApp {
}
)
api.addEventListener('reconnecting', () => {
this.ui.dialog.show('Reconnecting...')
})
api.addEventListener('reconnected', () => {
this.ui.dialog.close()
})
api.addEventListener('progress', ({ detail }) => {
if (
this.workflowManager.activePrompt?.workflow &&
@@ -1673,8 +1672,7 @@ export class ComfyApp {
api.addEventListener('execution_error', ({ detail }) => {
this.lastExecutionError = detail
const formattedError = this.#formatExecutionError(detail)
this.ui.dialog.show(formattedError)
showExecutionErrorDialog(detail)
this.canvas.draw(true, true)
})
@@ -1800,6 +1798,9 @@ export class ComfyApp {
let user = localStorage['Comfy.userId']
const users = userConfig.users ?? {}
if (!user || !users[user]) {
// Lift spinner / BlockUI for user selection.
if (this.vueAppReady) useWorkspaceStore().spinner = false
// This will rarely be hit so move the loading to on demand
const { UserSelectionScreen } = await import('./ui/userSelection')
@@ -2161,18 +2162,38 @@ export class ComfyApp {
}
showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
if (this.vueAppReady)
if (
this.vueAppReady &&
useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')
) {
showLoadWorkflowWarning({
missingNodeTypes,
hasAddedNodes,
maximizable: true
})
}
this.logging.addEntry('Comfy.App', 'warn', {
MissingNodes: missingNodeTypes
})
}
showMissingModelsError(missingModels) {
if (
this.vueAppReady &&
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
) {
showMissingModelsWarning({
missingModels,
maximizable: true
})
}
this.logging.addEntry('Comfy.App', 'warn', {
MissingModels: missingModels
})
}
async changeWorkflow(callback, workflow = null) {
try {
this.workflowManager.activeWorkflow?.changeTracker?.store()
@@ -2231,10 +2252,12 @@ export class ComfyApp {
}
const missingNodeTypes = []
const missingModels = []
await this.#invokeExtensionsAsync(
'beforeConfigureGraph',
graphData,
missingNodeTypes
// TODO: missingModels
)
for (let n of graphData.nodes) {
// Patch T2IAdapterLoader to ControlNetLoader since they are the same node now
@@ -2249,6 +2272,19 @@ export class ComfyApp {
n.type = sanitizeNodeName(n.type)
}
}
if (graphData.models && useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')) {
for (let m of graphData.models) {
const models_available = await this.getModelsInFolderCached(m.directory)
if (models_available === null) {
// @ts-expect-error
m.directory_invalid = true
missingModels.push(m)
}
else if (!models_available.includes(m.name)) {
missingModels.push(m)
}
}
}
try {
this.graph.configure(graphData)
@@ -2364,9 +2400,13 @@ export class ComfyApp {
this.#invokeExtensions('loadedGraphNode', node)
}
// TODO: Properly handle if both nodes and models are missing (sequential dialogs?)
if (missingNodeTypes.length) {
this.showMissingNodesError(missingNodeTypes)
}
if (missingModels.length) {
this.showMissingModelsError(missingModels)
}
await this.#invokeExtensionsAsync('afterConfigureGraph', missingNodeTypes)
requestAnimationFrame(() => {
this.graph.setDirtyCanvas(true, true)
@@ -2552,18 +2592,6 @@ export class ComfyApp {
return '(unknown error)'
}
#formatExecutionError(error) {
if (error == null) {
return '(unknown error)'
}
const traceback = error.traceback.join('')
const nodeId = error.node_id
const nodeType = error.node_type
return `Error occurred when executing ${nodeType}:\n\n${error.exception_message}\n\n${traceback}`
}
async queuePrompt(number, batchCount = 1) {
this.#queueItems.push({ number, batchCount })
@@ -2847,6 +2875,19 @@ export class ComfyApp {
app.graph.arrange()
}
/**
* Gets the list of model names in a folder, using a temporary local cache
*/
async getModelsInFolderCached(folder: string): Promise<string[]> {
if (folder in this.modelsInFolderCache) {
return this.modelsInFolderCache[folder]
}
// TODO: needs a lock to avoid overlapping calls
const models = await api.getModels(folder)
this.modelsInFolderCache[folder] = models
return models
}
/**
* Registers a Comfy web extension with the app
* @param {ComfyExtension} extension
@@ -2872,6 +2913,8 @@ export class ComfyApp {
}
if (this.vueAppReady) useToastStore().add(requestToastMessage)
this.modelsInFolderCache = {}
const defs = await api.getNodeDefs()
for (const nodeId in defs) {
@@ -2959,6 +3002,12 @@ export class ComfyApp {
([p, o1, o2]) => (p - o2) / this.canvas.ds.scale - o1
) as Vector2
}
getCanvasCenter(): Vector2 {
const dpi = Math.max(window.devicePixelRatio ?? 1, 1)
const [x, y, w, h] = app.canvas.ds.visible_area
return [x + w / dpi / 2, y + h / dpi / 2]
}
}
export const app = new ComfyApp()

View File

@@ -133,14 +133,5 @@ export const defaultGraph: ComfyWorkflowJSON = {
groups: [],
config: {},
extra: {},
version: 0.4,
models: [
{
name: 'v1-5-pruned-emaonly.ckpt',
url: 'https://huggingface.co/runwayml/stable-diffusion-v1-5/resolve/main/v1-5-pruned-emaonly.ckpt',
hash: 'cc6cb27103417325ff94f52b7a5d2dde45a7515b25c255d8e396c90014281516',
hash_type: 'SHA256',
directory: 'checkpoints'
}
]
version: 0.4
}

View File

@@ -227,9 +227,14 @@ LGraphCanvas.prototype.computeVisibleNodes = function (): LGraphNode[] {
const hidden = visibleNodes.indexOf(node) === -1
for (const w of node.widgets) {
if (w.element) {
w.element.hidden = hidden
w.element.style.display = hidden ? 'none' : undefined
if (hidden) {
w.element.dataset.isInVisibleNodes = hidden ? 'false' : 'true'
const shouldOtherwiseHide = w.element.dataset.shouldHide === 'true'
const isCollapsed = w.element.dataset.collapsed === 'true'
const wasHidden = w.element.hidden
const actualHidden = hidden || shouldOtherwiseHide || isCollapsed
w.element.hidden = actualHidden
w.element.style.display = actualHidden ? 'none' : null
if (actualHidden && !wasHidden) {
w.options.onHide?.(w)
}
}
@@ -309,15 +314,21 @@ LGraphNode.prototype.addDOMWidget = function (
}
const hidden =
node.flags?.collapsed ||
(!!options.hideOnZoom && app.canvas.ds.scale < 0.5) ||
widget.computedHeight <= 0 ||
widget.type === 'converted-widget' ||
widget.type === 'hidden'
element.hidden = hidden
element.style.display = hidden ? 'none' : null
if (hidden) {
element.dataset.shouldHide = hidden ? 'true' : 'false'
const isInVisibleNodes = element.dataset.isInVisibleNodes === 'true'
const isCollapsed = element.dataset.collapsed === 'true'
const actualHidden = hidden || !isInVisibleNodes || isCollapsed
const wasHidden = element.hidden
element.hidden = actualHidden
element.style.display = actualHidden ? 'none' : null
if (actualHidden && !wasHidden) {
widget.options.onHide?.(widget)
}
if (actualHidden) {
return
}
@@ -379,6 +390,7 @@ LGraphNode.prototype.addDOMWidget = function (
element.hidden = true
element.style.display = 'none'
}
element.dataset.collapsed = this.flags?.collapsed ? 'true' : 'false'
}
const onRemoved = this.onRemoved

View File

@@ -399,19 +399,21 @@ export class ComfyWorkflow {
async delete() {
// TODO: fix delete of current workflow - should mark workflow as unsaved and when saving use old name by default
try {
if (this.isFavorite) {
await this.favorite(false)
}
await api.deleteUserData('workflows/' + this.path)
this.unsaved = true
this.#path = null
this.#pathParts = null
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1)
this.manager.dispatchEvent(new CustomEvent('delete', { detail: this }))
} catch (error) {
alert(`Error deleting workflow: ${error.message || error}`)
if (this.isFavorite) {
await this.favorite(false)
}
const resp = await api.deleteUserData('workflows/' + this.path)
if (resp.status !== 204) {
alert(
`Error removing user data file '${this.path}': ${resp.status} ${resp.statusText}`
)
}
this.unsaved = true
this.#path = null
this.#pathParts = null
this.manager.workflows.splice(this.manager.workflows.indexOf(this), 1)
this.manager.dispatchEvent(new CustomEvent('delete', { detail: this }))
}
track() {

View File

@@ -3,8 +3,11 @@
// about importing primevue components.
import { useDialogStore } from '@/stores/dialogStore'
import LoadWorkflowWarning from '@/components/dialog/content/LoadWorkflowWarning.vue'
import MissingModelsWarning from '@/components/dialog/content/MissingModelsWarning.vue'
import SettingDialogContent from '@/components/dialog/content/SettingDialogContent.vue'
import SettingDialogHeader from '@/components/dialog/header/SettingDialogHeader.vue'
import type { ExecutionErrorWsMessage } from '@/types/apiTypes'
import ExecutionErrorDialogContent from '@/components/dialog/content/ExecutionErrorDialogContent.vue'
export function showLoadWorkflowWarning(props: {
missingNodeTypes: any[]
@@ -18,9 +21,29 @@ export function showLoadWorkflowWarning(props: {
})
}
export function showMissingModelsWarning(props: {
missingModels: any[]
[key: string]: any
}) {
const dialogStore = useDialogStore()
dialogStore.showDialog({
component: MissingModelsWarning,
props
})
}
export function showSettingsDialog() {
useDialogStore().showDialog({
headerComponent: SettingDialogHeader,
component: SettingDialogContent
})
}
export function showExecutionErrorDialog(error: ExecutionErrorWsMessage) {
useDialogStore().showDialog({
component: ExecutionErrorDialogContent,
props: {
error
}
})
}

View File

@@ -121,7 +121,14 @@ export class NodeSearchService {
includeScore: true,
threshold: 0.3,
shouldSort: true,
useExtendedSearch: true
useExtendedSearch: true,
// Sort by score, then by length of the display name, then by index
// Source: https://github.com/Comfy-Org/ComfyUI_frontend/issues/562#issuecomment-2303239027
sortFn: (a, b) =>
Math.min(a.score, b.score) < 0.0001 ||
Math.abs(a.score - b.score) > 0.01
? a.score - b.score
: a.item[1]['v']['length'] - b.item[1]['v']['length'] || a.idx - b.idx
})
const filterSearchOptions = {

View File

@@ -1,7 +1,7 @@
import { NodeSearchService } from '@/services/nodeSearchService'
import { ComfyNodeDef } from '@/types/apiTypes'
import { defineStore } from 'pinia'
import { Type, Transform, plainToClass } from 'class-transformer'
import { Type, Transform, plainToClass, Expose } from 'class-transformer'
import { ComfyWidgetConstructor } from '@/scripts/widgets'
import { TreeNode } from 'primevue/treenode'
import { buildTree } from '@/utils/treeUtil'
@@ -166,6 +166,23 @@ export class ComfyNodeDefImpl {
python_module: string
description: string
@Transform(({ value, obj }) => value ?? obj.category === '', {
toClassOnly: true
})
@Type(() => Boolean)
@Expose()
deprecated: boolean
@Transform(
({ value, obj }) => value ?? obj.category.startsWith('_for_testing'),
{
toClassOnly: true
}
)
@Type(() => Boolean)
@Expose()
experimental: boolean
@Type(() => ComfyInputsSpec)
input: ComfyInputsSpec
@@ -226,52 +243,40 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDef> = {
}
}
function sortedTree(node: TreeNode): TreeNode {
// Create a new node with the same label and data
const newNode: TreeNode = {
...node
}
if (node.children) {
// Sort the children of the current node
const sortedChildren = [...node.children].sort((a, b) =>
a.label.localeCompare(b.label)
)
// Recursively sort the children and add them to the new node
newNode.children = []
for (const child of sortedChildren) {
newNode.children.push(sortedTree(child))
}
}
return newNode
}
interface State {
nodeDefsByName: Record<string, ComfyNodeDefImpl>
widgets: Record<string, ComfyWidgetConstructor>
showDeprecated: boolean
showExperimental: boolean
}
export const useNodeDefStore = defineStore('nodeDef', {
state: (): State => ({
nodeDefsByName: {},
widgets: {}
widgets: {},
showDeprecated: false,
showExperimental: false
}),
getters: {
nodeDefs(state) {
return Object.values(state.nodeDefsByName)
},
nodeSearchService(state) {
return new NodeSearchService(Object.values(state.nodeDefsByName))
// Node defs that are not deprecated
visibleNodeDefs(state) {
return this.nodeDefs.filter(
(nodeDef: ComfyNodeDefImpl) =>
(state.showDeprecated || !nodeDef.deprecated) &&
(state.showExperimental || !nodeDef.experimental)
)
},
nodeSearchService() {
return new NodeSearchService(this.visibleNodeDefs)
},
nodeTree(): TreeNode {
return buildTree(this.nodeDefs, (nodeDef: ComfyNodeDefImpl) => [
return buildTree(this.visibleNodeDefs, (nodeDef: ComfyNodeDefImpl) => [
...nodeDef.category.split('/'),
nodeDef.display_name
])
},
sortedNodeTree(): TreeNode {
return sortedTree(this.nodeTree)
}
},
actions: {

View File

@@ -10,7 +10,7 @@ import type {
ResultItem
} from '@/types/apiTypes'
import type { NodeId } from '@/types/comfyWorkflow'
import { instanceToPlain, plainToClass } from 'class-transformer'
import { plainToClass } from 'class-transformer'
import _ from 'lodash'
import { defineStore } from 'pinia'
import { toRaw } from 'vue'
@@ -70,7 +70,7 @@ export class TaskItemImpl {
this.flatOutputs = flatOutputs ?? this.calculateFlatOutputs()
}
private calculateFlatOutputs(): ReadonlyArray<ResultItemImpl> {
calculateFlatOutputs(): ReadonlyArray<ResultItemImpl> {
if (!this.outputs) {
return []
}
@@ -88,7 +88,12 @@ export class TaskItemImpl {
}
get previewOutput(): ResultItemImpl | undefined {
return this.flatOutputs.find((output) => output.supportsPreview)
return (
this.flatOutputs.find(
// Prefer saved media files over the temp previews
(output) => output.type === 'output' && output.supportsPreview
) ?? this.flatOutputs.find((output) => output.supportsPreview)
)
}
get apiTaskType(): APITaskType {
@@ -214,6 +219,33 @@ export class TaskItemImpl {
app.nodeOutputs = toRaw(this.outputs)
}
}
public flatten(): TaskItemImpl[] {
if (this.displayStatus !== TaskItemDisplayStatus.Completed) {
return [this]
}
return this.flatOutputs.map(
(output: ResultItemImpl, i: number) =>
new TaskItemImpl(
this.taskType,
[
this.queueIndex,
`${this.promptId}-${i}`,
this.promptInputs,
this.extraData,
this.outputsToExecute
],
this.status,
{
[output.nodeId]: {
[output.mediaType]: [output]
}
},
[output]
)
)
}
}
interface State {
@@ -239,32 +271,7 @@ export const useQueueStore = defineStore('queue', {
]
},
flatTasks(): TaskItemImpl[] {
return this.tasks.flatMap((task: TaskItemImpl) => {
if (task.displayStatus !== TaskItemDisplayStatus.Completed) {
return [task]
}
return task.flatOutputs.map(
(output: ResultItemImpl, i: number) =>
new TaskItemImpl(
task.taskType,
[
task.queueIndex,
`${task.promptId}-${i}`,
task.promptInputs,
task.extraData,
task.outputsToExecute
],
task.status,
{
[output.nodeId]: {
[output.mediaType]: [output]
}
},
[output]
)
)
})
return this.tasks.flatMap((task: TaskItemImpl) => task.flatten())
},
lastHistoryQueueIndex(state) {
return state.historyTasks.length ? state.historyTasks[0].queueIndex : -1

View File

@@ -95,6 +95,15 @@ export const useSettingStore = defineStore('setting', {
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.NodeSearchBoxImpl.ShowCategory',
category: ['Comfy', 'Node Search Box', 'ShowCategory'],
name: 'Show node category in search results',
tooltip: 'Only applies to the default implementation',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Sidebar.Location',
category: ['Comfy', 'Sidebar', 'Location'],
@@ -131,6 +140,57 @@ export const useSettingStore = defineStore('setting', {
type: 'boolean',
defaultValue: false
})
app.ui.settings.addSetting({
id: 'Comfy.Graph.CanvasInfo',
name: 'Show canvas info (fps, etc.)',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Node.ShowDeprecated',
name: 'Show deprecated nodes in search',
tooltip:
'Deprecated nodes are hidden by default in the UI, but remain functional in existing workflows that use them.',
type: 'boolean',
defaultValue: false
})
app.ui.settings.addSetting({
id: 'Comfy.Node.ShowExperimental',
name: 'Show experimental nodes in search',
tooltip:
'Experimental nodes are marked as such in the UI and may be subject to significant changes or removal in future versions. Use with caution in production workflows',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Workflow.ShowMissingNodesWarning',
name: 'Show missing nodes warning',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Workflow.ShowMissingModelsWarning',
name: 'Show missing models warning',
type: 'boolean',
defaultValue: true
})
app.ui.settings.addSetting({
id: 'Comfy.Graph.ZoomSpeed',
name: 'Canvas zoom speed',
type: 'slider',
defaultValue: 1.1,
attrs: {
min: 1.01,
max: 2.5,
step: 0.01
}
})
},
set<K extends keyof Settings>(key: K, value: Settings[K]) {

View File

@@ -68,11 +68,18 @@ const zExecutionErrorWsMessage = zExecutionWsMessageBase.extend({
executed: z.array(zNodeId),
exception_message: z.string(),
exception_type: z.string(),
traceback: z.string(),
traceback: z.array(z.string()),
current_inputs: z.any(),
current_outputs: z.any()
})
const zDownloadModelStatus = z.object({
status: z.string(),
progress_percentage: z.number(),
message: z.string(),
already_existed: z.boolean(),
})
export type StatusWsMessageStatus = z.infer<typeof zStatusWsMessageStatus>
export type StatusWsMessage = z.infer<typeof zStatusWsMessage>
export type ProgressWsMessage = z.infer<typeof zProgressWsMessage>
@@ -87,6 +94,8 @@ export type ExecutionInterruptedWsMessage = z.infer<
typeof zExecutionInterruptedWsMessage
>
export type ExecutionErrorWsMessage = z.infer<typeof zExecutionErrorWsMessage>
export type DownloadModelStatus = z.infer<typeof zDownloadModelStatus>
// End of ws messages
const zPromptInputItem = z.object({
@@ -335,7 +344,9 @@ const zComfyNodeDef = z.object({
description: z.string(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string()
python_module: z.string(),
deprecated: z.boolean().optional(),
experimental: z.boolean().optional()
})
// `/object_info`
@@ -400,6 +411,8 @@ const zSettings = z.record(z.any()).and(
'Comfy.CustomColorPalettes': colorPalettesSchema,
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),
'Comfy.Workflow.ShowMissingNodesWarning': z.boolean(),
'Comfy.Workflow.ShowMissingModelsWarning': z.boolean(),
'Comfy.DisableFloatRounding': z.boolean(),
'Comfy.DisableSliders': z.boolean(),
'Comfy.DOMClippingEnabled': z.boolean(),
@@ -407,6 +420,7 @@ const zSettings = z.record(z.any()).and(
'Comfy.EnableTooltips': z.boolean(),
'Comfy.EnableWorkflowViewRestore': z.boolean(),
'Comfy.FloatRoundingPrecision': z.number(),
'Comfy.Graph.ZoomSpeed': z.number(),
'Comfy.InvertMenuScrolling': z.boolean(),
'Comfy.Logging.Enabled': z.boolean(),
'Comfy.NodeInputConversionSubmenus': z.boolean(),
@@ -417,7 +431,10 @@ const zSettings = z.record(z.any()).and(
]),
'Comfy.NodeSearchBoxImpl.NodePreview': z.boolean(),
'Comfy.NodeSearchBoxImpl': z.enum(['default', 'simple']),
'Comfy.NodeSearchBoxImpl.ShowCategory': z.boolean(),
'Comfy.NodeSuggestions.number': z.number(),
'Comfy.Node.ShowDeprecated': z.boolean(),
'Comfy.Node.ShowExperimental': z.boolean(),
'Comfy.PreviewFormat': z.string(),
'Comfy.PromptFilename': z.boolean(),
'Comfy.Sidebar.Location': z.enum(['left', 'right']),

View File

@@ -1,5 +1,5 @@
import { LGraphNode, IWidget } from './litegraph'
import { ComfyApp } from '../../scripts/app'
import { ComfyApp } from '../scripts/app'
export interface ComfyExtension {
/**

View File

@@ -47,3 +47,24 @@ export function flattenTree<T>(tree: TreeNode): T[] {
}
return result
}
export function sortedTree(node: TreeNode): TreeNode {
// Create a new node with the same label and data
const newNode: TreeNode = {
...node
}
if (node.children) {
// Sort the children of the current node
const sortedChildren = [...node.children].sort((a, b) =>
a.label.localeCompare(b.label)
)
// Recursively sort the children and add them to the new node
newNode.children = []
for (const child of sortedChildren) {
newNode.children.push(sortedTree(child))
}
}
return newNode
}

View File

@@ -15,7 +15,8 @@ module.exports = async function () {
jest.mock('@/services/dialogService', () => {
return {
showLoadWorkflowWarning: jest.fn()
showLoadWorkflowWarning: jest.fn(),
showMissingModelsWarning: jest.fn()
}
})
}

View File

@@ -16,7 +16,9 @@ const EXAMPLE_NODE_DEF: ComfyNodeDef = {
description: '',
python_module: 'nodes',
category: 'loaders',
output_node: false
output_node: false,
experimental: false,
deprecated: false
}
describe('validateNodeDef', () => {

View File

@@ -194,6 +194,52 @@ describe('ComfyNodeDefImpl', () => {
is_list: false
}
])
expect(result.deprecated).toBe(false)
})
it('should transform a deprecated basic node definition', () => {
const plainObject = {
name: 'TestNode',
display_name: 'Test Node',
category: 'Testing',
python_module: 'test_module',
description: 'A test node',
input: {
required: {
intInput: ['INT', { min: 0, max: 100, default: 50 }]
}
},
output: ['INT'],
output_is_list: [false],
output_name: ['intOutput'],
deprecated: true
}
const result = plainToClass(ComfyNodeDefImpl, plainObject)
expect(result.deprecated).toBe(true)
})
// Legacy way of marking a node as deprecated
it('should mark deprecated with empty category', () => {
const plainObject = {
name: 'TestNode',
display_name: 'Test Node',
// Empty category should be treated as deprecated
category: '',
python_module: 'test_module',
description: 'A test node',
input: {
required: {
intInput: ['INT', { min: 0, max: 100, default: 50 }]
}
},
output: ['INT'],
output_is_list: [false],
output_name: ['intOutput']
}
const result = plainToClass(ComfyNodeDefImpl, plainObject)
expect(result.deprecated).toBe(true)
})
it('should handle multiple outputs including COMBO type', () => {

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