Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d232e38c33 | ||
|
|
1a3cf4c3f3 | ||
|
|
31d172d4d9 | ||
|
|
92ce064ebf | ||
|
|
3d041dd742 | ||
|
|
af378262f4 | ||
|
|
57c5a78af3 | ||
|
|
233fd1347e | ||
|
|
60221254d9 | ||
|
|
7434691bed | ||
|
|
fbdc9d430b | ||
|
|
3e457f812d | ||
|
|
0466c79725 | ||
|
|
8b989c6415 | ||
|
|
2e51122778 | ||
|
|
8f8eac038a | ||
|
|
4b3bad4bc5 | ||
|
|
17c7f57d8f | ||
|
|
5542845710 | ||
|
|
f2de9b0d3c | ||
|
|
71fa71e82c | ||
|
|
77ba201367 | ||
|
|
6edfc9bc1b | ||
|
|
743dc4879a | ||
|
|
2c1bd662e1 | ||
|
|
7051d86ba7 | ||
|
|
7487c565c8 | ||
|
|
a86d10b02d | ||
|
|
3d89c245e5 | ||
|
|
9dd6da3dc2 | ||
|
|
269e468425 | ||
|
|
c3ef716d53 | ||
|
|
bd7bbd9e95 | ||
|
|
447d1c95ef | ||
|
|
c4bc0e8430 | ||
|
|
f3ab9cfb8e | ||
|
|
52c8c8194e | ||
|
|
9dbc114ae9 | ||
|
|
556edea299 | ||
|
|
d5584a1d39 | ||
|
|
628f2afc34 | ||
|
|
ea01fde607 | ||
|
|
b8a3e6b1ad | ||
|
|
cfad3cd918 | ||
|
|
339e201920 | ||
|
|
c227a8af9a | ||
|
|
6b47162606 | ||
|
|
a4c5a2a3d1 | ||
|
|
45a47be7c0 | ||
|
|
2252f0a134 | ||
|
|
0dfbcfb2d6 | ||
|
|
b46036f25d | ||
|
|
f5ce42d5d5 | ||
|
|
ce75a29202 | ||
|
|
6a8a68a240 | ||
|
|
f9adaadc7d | ||
|
|
727992048e | ||
|
|
dd1e3f087d |
5
.github/workflows/test-ui.yaml
vendored
@@ -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
@@ -34,6 +34,7 @@ tests-ui/workflows/examples
|
||||
/playwright-report/
|
||||
/blob-report/
|
||||
/playwright/.cache/
|
||||
browser_tests/*/*-win32.png
|
||||
|
||||
.env
|
||||
|
||||
|
||||
69
README.md
@@ -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
|
||||
|
||||

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

|
||||
|
||||
## 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.
|
||||
82
browser_tests/assets/execution_error.json
Normal 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
|
||||
}
|
||||
61
browser_tests/assets/missing_nodes.json
Normal 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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 95 KiB |
27
browser_tests/dialog.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 98 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
Before Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 35 KiB |
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 91 KiB |
24
browser_tests/propertiesPanel.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
Before Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 113 KiB |
|
Before Width: | Height: | Size: 129 KiB |
|
Before Width: | Height: | Size: 102 KiB |
|
Before Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 104 KiB |
|
Before Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 91 KiB |
|
Before Width: | Height: | Size: 93 KiB |
58
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
4
public/materialdesignicons.min.css
vendored
24
src/App.vue
@@ -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>
|
||||
|
||||
|
||||
58
src/components/common/ComfyImage.vue
Normal 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>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
184
src/components/dialog/content/ExecutionErrorDialogContent.vue
Normal 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>
|
||||
262
src/components/dialog/content/MissingModelsWarning.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
55
src/components/dialog/content/error/FindIssueButton.vue
Normal 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>
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
20
src/i18n.ts
@@ -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: '返回'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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']),
|
||||
|
||||
2
src/types/comfy.d.ts
vendored
@@ -1,5 +1,5 @@
|
||||
import { LGraphNode, IWidget } from './litegraph'
|
||||
import { ComfyApp } from '../../scripts/app'
|
||||
import { ComfyApp } from '../scripts/app'
|
||||
|
||||
export interface ComfyExtension {
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ module.exports = async function () {
|
||||
|
||||
jest.mock('@/services/dialogService', () => {
|
||||
return {
|
||||
showLoadWorkflowWarning: jest.fn()
|
||||
showLoadWorkflowWarning: jest.fn(),
|
||||
showMissingModelsWarning: jest.fn()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||