Compare commits
3 Commits
v1.3.28
...
feature/sh
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76cf77b901 | ||
|
|
6b2cbacc09 | ||
|
|
186ff3a78e |
6
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -50,12 +50,6 @@ body:
|
||||
description: 'Please copy the output from your browser logs here. You can access this by pressing F12 to toggle the developer tools, then navigating to the Console tab.'
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Setting JSON
|
||||
description: 'Please upload the setting file here. The setting file is located at `user/default/comfy.settings.json`'
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
id: browsers
|
||||
attributes:
|
||||
|
||||
@@ -1,5 +1 @@
|
||||
if [[ "$OS" == "Windows_NT" ]]; then
|
||||
npx.cmd lint-staged
|
||||
else
|
||||
npx lint-staged
|
||||
fi
|
||||
npx lint-staged
|
||||
|
||||
92
README.md
@@ -193,76 +193,7 @@ https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
|
||||
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
|
||||
</details>
|
||||
|
||||
### Developer APIs
|
||||
|
||||
<details>
|
||||
<summary>v1.3.22: Register bottom panel tabs</summary>
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension',
|
||||
bottomPanelTabs: [
|
||||
{
|
||||
id: 'TestTab',
|
||||
title: 'Test Tab',
|
||||
type: 'custom',
|
||||
render: (el) => {
|
||||
el.innerHTML = '<div>Custom tab</div>'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.3.22: New settings API</summary>
|
||||
|
||||
Legacy settings API.
|
||||
|
||||
```js
|
||||
// Register a new setting
|
||||
app.ui.settings.addSetting({
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'Hello, world!'
|
||||
})
|
||||
|
||||
// Get the value of a setting
|
||||
const value = app.ui.settings.getSettingValue('TestSetting')
|
||||
|
||||
// Set the value of a setting
|
||||
app.ui.settings.setSettingValue('TestSetting', 'Hello, universe!')
|
||||
```
|
||||
|
||||
New settings API.
|
||||
|
||||
```js
|
||||
// Register a new setting
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
settings: [
|
||||
{
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'Hello, world!'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Get the value of a setting
|
||||
const value = app.extensionManager.setting.get('TestSetting')
|
||||
|
||||
// Set the value of a setting
|
||||
app.extensionManager.setting.set('TestSetting', 'Hello, universe!')
|
||||
```
|
||||
|
||||
</details>
|
||||
### Node developers API
|
||||
|
||||
<details>
|
||||
<summary>v1.3.7: Register commands and keybindings</summary>
|
||||
@@ -367,14 +298,6 @@ We will support custom icons later.
|
||||
|
||||
## Development
|
||||
|
||||
### Tech Stack
|
||||
|
||||
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
|
||||
- [Pinia](https://pinia.vuejs.org/) for state management
|
||||
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
|
||||
- [Litegraph](https://github.com/Comfy-Org/litegraph.js) for node editor
|
||||
- [zod](https://zod.dev/) for schema validation
|
||||
|
||||
### Git pre-commit hooks
|
||||
|
||||
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
|
||||
@@ -389,7 +312,7 @@ core extensions will be loaded.
|
||||
- Start local ComfyUI backend at `localhost:8188`
|
||||
- Run `npm run dev` to start the dev server
|
||||
|
||||
### Unit Test
|
||||
### Test
|
||||
|
||||
- `git clone https://github.com/comfyanonymous/ComfyUI_examples.git` to `tests-ui/ComfyUI_examples` or the EXAMPLE_REPO_PATH location specified in .env
|
||||
- `npm i` to install all dependencies
|
||||
@@ -397,16 +320,6 @@ core extensions will be loaded.
|
||||
- `npm run test:generate:examples` to extract the example workflows
|
||||
- `npm run test` to execute all unit tests.
|
||||
|
||||
### Component Test
|
||||
|
||||
Component test verifies Vue components in `src/components/`.
|
||||
|
||||
- `npm run test:component` to execute all component tests.
|
||||
|
||||
### Playwright Test
|
||||
|
||||
Playwright test verifies the whole app. See <https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/browser_tests/README.md> for details.
|
||||
|
||||
### LiteGraph
|
||||
|
||||
This repo is using litegraph package hosted on <https://github.com/Comfy-Org/litegraph.js>. Any changes to litegraph should be submitted in that repo instead.
|
||||
@@ -414,6 +327,7 @@ This repo is using litegraph package hosted on <https://github.com/Comfy-Org/lit
|
||||
### Test litegraph changes
|
||||
|
||||
- Run `npm link` in the local litegraph repo.
|
||||
- Run `npm uninstall @comfyorg/litegraph` in this repo.
|
||||
- Run `npm link @comfyorg/litegraph` in this repo.
|
||||
|
||||
This will replace the litegraph package in this repo with the local litegraph repo.
|
||||
|
||||
@@ -1,22 +1,287 @@
|
||||
import type { Page, Locator, APIRequestContext } from '@playwright/test'
|
||||
import { expect } from '@playwright/test'
|
||||
import { test as base } from '@playwright/test'
|
||||
import { ComfyActionbar } from '../helpers/actionbar'
|
||||
import { ComfyActionbar } from './helpers/actionbar'
|
||||
import dotenv from 'dotenv'
|
||||
dotenv.config()
|
||||
import * as fs from 'fs'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import type { NodeId } from '../../src/types/comfyWorkflow'
|
||||
import type { KeyCombo } from '../../src/types/keyBindingTypes'
|
||||
import { ComfyTemplates } from '../helpers/templates'
|
||||
import { ComfyNodeSearchBox } from './components/ComfyNodeSearchBox'
|
||||
import {
|
||||
NodeLibrarySidebarTab,
|
||||
WorkflowsSidebarTab
|
||||
} from './components/SidebarTab'
|
||||
import { Topbar } from './components/Topbar'
|
||||
import { NodeReference } from './utils/litegraphUtils'
|
||||
import type { Position, Size } from './types'
|
||||
import { NodeBadgeMode } from '../src/types/nodeSource'
|
||||
import type { NodeId } from '../src/types/comfyWorkflow'
|
||||
import type { KeyCombo } from '../src/types/keyBindingTypes'
|
||||
import { ManageGroupNode } from './helpers/manageGroupNode'
|
||||
import { ComfyTemplates } from './helpers/templates'
|
||||
|
||||
interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
class ComfyNodeSearchFilterSelectionPanel {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async selectFilterType(filterType: string) {
|
||||
await this.page
|
||||
.locator(
|
||||
`.filter-type-select .p-togglebutton-label:has-text("${filterType}")`
|
||||
)
|
||||
.click()
|
||||
}
|
||||
|
||||
async selectFilterValue(filterValue: string) {
|
||||
await this.page.locator('.filter-value-select .p-select-dropdown').click()
|
||||
await this.page
|
||||
.locator(
|
||||
`.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")`
|
||||
)
|
||||
.click()
|
||||
}
|
||||
|
||||
async addFilter(filterValue: string, filterType: string) {
|
||||
await this.selectFilterType(filterType)
|
||||
await this.selectFilterValue(filterValue)
|
||||
await this.page.locator('.p-button-label:has-text("Add")').click()
|
||||
}
|
||||
}
|
||||
|
||||
class ComfyNodeSearchBox {
|
||||
public readonly input: Locator
|
||||
public readonly dropdown: Locator
|
||||
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.input = page.locator(
|
||||
'.comfy-vue-node-search-container input[type="text"]'
|
||||
)
|
||||
this.dropdown = page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-list'
|
||||
)
|
||||
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
|
||||
}
|
||||
|
||||
get filterButton() {
|
||||
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
|
||||
}
|
||||
|
||||
async fillAndSelectFirstNode(
|
||||
nodeName: string,
|
||||
options?: { suggestionIndex: number }
|
||||
) {
|
||||
await this.input.waitFor({ state: 'visible' })
|
||||
await this.input.fill(nodeName)
|
||||
await this.dropdown.waitFor({ state: 'visible' })
|
||||
// Wait for some time for the auto complete list to update.
|
||||
// The auto complete list is debounced and may take some time to update.
|
||||
await this.page.waitForTimeout(500)
|
||||
await this.dropdown
|
||||
.locator('li')
|
||||
.nth(options?.suggestionIndex || 0)
|
||||
.click()
|
||||
}
|
||||
|
||||
async addFilter(filterValue: string, filterType: string) {
|
||||
await this.filterButton.click()
|
||||
await this.filterSelectionPanel.addFilter(filterValue, filterType)
|
||||
}
|
||||
|
||||
get filterChips() {
|
||||
return this.page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
|
||||
)
|
||||
}
|
||||
|
||||
async removeFilter(index: number) {
|
||||
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
|
||||
}
|
||||
}
|
||||
|
||||
class SidebarTab {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly tabId: string
|
||||
) {}
|
||||
|
||||
get tabButton() {
|
||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
||||
}
|
||||
|
||||
get selectedTabButton() {
|
||||
return this.page.locator(
|
||||
`.${this.tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (await this.selectedTabButton.isVisible()) {
|
||||
return
|
||||
}
|
||||
await this.tabButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
class NodeLibrarySidebarTab extends SidebarTab {
|
||||
constructor(public readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
}
|
||||
|
||||
get nodeLibrarySearchBoxInput() {
|
||||
return this.page.locator('.node-lib-search-box input[type="text"]')
|
||||
}
|
||||
|
||||
get nodeLibraryTree() {
|
||||
return this.page.locator('.node-lib-tree-explorer')
|
||||
}
|
||||
|
||||
get nodePreview() {
|
||||
return this.page.locator('.node-lib-node-preview')
|
||||
}
|
||||
|
||||
get tabContainer() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
}
|
||||
|
||||
get newFolderButton() {
|
||||
return this.tabContainer.locator('.new-folder-button')
|
||||
}
|
||||
|
||||
async open() {
|
||||
await super.open()
|
||||
await this.nodeLibraryTree.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.tabButton.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.tabButton.click()
|
||||
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
folderSelector(folderName: string) {
|
||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
|
||||
}
|
||||
|
||||
getFolder(folderName: string) {
|
||||
return this.page.locator(this.folderSelector(folderName))
|
||||
}
|
||||
|
||||
nodeSelector(nodeName: string) {
|
||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
|
||||
}
|
||||
|
||||
getNode(nodeName: string) {
|
||||
return this.page.locator(this.nodeSelector(nodeName))
|
||||
}
|
||||
}
|
||||
|
||||
class WorkflowsSidebarTab extends SidebarTab {
|
||||
constructor(public readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
}
|
||||
|
||||
get browseGalleryButton() {
|
||||
return this.page.locator('.browse-templates-button')
|
||||
}
|
||||
|
||||
get newBlankWorkflowButton() {
|
||||
return this.page.locator('.new-blank-workflow-button')
|
||||
}
|
||||
|
||||
get openWorkflowButton() {
|
||||
return this.page.locator('.open-workflow-button')
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
return await this.page
|
||||
.locator('.comfyui-workflows-open .node-label')
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
async getTopLevelSavedWorkflowNames() {
|
||||
return await this.page
|
||||
.locator('.comfyui-workflows-browse .node-label')
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
async switchToWorkflow(workflowName: string) {
|
||||
const workflowLocator = this.page.locator(
|
||||
'.comfyui-workflows-open .node-label',
|
||||
{ hasText: workflowName }
|
||||
)
|
||||
await workflowLocator.click()
|
||||
await this.page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
|
||||
class Topbar {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async getTabNames(): Promise<string[]> {
|
||||
return await this.page
|
||||
.locator('.workflow-tabs .workflow-label')
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
async openSubmenuMobile() {
|
||||
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
|
||||
}
|
||||
|
||||
async getMenuItem(itemLabel: string): Promise<Locator> {
|
||||
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
|
||||
}
|
||||
|
||||
async getWorkflowTab(tabName: string): Promise<Locator> {
|
||||
return this.page
|
||||
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
||||
.locator('..')
|
||||
}
|
||||
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = await this.getWorkflowTab(tabName)
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
}
|
||||
|
||||
async saveWorkflow(workflowName: string) {
|
||||
await this.triggerTopbarCommand(['Workflow', 'Save'])
|
||||
await this.page.locator('.p-dialog-content input').fill(workflowName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
// Wait for the dialog to close.
|
||||
await this.page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
async triggerTopbarCommand(path: string[]) {
|
||||
if (path.length < 2) {
|
||||
throw new Error('Path is too short')
|
||||
}
|
||||
|
||||
const tabName = path[0]
|
||||
const topLevelMenu = this.page.locator(
|
||||
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
|
||||
)
|
||||
await topLevelMenu.waitFor({ state: 'visible' })
|
||||
await topLevelMenu.click()
|
||||
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const commandName = path[i]
|
||||
const menuItem = this.page
|
||||
.locator(
|
||||
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
|
||||
)
|
||||
.first()
|
||||
await menuItem.waitFor({ state: 'visible' })
|
||||
await menuItem.hover()
|
||||
|
||||
if (i === path.length - 1) {
|
||||
await menuItem.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ComfyMenu {
|
||||
public readonly sideToolbar: Locator
|
||||
@@ -155,20 +420,7 @@ export class ComfyPage {
|
||||
}
|
||||
}
|
||||
|
||||
async setupSettings(settings: Record<string, any>) {
|
||||
const resp = await this.request.post(
|
||||
`${this.url}/api/devtools/set_settings`,
|
||||
{
|
||||
data: settings
|
||||
}
|
||||
)
|
||||
|
||||
if (resp.status() !== 200) {
|
||||
throw new Error(`Failed to setup settings: ${await resp.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async setup() {
|
||||
async setup({ resetView = true } = {}) {
|
||||
await this.goto()
|
||||
await this.page.evaluate(() => {
|
||||
localStorage.clear()
|
||||
@@ -189,13 +441,26 @@ export class ComfyPage {
|
||||
})
|
||||
await this.page.waitForFunction(() => document.fonts.ready)
|
||||
await this.page.waitForFunction(
|
||||
() =>
|
||||
// window['app'] => GraphCanvas ready
|
||||
// window['app'].extensionManager => GraphView ready
|
||||
window['app'] && window['app'].extensionManager
|
||||
() => window['app'] !== undefined && window['app'].vueAppReady
|
||||
)
|
||||
await this.page.waitForSelector('.p-blockui-mask', { state: 'hidden' })
|
||||
await this.page.evaluate(() => {
|
||||
window['app']['canvas'].show_info = false
|
||||
})
|
||||
await this.nextFrame()
|
||||
if (resetView) {
|
||||
// Reset view to force re-rendering of canvas. So that info fields like fps
|
||||
// become hidden.
|
||||
await this.resetView()
|
||||
}
|
||||
|
||||
// Hide all badges by default.
|
||||
await this.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', NodeBadgeMode.None)
|
||||
await this.setSetting(
|
||||
'Comfy.NodeBadge.NodeSourceBadgeMode',
|
||||
NodeBadgeMode.None
|
||||
)
|
||||
// Hide canvas menu by default.
|
||||
await this.setSetting('Comfy.Graph.CanvasMenu', false)
|
||||
}
|
||||
|
||||
public assetPath(fileName: string) {
|
||||
@@ -263,7 +528,7 @@ export class ComfyPage {
|
||||
async setSetting(settingId: string, settingValue: any) {
|
||||
return await this.page.evaluate(
|
||||
async ({ id, value }) => {
|
||||
await window['app'].extensionManager.setting.set(id, value)
|
||||
await window['app'].ui.settings.setSettingValueAsync(id, value)
|
||||
},
|
||||
{ id: settingId, value: settingValue }
|
||||
)
|
||||
@@ -271,7 +536,7 @@ export class ComfyPage {
|
||||
|
||||
async getSetting(settingId: string) {
|
||||
return await this.page.evaluate(async (id) => {
|
||||
return await window['app'].extensionManager.setting.get(id)
|
||||
return await window['app'].ui.settings.getSettingValue(id)
|
||||
}, settingId)
|
||||
}
|
||||
|
||||
@@ -719,19 +984,261 @@ export class ComfyPage {
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeSlotReference {
|
||||
constructor(
|
||||
readonly type: 'input' | 'output',
|
||||
readonly index: number,
|
||||
readonly node: NodeReference
|
||||
) {}
|
||||
async getPosition() {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
return window['app'].canvas.ds.convertOffsetToCanvas(
|
||||
node.getConnectionPos(type === 'input', index)
|
||||
)
|
||||
},
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
async getLinkCount() {
|
||||
return await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
return node.inputs[index].link == null ? 0 : 1
|
||||
}
|
||||
return node.outputs[index].links?.length ?? 0
|
||||
},
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
}
|
||||
async removeLinks() {
|
||||
await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
node.disconnectInput(index)
|
||||
} else {
|
||||
node.disconnectOutput(index)
|
||||
}
|
||||
},
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeWidgetReference {
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly node: NodeReference
|
||||
) {}
|
||||
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
const widget = node.widgets[index]
|
||||
if (!widget) throw new Error(`Widget ${index} not found.`)
|
||||
|
||||
const [x, y, w, h] = node.getBounding()
|
||||
return window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
x + w / 2,
|
||||
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
|
||||
])
|
||||
},
|
||||
[this.node.id, this.index] as const
|
||||
)
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeReference {
|
||||
constructor(
|
||||
readonly id: NodeId,
|
||||
readonly comfyPage: ComfyPage
|
||||
) {}
|
||||
async exists(): Promise<boolean> {
|
||||
return await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return !!node
|
||||
}, this.id)
|
||||
}
|
||||
getType(): Promise<string> {
|
||||
return this.getProperty('type')
|
||||
}
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos = await this.comfyPage.convertOffsetToCanvas(
|
||||
await this.getProperty<[number, number]>('pos')
|
||||
)
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
async getBounding(): Promise<Position & Size> {
|
||||
const [x, y, width, height]: [number, number, number, number] =
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node.getBounding()
|
||||
}, this.id)
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
async getSize(): Promise<Size> {
|
||||
const size = await this.getProperty<[number, number]>('size')
|
||||
return {
|
||||
width: size[0],
|
||||
height: size[1]
|
||||
}
|
||||
}
|
||||
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
|
||||
return await this.getProperty('flags')
|
||||
}
|
||||
async isPinned() {
|
||||
return !!(await this.getFlags()).pinned
|
||||
}
|
||||
async isCollapsed() {
|
||||
return !!(await this.getFlags()).collapsed
|
||||
}
|
||||
async isBypassed() {
|
||||
return (await this.getProperty<number | null | undefined>('mode')) === 4
|
||||
}
|
||||
async getProperty<T>(prop: string): Promise<T> {
|
||||
return await this.comfyPage.page.evaluate(
|
||||
([id, prop]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node[prop]
|
||||
},
|
||||
[this.id, prop] as const
|
||||
)
|
||||
}
|
||||
async getOutput(index: number) {
|
||||
return new NodeSlotReference('output', index, this)
|
||||
}
|
||||
async getInput(index: number) {
|
||||
return new NodeSlotReference('input', index, this)
|
||||
}
|
||||
async getWidget(index: number) {
|
||||
return new NodeWidgetReference(index, this)
|
||||
}
|
||||
async click(
|
||||
position: 'title' | 'collapse',
|
||||
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
||||
) {
|
||||
const nodePos = await this.getPosition()
|
||||
const nodeSize = await this.getSize()
|
||||
let clickPos: Position
|
||||
switch (position) {
|
||||
case 'title':
|
||||
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
|
||||
break
|
||||
case 'collapse':
|
||||
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
|
||||
break
|
||||
default:
|
||||
throw new Error(`Invalid click position ${position}`)
|
||||
}
|
||||
|
||||
const moveMouseToEmptyArea = options?.moveMouseToEmptyArea
|
||||
if (options) {
|
||||
delete options.moveMouseToEmptyArea
|
||||
}
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
...options,
|
||||
position: clickPos
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
if (moveMouseToEmptyArea) {
|
||||
await this.comfyPage.moveMouseToEmptyArea()
|
||||
}
|
||||
}
|
||||
async copy() {
|
||||
await this.click('title')
|
||||
await this.comfyPage.ctrlC()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async connectWidget(
|
||||
originSlotIndex: number,
|
||||
targetNode: NodeReference,
|
||||
targetWidgetIndex: number
|
||||
) {
|
||||
const originSlot = await this.getOutput(originSlotIndex)
|
||||
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
|
||||
await this.comfyPage.dragAndDrop(
|
||||
await originSlot.getPosition(),
|
||||
await targetWidget.getPosition()
|
||||
)
|
||||
return originSlot
|
||||
}
|
||||
async connectOutput(
|
||||
originSlotIndex: number,
|
||||
targetNode: NodeReference,
|
||||
targetSlotIndex: number
|
||||
) {
|
||||
const originSlot = await this.getOutput(originSlotIndex)
|
||||
const targetSlot = await targetNode.getInput(targetSlotIndex)
|
||||
await this.comfyPage.dragAndDrop(
|
||||
await originSlot.getPosition(),
|
||||
await targetSlot.getPosition()
|
||||
)
|
||||
return originSlot
|
||||
}
|
||||
async getContextMenuOptionNames() {
|
||||
await this.click('title', { button: 'right' })
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
return await ctx.locator('.litemenu-entry').allInnerTexts()
|
||||
}
|
||||
async clickContextMenuOption(optionText: string) {
|
||||
await this.click('title', { button: 'right' })
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
await ctx.getByText(optionText).click()
|
||||
}
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
this.comfyPage.page.once('dialog', async (dialog) => {
|
||||
await dialog.accept(groupNodeName)
|
||||
})
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
await this.comfyPage.nextFrame()
|
||||
const nodes = await this.comfyPage.getNodeRefsByType(
|
||||
`workflow>${groupNodeName}`
|
||||
)
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(`Did not find single group node (found=${nodes.length})`)
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async manageGroupNode() {
|
||||
await this.clickContextMenuOption('Manage Group Node')
|
||||
await this.comfyPage.nextFrame()
|
||||
return new ManageGroupNode(
|
||||
this.comfyPage.page,
|
||||
this.comfyPage.page.locator('.comfy-group-manage')
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({
|
||||
comfyPage: async ({ page, request }, use) => {
|
||||
const comfyPage = new ComfyPage(page, request)
|
||||
await comfyPage.setupSettings({
|
||||
// Hide canvas menu/info by default.
|
||||
'Comfy.Graph.CanvasInfo': false,
|
||||
'Comfy.Graph.CanvasMenu': false,
|
||||
// Hide all badges by default.
|
||||
'Comfy.NodeBadge.NodeIdBadgeMode': NodeBadgeMode.None,
|
||||
'Comfy.NodeBadge.NodeSourceBadgeMode': NodeBadgeMode.None,
|
||||
// Disable tooltips by default to avoid flakiness.
|
||||
'Comfy.EnableTooltips': false
|
||||
})
|
||||
await comfyPage.setup()
|
||||
await use(comfyPage)
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Response } from '@playwright/test'
|
||||
import type { StatusWsMessage } from '../src/types/apiTypes.ts'
|
||||
import { expect, mergeTests } from '@playwright/test'
|
||||
import { comfyPageFixture } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture } from './ComfyPage'
|
||||
import { webSocketFixture } from './fixtures/ws.ts'
|
||||
|
||||
const test = mergeTests(comfyPageFixture, webSocketFixture)
|
||||
@@ -11,6 +11,10 @@ test.describe('Actionbar', () => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
/**
|
||||
* This test ensures that the autoqueue change mode can only queue one change at a time
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Browser tab title', () => {
|
||||
test.describe('Beta Menu', () => {
|
||||
@@ -7,6 +7,10 @@ test.describe('Browser tab title', () => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Can display workflow name', async ({ comfyPage }) => {
|
||||
const workflowName = await comfyPage.page.evaluate(async () => {
|
||||
return window['app'].workflowManager.activeWorkflow.name
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
ComfyPage,
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from './fixtures/ComfyPage'
|
||||
} from './ComfyPage'
|
||||
|
||||
async function beforeChange(comfyPage: ComfyPage) {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
const customColorPalettes = {
|
||||
obsidian: {
|
||||
@@ -135,6 +135,12 @@ test.describe('Color Palette', () => {
|
||||
await comfyPage.setSetting('Comfy.CustomColorPalettes', customColorPalettes)
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.CustomColorPalettes', {})
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
|
||||
})
|
||||
|
||||
test('Can show custom color palette', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'custom_obsidian_dark')
|
||||
await comfyPage.nextFrame()
|
||||
@@ -152,6 +158,11 @@ test.describe('Node Color Adjustments', () => {
|
||||
await comfyPage.loadWorkflow('every_node_color')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Node.Opacity', 1.0)
|
||||
await comfyPage.setSetting('Comfy.ColorPalette', 'dark')
|
||||
})
|
||||
|
||||
test('should adjust opacity via node opacity setting', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Should execute command', async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Copy Paste', () => {
|
||||
test('Can copy and paste node', async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Load workflow warning', () => {
|
||||
test('Should display a warning when loading a workflow with missing nodes', async ({
|
||||
@@ -63,15 +63,45 @@ test.describe('Missing models warning', () => {
|
||||
|
||||
const downloadButton = comfyPage.page.getByLabel('Download')
|
||||
await expect(downloadButton).toBeVisible()
|
||||
const downloadPromise = comfyPage.page.waitForEvent('download')
|
||||
await downloadButton.click()
|
||||
|
||||
const download = await downloadPromise
|
||||
expect(download.suggestedFilename()).toBe('fake_model.safetensors')
|
||||
const downloadComplete = comfyPage.page.locator('.download-complete')
|
||||
await expect(downloadComplete).toBeVisible()
|
||||
})
|
||||
|
||||
test('Can configure download folder', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('missing_models')
|
||||
|
||||
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
|
||||
await expect(missingModelsWarning).toBeVisible()
|
||||
|
||||
const folderSelectToggle = comfyPage.page.locator(
|
||||
'.model-path-select-checkbox'
|
||||
)
|
||||
const folderSelect = comfyPage.page.locator('.model-path-select')
|
||||
await expect(folderSelectToggle).toBeVisible()
|
||||
await expect(folderSelect).not.toBeVisible()
|
||||
|
||||
await folderSelectToggle.click() // show the selectors
|
||||
await expect(folderSelect).toBeVisible()
|
||||
|
||||
await folderSelect.click() // open dropdown
|
||||
await expect(folderSelect).toHaveClass(/p-select-open/)
|
||||
|
||||
await folderSelect.click() // close the dropdown
|
||||
await expect(folderSelect).not.toHaveClass(/p-select-open/)
|
||||
|
||||
await folderSelectToggle.click() // hide the selectors
|
||||
await expect(folderSelect).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Settings', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
// Restore default setting value
|
||||
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', 1.1)
|
||||
})
|
||||
|
||||
test('@mobile Should be visible on mobile', async ({ comfyPage }) => {
|
||||
await comfyPage.page.keyboard.press('Control+,')
|
||||
const searchBox = comfyPage.page.locator('.settings-content')
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { expect, Locator } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Topbar commands', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Should allow registering topbar commands', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].registerExtension({
|
||||
@@ -79,23 +83,4 @@ test.describe('Topbar commands', () => {
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
test('Should allow adding settings', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window['app'].registerExtension({
|
||||
name: 'TestExtension1',
|
||||
settings: [
|
||||
{
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'Hello, world!'
|
||||
}
|
||||
]
|
||||
})
|
||||
})
|
||||
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, world!')
|
||||
await comfyPage.setSetting('TestSetting', 'Hello, universe!')
|
||||
expect(await comfyPage.getSetting('TestSetting')).toBe('Hello, universe!')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class ComfyNodeSearchFilterSelectionPanel {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async selectFilterType(filterType: string) {
|
||||
await this.page
|
||||
.locator(
|
||||
`.filter-type-select .p-togglebutton-label:has-text("${filterType}")`
|
||||
)
|
||||
.click()
|
||||
}
|
||||
|
||||
async selectFilterValue(filterValue: string) {
|
||||
await this.page.locator('.filter-value-select .p-select-dropdown').click()
|
||||
await this.page
|
||||
.locator(
|
||||
`.p-select-overlay .p-select-list .p-select-option-label:text-is("${filterValue}")`
|
||||
)
|
||||
.click()
|
||||
}
|
||||
|
||||
async addFilter(filterValue: string, filterType: string) {
|
||||
await this.selectFilterType(filterType)
|
||||
await this.selectFilterValue(filterValue)
|
||||
await this.page.locator('.p-button-label:has-text("Add")').click()
|
||||
}
|
||||
}
|
||||
|
||||
export class ComfyNodeSearchBox {
|
||||
public readonly input: Locator
|
||||
public readonly dropdown: Locator
|
||||
public readonly filterSelectionPanel: ComfyNodeSearchFilterSelectionPanel
|
||||
|
||||
constructor(public readonly page: Page) {
|
||||
this.input = page.locator(
|
||||
'.comfy-vue-node-search-container input[type="text"]'
|
||||
)
|
||||
this.dropdown = page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-list'
|
||||
)
|
||||
this.filterSelectionPanel = new ComfyNodeSearchFilterSelectionPanel(page)
|
||||
}
|
||||
|
||||
get filterButton() {
|
||||
return this.page.locator('.comfy-vue-node-search-container ._filter-button')
|
||||
}
|
||||
|
||||
async fillAndSelectFirstNode(
|
||||
nodeName: string,
|
||||
options?: { suggestionIndex: number }
|
||||
) {
|
||||
await this.input.waitFor({ state: 'visible' })
|
||||
await this.input.fill(nodeName)
|
||||
await this.dropdown.waitFor({ state: 'visible' })
|
||||
// Wait for some time for the auto complete list to update.
|
||||
// The auto complete list is debounced and may take some time to update.
|
||||
await this.page.waitForTimeout(500)
|
||||
await this.dropdown
|
||||
.locator('li')
|
||||
.nth(options?.suggestionIndex || 0)
|
||||
.click()
|
||||
}
|
||||
|
||||
async addFilter(filterValue: string, filterType: string) {
|
||||
await this.filterButton.click()
|
||||
await this.filterSelectionPanel.addFilter(filterValue, filterType)
|
||||
}
|
||||
|
||||
get filterChips() {
|
||||
return this.page.locator(
|
||||
'.comfy-vue-node-search-container .p-autocomplete-chip-item'
|
||||
)
|
||||
}
|
||||
|
||||
async removeFilter(index: number) {
|
||||
await this.filterChips.nth(index).locator('.p-chip-remove-icon').click()
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
import { Page } from '@playwright/test'
|
||||
|
||||
class SidebarTab {
|
||||
constructor(
|
||||
public readonly page: Page,
|
||||
public readonly tabId: string
|
||||
) {}
|
||||
|
||||
get tabButton() {
|
||||
return this.page.locator(`.${this.tabId}-tab-button`)
|
||||
}
|
||||
|
||||
get selectedTabButton() {
|
||||
return this.page.locator(
|
||||
`.${this.tabId}-tab-button.side-bar-button-selected`
|
||||
)
|
||||
}
|
||||
|
||||
async open() {
|
||||
if (await this.selectedTabButton.isVisible()) {
|
||||
return
|
||||
}
|
||||
await this.tabButton.click()
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeLibrarySidebarTab extends SidebarTab {
|
||||
constructor(public readonly page: Page) {
|
||||
super(page, 'node-library')
|
||||
}
|
||||
|
||||
get nodeLibrarySearchBoxInput() {
|
||||
return this.page.locator('.node-lib-search-box input[type="text"]')
|
||||
}
|
||||
|
||||
get nodeLibraryTree() {
|
||||
return this.page.locator('.node-lib-tree-explorer')
|
||||
}
|
||||
|
||||
get nodePreview() {
|
||||
return this.page.locator('.node-lib-node-preview')
|
||||
}
|
||||
|
||||
get tabContainer() {
|
||||
return this.page.locator('.sidebar-content-container')
|
||||
}
|
||||
|
||||
get newFolderButton() {
|
||||
return this.tabContainer.locator('.new-folder-button')
|
||||
}
|
||||
|
||||
async open() {
|
||||
await super.open()
|
||||
await this.nodeLibraryTree.waitFor({ state: 'visible' })
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.tabButton.isVisible()) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.tabButton.click()
|
||||
await this.nodeLibraryTree.waitFor({ state: 'hidden' })
|
||||
}
|
||||
|
||||
folderSelector(folderName: string) {
|
||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-folder .node-label:has-text("${folderName}")))`
|
||||
}
|
||||
|
||||
getFolder(folderName: string) {
|
||||
return this.page.locator(this.folderSelector(folderName))
|
||||
}
|
||||
|
||||
nodeSelector(nodeName: string) {
|
||||
return `.p-tree-node-content:has(> .tree-explorer-node-label:has(.tree-leaf .node-label:has-text("${nodeName}")))`
|
||||
}
|
||||
|
||||
getNode(nodeName: string) {
|
||||
return this.page.locator(this.nodeSelector(nodeName))
|
||||
}
|
||||
}
|
||||
|
||||
export class WorkflowsSidebarTab extends SidebarTab {
|
||||
constructor(public readonly page: Page) {
|
||||
super(page, 'workflows')
|
||||
}
|
||||
|
||||
get browseGalleryButton() {
|
||||
return this.page.locator('.browse-templates-button')
|
||||
}
|
||||
|
||||
get newBlankWorkflowButton() {
|
||||
return this.page.locator('.new-blank-workflow-button')
|
||||
}
|
||||
|
||||
get openWorkflowButton() {
|
||||
return this.page.locator('.open-workflow-button')
|
||||
}
|
||||
|
||||
async getOpenedWorkflowNames() {
|
||||
return await this.page
|
||||
.locator('.comfyui-workflows-open .node-label')
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
async getTopLevelSavedWorkflowNames() {
|
||||
return await this.page
|
||||
.locator('.comfyui-workflows-browse .node-label')
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
async switchToWorkflow(workflowName: string) {
|
||||
const workflowLocator = this.page.locator(
|
||||
'.comfyui-workflows-open .node-label',
|
||||
{ hasText: workflowName }
|
||||
)
|
||||
await workflowLocator.click()
|
||||
await this.page.waitForTimeout(300)
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
import { Locator, Page } from '@playwright/test'
|
||||
|
||||
export class Topbar {
|
||||
constructor(public readonly page: Page) {}
|
||||
|
||||
async getTabNames(): Promise<string[]> {
|
||||
return await this.page
|
||||
.locator('.workflow-tabs .workflow-label')
|
||||
.allInnerTexts()
|
||||
}
|
||||
|
||||
async openSubmenuMobile() {
|
||||
await this.page.locator('.p-menubar-mobile .p-menubar-button').click()
|
||||
}
|
||||
|
||||
async getMenuItem(itemLabel: string): Promise<Locator> {
|
||||
return this.page.locator(`.p-menubar-item-label:text-is("${itemLabel}")`)
|
||||
}
|
||||
|
||||
async getWorkflowTab(tabName: string): Promise<Locator> {
|
||||
return this.page
|
||||
.locator(`.workflow-tabs .workflow-label:has-text("${tabName}")`)
|
||||
.locator('..')
|
||||
}
|
||||
|
||||
async closeWorkflowTab(tabName: string) {
|
||||
const tab = await this.getWorkflowTab(tabName)
|
||||
await tab.locator('.close-button').click({ force: true })
|
||||
}
|
||||
|
||||
async saveWorkflow(workflowName: string) {
|
||||
await this.triggerTopbarCommand(['Workflow', 'Save'])
|
||||
await this.page.locator('.p-dialog-content input').fill(workflowName)
|
||||
await this.page.keyboard.press('Enter')
|
||||
// Wait for the dialog to close.
|
||||
await this.page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
async triggerTopbarCommand(path: string[]) {
|
||||
if (path.length < 2) {
|
||||
throw new Error('Path is too short')
|
||||
}
|
||||
|
||||
const tabName = path[0]
|
||||
const topLevelMenu = this.page.locator(
|
||||
`.top-menubar .p-menubar-item-label:text-is("${tabName}")`
|
||||
)
|
||||
await topLevelMenu.waitFor({ state: 'visible' })
|
||||
await topLevelMenu.click()
|
||||
|
||||
for (let i = 1; i < path.length; i++) {
|
||||
const commandName = path[i]
|
||||
const menuItem = this.page
|
||||
.locator(
|
||||
`.top-menubar .p-menubar-submenu .p-menubar-item:has-text("${commandName}")`
|
||||
)
|
||||
.first()
|
||||
await menuItem.waitFor({ state: 'visible' })
|
||||
await menuItem.hover()
|
||||
|
||||
if (i === path.length - 1) {
|
||||
await menuItem.click()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
export interface Position {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface Size {
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
import { ManageGroupNode } from '../../helpers/manageGroupNode'
|
||||
import type { NodeId } from '../../../src/types/comfyWorkflow'
|
||||
import type { Page } from '@playwright/test'
|
||||
import type { ComfyPage } from '../ComfyPage'
|
||||
import type { Position, Size } from '../types'
|
||||
|
||||
export class NodeSlotReference {
|
||||
constructor(
|
||||
readonly type: 'input' | 'output',
|
||||
readonly index: number,
|
||||
readonly node: NodeReference
|
||||
) {}
|
||||
async getPosition() {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
return window['app'].canvas.ds.convertOffsetToCanvas(
|
||||
node.getConnectionPos(type === 'input', index)
|
||||
)
|
||||
},
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
async getLinkCount() {
|
||||
return await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
return node.inputs[index].link == null ? 0 : 1
|
||||
}
|
||||
return node.outputs[index].links?.length ?? 0
|
||||
},
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
}
|
||||
async removeLinks() {
|
||||
await this.node.comfyPage.page.evaluate(
|
||||
([type, id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
if (type === 'input') {
|
||||
node.disconnectInput(index)
|
||||
} else {
|
||||
node.disconnectOutput(index)
|
||||
}
|
||||
},
|
||||
[this.type, this.node.id, this.index] as const
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeWidgetReference {
|
||||
constructor(
|
||||
readonly index: number,
|
||||
readonly node: NodeReference
|
||||
) {}
|
||||
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos: [number, number] = await this.node.comfyPage.page.evaluate(
|
||||
([id, index]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error(`Node ${id} not found.`)
|
||||
const widget = node.widgets[index]
|
||||
if (!widget) throw new Error(`Widget ${index} not found.`)
|
||||
|
||||
const [x, y, w, h] = node.getBounding()
|
||||
return window['app'].canvas.ds.convertOffsetToCanvas([
|
||||
x + w / 2,
|
||||
y + window['LiteGraph']['NODE_TITLE_HEIGHT'] + widget.last_y + 1
|
||||
])
|
||||
},
|
||||
[this.node.id, this.index] as const
|
||||
)
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class NodeReference {
|
||||
constructor(
|
||||
readonly id: NodeId,
|
||||
readonly comfyPage: ComfyPage
|
||||
) {}
|
||||
async exists(): Promise<boolean> {
|
||||
return await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
return !!node
|
||||
}, this.id)
|
||||
}
|
||||
getType(): Promise<string> {
|
||||
return this.getProperty('type')
|
||||
}
|
||||
async getPosition(): Promise<Position> {
|
||||
const pos = await this.comfyPage.convertOffsetToCanvas(
|
||||
await this.getProperty<[number, number]>('pos')
|
||||
)
|
||||
return {
|
||||
x: pos[0],
|
||||
y: pos[1]
|
||||
}
|
||||
}
|
||||
async getBounding(): Promise<Position & Size> {
|
||||
const [x, y, width, height]: [number, number, number, number] =
|
||||
await this.comfyPage.page.evaluate((id) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node.getBounding()
|
||||
}, this.id)
|
||||
return {
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
async getSize(): Promise<Size> {
|
||||
const size = await this.getProperty<[number, number]>('size')
|
||||
return {
|
||||
width: size[0],
|
||||
height: size[1]
|
||||
}
|
||||
}
|
||||
async getFlags(): Promise<{ collapsed?: boolean; pinned?: boolean }> {
|
||||
return await this.getProperty('flags')
|
||||
}
|
||||
async isPinned() {
|
||||
return !!(await this.getFlags()).pinned
|
||||
}
|
||||
async isCollapsed() {
|
||||
return !!(await this.getFlags()).collapsed
|
||||
}
|
||||
async isBypassed() {
|
||||
return (await this.getProperty<number | null | undefined>('mode')) === 4
|
||||
}
|
||||
async getProperty<T>(prop: string): Promise<T> {
|
||||
return await this.comfyPage.page.evaluate(
|
||||
([id, prop]) => {
|
||||
const node = window['app'].graph.getNodeById(id)
|
||||
if (!node) throw new Error('Node not found')
|
||||
return node[prop]
|
||||
},
|
||||
[this.id, prop] as const
|
||||
)
|
||||
}
|
||||
async getOutput(index: number) {
|
||||
return new NodeSlotReference('output', index, this)
|
||||
}
|
||||
async getInput(index: number) {
|
||||
return new NodeSlotReference('input', index, this)
|
||||
}
|
||||
async getWidget(index: number) {
|
||||
return new NodeWidgetReference(index, this)
|
||||
}
|
||||
async click(
|
||||
position: 'title' | 'collapse',
|
||||
options?: Parameters<Page['click']>[1] & { moveMouseToEmptyArea?: boolean }
|
||||
) {
|
||||
const nodePos = await this.getPosition()
|
||||
const nodeSize = await this.getSize()
|
||||
let clickPos: Position
|
||||
switch (position) {
|
||||
case 'title':
|
||||
clickPos = { x: nodePos.x + nodeSize.width / 2, y: nodePos.y - 15 }
|
||||
break
|
||||
case 'collapse':
|
||||
clickPos = { x: nodePos.x + 5, y: nodePos.y - 10 }
|
||||
break
|
||||
default:
|
||||
throw new Error(`Invalid click position ${position}`)
|
||||
}
|
||||
|
||||
const moveMouseToEmptyArea = options?.moveMouseToEmptyArea
|
||||
if (options) {
|
||||
delete options.moveMouseToEmptyArea
|
||||
}
|
||||
|
||||
await this.comfyPage.canvas.click({
|
||||
...options,
|
||||
position: clickPos
|
||||
})
|
||||
await this.comfyPage.nextFrame()
|
||||
if (moveMouseToEmptyArea) {
|
||||
await this.comfyPage.moveMouseToEmptyArea()
|
||||
}
|
||||
}
|
||||
async copy() {
|
||||
await this.click('title')
|
||||
await this.comfyPage.ctrlC()
|
||||
await this.comfyPage.nextFrame()
|
||||
}
|
||||
async connectWidget(
|
||||
originSlotIndex: number,
|
||||
targetNode: NodeReference,
|
||||
targetWidgetIndex: number
|
||||
) {
|
||||
const originSlot = await this.getOutput(originSlotIndex)
|
||||
const targetWidget = await targetNode.getWidget(targetWidgetIndex)
|
||||
await this.comfyPage.dragAndDrop(
|
||||
await originSlot.getPosition(),
|
||||
await targetWidget.getPosition()
|
||||
)
|
||||
return originSlot
|
||||
}
|
||||
async connectOutput(
|
||||
originSlotIndex: number,
|
||||
targetNode: NodeReference,
|
||||
targetSlotIndex: number
|
||||
) {
|
||||
const originSlot = await this.getOutput(originSlotIndex)
|
||||
const targetSlot = await targetNode.getInput(targetSlotIndex)
|
||||
await this.comfyPage.dragAndDrop(
|
||||
await originSlot.getPosition(),
|
||||
await targetSlot.getPosition()
|
||||
)
|
||||
return originSlot
|
||||
}
|
||||
async getContextMenuOptionNames() {
|
||||
await this.click('title', { button: 'right' })
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
return await ctx.locator('.litemenu-entry').allInnerTexts()
|
||||
}
|
||||
async clickContextMenuOption(optionText: string) {
|
||||
await this.click('title', { button: 'right' })
|
||||
const ctx = this.comfyPage.page.locator('.litecontextmenu')
|
||||
await ctx.getByText(optionText).click()
|
||||
}
|
||||
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
|
||||
this.comfyPage.page.once('dialog', async (dialog) => {
|
||||
await dialog.accept(groupNodeName)
|
||||
})
|
||||
await this.clickContextMenuOption('Convert to Group Node')
|
||||
await this.comfyPage.nextFrame()
|
||||
const nodes = await this.comfyPage.getNodeRefsByType(
|
||||
`workflow>${groupNodeName}`
|
||||
)
|
||||
if (nodes.length !== 1) {
|
||||
throw new Error(`Did not find single group node (found=${nodes.length})`)
|
||||
}
|
||||
return nodes[0]
|
||||
}
|
||||
async manageGroupNode() {
|
||||
await this.clickContextMenuOption('Manage Group Node')
|
||||
await this.comfyPage.nextFrame()
|
||||
return new ManageGroupNode(
|
||||
this.comfyPage.page,
|
||||
this.comfyPage.page.locator('.comfy-group-manage')
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Graph Canvas Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { ComfyPage, comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import type { NodeReference } from './fixtures/utils/litegraphUtils'
|
||||
import { ComfyPage, NodeReference, comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Group Node', () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Node library sidebar', () => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
const groupNodeCategory = 'group nodes>workflow'
|
||||
@@ -16,6 +19,11 @@ test.describe('Group Node', () => {
|
||||
await libraryTab.open()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
|
||||
await libraryTab.close()
|
||||
})
|
||||
|
||||
test('Is added to node library sidebar', async ({ comfyPage }) => {
|
||||
expect(await libraryTab.getFolder('group nodes').count()).toBe(1)
|
||||
})
|
||||
@@ -90,7 +98,6 @@ test.describe('Group Node', () => {
|
||||
})
|
||||
|
||||
test('Displays tooltip on title hover', async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.EnableTooltips', true)
|
||||
await comfyPage.convertAllNodesToGroupNode('Group Node')
|
||||
await comfyPage.page.mouse.move(47, 173)
|
||||
const tooltipTimeout = 500
|
||||
@@ -185,6 +192,13 @@ test.describe('Group Node', () => {
|
||||
await groupNode.copy()
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.page.evaluate((groupNodeName) => {
|
||||
window['LiteGraph'].unregisterNodeType(groupNodeName)
|
||||
}, GROUP_NODE_TYPE)
|
||||
})
|
||||
|
||||
test('Copies and pastes group node within the same workflow', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Node Interaction', () => {
|
||||
test('Can enter prompt', async ({ comfyPage }) => {
|
||||
@@ -73,8 +73,6 @@ test.describe('Node Interaction', () => {
|
||||
await comfyPage.disconnectEdge()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('disconnected-edge.png')
|
||||
await comfyPage.connectEdge()
|
||||
// Move mouse to empty area to avoid slot highlight.
|
||||
await comfyPage.moveMouseToEmptyArea()
|
||||
// Litegraph renders edge with a slight offset.
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('default.png', {
|
||||
maxDiffPixels: 50
|
||||
@@ -495,6 +493,10 @@ test.describe('Load duplicate workflow', () => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('A workflow can be loaded multiple times in a row', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 78 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 100 KiB |
|
Before Width: | Height: | Size: 96 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 95 KiB After Width: | Height: | Size: 96 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 64 KiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 7.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
function listenForEvent(): Promise<Event> {
|
||||
return new Promise<Event>((resolve) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Load Workflow in Media', () => {
|
||||
;[
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
const currentThemeId = await comfyPage.menu.getThemeId()
|
||||
if (currentThemeId !== 'dark') {
|
||||
await comfyPage.menu.toggleTheme()
|
||||
}
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
// Skip reason: Flaky.
|
||||
test.skip('Toggle theme', async ({ comfyPage }) => {
|
||||
test.setTimeout(30000)
|
||||
@@ -17,7 +25,8 @@ test.describe('Menu', () => {
|
||||
expect(await comfyPage.menu.getThemeId()).toBe('light')
|
||||
|
||||
// Theme id should persist after reload.
|
||||
await comfyPage.reload()
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
expect(await comfyPage.menu.getThemeId()).toBe('light')
|
||||
|
||||
await comfyPage.menu.toggleTheme()
|
||||
@@ -359,7 +368,8 @@ test.describe('Menu', () => {
|
||||
'KSampler'
|
||||
])
|
||||
await comfyPage.setSetting('Comfy.NodeLibrary.Bookmarks.V2', [])
|
||||
await comfyPage.reload()
|
||||
await comfyPage.page.reload()
|
||||
await comfyPage.setup()
|
||||
expect(await comfyPage.getSetting('Comfy.NodeLibrary.Bookmarks')).toEqual(
|
||||
[]
|
||||
)
|
||||
@@ -402,7 +412,7 @@ test.describe('Menu', () => {
|
||||
'workflow2.json': 'default.json'
|
||||
})
|
||||
// Avoid reset view as the button is not visible in BetaMenu UI.
|
||||
await comfyPage.setup()
|
||||
await comfyPage.setup({ resetView: false })
|
||||
|
||||
const tab = comfyPage.menu.workflowsTab
|
||||
await tab.open()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
import type { ComfyApp } from '../src/scripts/app'
|
||||
import { NodeBadgeMode } from '../src/types/nodeSource'
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
// If an input is optional by node definition, it should be shown as
|
||||
// a hollow circle no matter what shape it was defined in the workflow JSON.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Node search box', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import type { NodeReference } from './fixtures/utils/litegraphUtils'
|
||||
import { type NodeReference, comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Primitive Node', () => {
|
||||
test('Can load with correct size', async ({ comfyPage }) => {
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Properties Panel', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
// TODO: Update expectation after new menu dropdown is added.
|
||||
test.skip('Can change property value', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickEmptyLatentNode()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
import { NodeBadgeMode } from '../src/types/nodeSource'
|
||||
|
||||
test.describe('Canvas Right Click Menu', () => {
|
||||
@@ -96,6 +96,11 @@ test.describe('Node Right Click Menu', () => {
|
||||
test.describe('Widget conversion', () => {
|
||||
const convertibleWidgetTypes = ['text', 'string', 'number', 'toggle']
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
// Restore default setting value
|
||||
await comfyPage.setSetting('Comfy.NodeInputConversionSubmenus', true)
|
||||
})
|
||||
|
||||
test('Can convert widget to input', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickEmptyLatentNode()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('right-click-node.png')
|
||||
|
||||
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 98 KiB |
@@ -1,11 +1,15 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Templates', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test('Can load template workflows', async ({ comfyPage }) => {
|
||||
// This test will need expanding on once the templates are decided
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from './fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from './ComfyPage'
|
||||
|
||||
test.describe('Combo text widget', () => {
|
||||
test('Truncates text when resized', async ({ comfyPage }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 93 KiB After Width: | Height: | Size: 93 KiB |
|
Before Width: | Height: | Size: 89 KiB After Width: | Height: | Size: 90 KiB |
26
package-lock.json
generated
@@ -1,15 +1,15 @@
|
||||
{
|
||||
"name": "comfyui-frontend",
|
||||
"version": "1.3.28",
|
||||
"version": "1.3.19",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "comfyui-frontend",
|
||||
"version": "1.3.28",
|
||||
"version": "1.3.19",
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/litegraph": "^0.8.10",
|
||||
"@comfyorg/litegraph": "^0.8.3",
|
||||
"@primevue/themes": "^4.0.5",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"axios": "^1.7.4",
|
||||
@@ -58,7 +58,6 @@
|
||||
"tailwindcss": "^3.4.4",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-files": "^1.1.4",
|
||||
"tsx": "^4.15.6",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
@@ -1911,9 +1910,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@comfyorg/litegraph": {
|
||||
"version": "0.8.10",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.10.tgz",
|
||||
"integrity": "sha512-xGAlWKnXTOG9cYR6300MJ/6euCaf1Wonc/gbbueNyaSvpBLUo4H5CdiYL6SzUCXol7FG3KeTt4/CpXWrhSvGKQ==",
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.3.tgz",
|
||||
"integrity": "sha512-BlKqsHhEAn1+ZXlS8Lv8oIVf9qjHyQSj7sJsnBqherqCl9RCksqiXZUXRu+/+GWlHapscQsZJcnPcoAHzGaOKA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
@@ -12106,19 +12105,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/tsc-files": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/tsc-files/-/tsc-files-1.1.4.tgz",
|
||||
"integrity": "sha512-RePsRsOLru3BPpnf237y1Xe1oCGta8rmSYzM76kYo5tLGsv5R2r3s64yapYorGTPuuLyfS9NVbh9ydzmvNie2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"tsc-files": "cli.js"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": ">=3"
|
||||
}
|
||||
},
|
||||
"node_modules/tsconfig": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/tsconfig/-/tsconfig-7.0.0.tgz",
|
||||
|
||||
11
package.json
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.3.28",
|
||||
"version": "1.3.19",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -53,7 +53,6 @@
|
||||
"tailwindcss": "^3.4.4",
|
||||
"ts-jest": "^29.1.4",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsc-files": "^1.1.4",
|
||||
"tsx": "^4.15.6",
|
||||
"typescript": "^5.4.5",
|
||||
"typescript-eslint": "^8.0.0",
|
||||
@@ -67,7 +66,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/litegraph": "^0.8.10",
|
||||
"@comfyorg/litegraph": "^0.8.3",
|
||||
"@primevue/themes": "^4.0.5",
|
||||
"@vueuse/core": "^11.0.0",
|
||||
"axios": "^1.7.4",
|
||||
@@ -84,7 +83,9 @@
|
||||
"zod-validation-error": "^3.3.0"
|
||||
},
|
||||
"lint-staged": {
|
||||
"./**/*.{js,ts,tsx,vue}": "prettier --write",
|
||||
"**/*.ts": "tsc-files --noEmit"
|
||||
"./**/*.{js,ts,tsx,vue}": [
|
||||
"prettier --write",
|
||||
"git add"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,6 +181,25 @@ body {
|
||||
margin: 3px 3px 3px 4px;
|
||||
}
|
||||
|
||||
.comfy-menu-hamburger {
|
||||
position: fixed;
|
||||
top: 10px;
|
||||
z-index: 9999;
|
||||
right: 10px;
|
||||
width: 30px;
|
||||
display: none;
|
||||
gap: 8px;
|
||||
flex-direction: column;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.comfy-menu-hamburger div {
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
border-radius: 20px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.comfy-menu {
|
||||
font-size: 15px;
|
||||
position: absolute;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<Splitter
|
||||
class="splitter-overlay-root splitter-overlay"
|
||||
:pt:gutter="sidebarPanelVisible ? '' : 'hidden'"
|
||||
>
|
||||
<Splitter class="splitter-overlay" :pt:gutter="gutterClass">
|
||||
<SplitterPanel
|
||||
class="side-bar-panel"
|
||||
:minSize="10"
|
||||
@@ -12,22 +9,9 @@
|
||||
>
|
||||
<slot name="side-bar-panel"></slot>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel :size="100">
|
||||
<Splitter
|
||||
class="splitter-overlay"
|
||||
layout="vertical"
|
||||
:pt:gutter="bottomPanelVisible ? '' : 'hidden'"
|
||||
>
|
||||
<SplitterPanel class="graph-canvas-panel relative">
|
||||
<slot name="graph-canvas-panel"></slot>
|
||||
</SplitterPanel>
|
||||
<SplitterPanel class="bottom-panel" v-show="bottomPanelVisible">
|
||||
<slot name="bottom-panel"></slot>
|
||||
</SplitterPanel>
|
||||
</Splitter>
|
||||
<SplitterPanel class="graph-canvas-panel relative" :size="100">
|
||||
<slot name="graph-canvas-panel"></slot>
|
||||
</SplitterPanel>
|
||||
|
||||
<SplitterPanel
|
||||
class="side-bar-panel"
|
||||
:minSize="10"
|
||||
@@ -42,8 +26,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
|
||||
import Splitter from 'primevue/splitter'
|
||||
import SplitterPanel from 'primevue/splitterpanel'
|
||||
import { computed } from 'vue'
|
||||
@@ -54,39 +37,42 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
)
|
||||
|
||||
const sidebarPanelVisible = computed(
|
||||
() => useSidebarTabStore().activeSidebarTab !== null
|
||||
)
|
||||
const bottomPanelVisible = computed(
|
||||
() => useBottomPanelStore().bottomPanelVisible
|
||||
() => useWorkspaceStore().sidebarTab.activeSidebarTab !== null
|
||||
)
|
||||
const gutterClass = computed(() => {
|
||||
return sidebarPanelVisible.value ? '' : 'gutter-hidden'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
:deep(.p-splitter-gutter) {
|
||||
<style>
|
||||
.p-splitter-gutter {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.gutter-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.side-bar-panel {
|
||||
background-color: var(--bg-color);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.bottom-panel {
|
||||
background-color: var(--bg-color);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.splitter-overlay {
|
||||
@apply bg-transparent pointer-events-none border-none;
|
||||
}
|
||||
|
||||
.splitter-overlay-root {
|
||||
@apply w-full h-full absolute top-0 left-0;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background-color: transparent;
|
||||
pointer-events: none;
|
||||
/* Set it the same as the ComfyUI menu */
|
||||
/* Note: Lite-graph DOM widgets have the same z-index as the node id, so
|
||||
999 should be sufficient to make sure splitter overlays on node's DOM
|
||||
widgets */
|
||||
z-index: 999;
|
||||
border: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="workspaceState.focusMode"
|
||||
class="comfy-menu-hamburger"
|
||||
icon="pi pi-bars"
|
||||
severity="secondary"
|
||||
text
|
||||
size="large"
|
||||
@click="exitFocusMode"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
|
||||
import { watchEffect } from 'vue'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
|
||||
const workspaceState = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const exitFocusMode = () => {
|
||||
workspaceState.focusMode = false
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (settingStore.get('Comfy.UseNewMenu') !== 'Disabled') {
|
||||
return
|
||||
}
|
||||
if (workspaceState.focusMode) {
|
||||
app.ui.menuContainer.style.display = 'none'
|
||||
} else {
|
||||
app.ui.menuContainer.style.display = 'block'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.comfy-menu-hamburger {
|
||||
pointer-events: auto;
|
||||
position: fixed;
|
||||
top: 0px;
|
||||
right: 0px;
|
||||
z-index: 9999;
|
||||
}
|
||||
</style>
|
||||
@@ -1,51 +0,0 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<Tabs v-model:value="bottomPanelStore.activeBottomPanelTabId">
|
||||
<TabList pt:tabList="border-none">
|
||||
<div class="w-full flex justify-between">
|
||||
<div class="tabs-container">
|
||||
<Tab
|
||||
v-for="tab in bottomPanelStore.bottomPanelTabs"
|
||||
:key="tab.id"
|
||||
:value="tab.id"
|
||||
class="p-3 border-none"
|
||||
>
|
||||
<span class="font-bold">
|
||||
{{ tab.title.toUpperCase() }}
|
||||
</span>
|
||||
</Tab>
|
||||
</div>
|
||||
<Button
|
||||
class="justify-self-end"
|
||||
icon="pi pi-times"
|
||||
severity="secondary"
|
||||
size="small"
|
||||
text
|
||||
@click="bottomPanelStore.bottomPanelVisible = false"
|
||||
/>
|
||||
</div>
|
||||
</TabList>
|
||||
</Tabs>
|
||||
<!-- h-0 to force the div to flex-grow -->
|
||||
<div class="flex-grow h-0">
|
||||
<ExtensionSlot
|
||||
v-if="
|
||||
bottomPanelStore.bottomPanelVisible &&
|
||||
bottomPanelStore.activeBottomPanelTab
|
||||
"
|
||||
:extension="bottomPanelStore.activeBottomPanelTab"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import Tabs from 'primevue/tabs'
|
||||
import TabList from 'primevue/tablist'
|
||||
import Tab from 'primevue/tab'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
</script>
|
||||
@@ -1,61 +0,0 @@
|
||||
<template>
|
||||
<div class="p-terminal rounded-none h-full w-full">
|
||||
<ScrollPanel class="h-full w-full" ref="scrollPanelRef">
|
||||
<pre class="px-4 whitespace-pre-wrap">{{ log }}</pre>
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ScrollPanel from 'primevue/scrollpanel'
|
||||
import { api } from '@/scripts/api'
|
||||
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
|
||||
const log = ref<string>('')
|
||||
const scrollPanelRef = ref<InstanceType<typeof ScrollPanel> | null>(null)
|
||||
/**
|
||||
* Whether the user has scrolled to the bottom of the terminal.
|
||||
* This is used to prevent the terminal from scrolling to the bottom
|
||||
* when new logs are fetched.
|
||||
*/
|
||||
const scrolledToBottom = ref(false)
|
||||
|
||||
let intervalId: number = 0
|
||||
|
||||
onMounted(async () => {
|
||||
const element = scrollPanelRef.value?.$el
|
||||
const scrollContainer = element?.querySelector('.p-scrollpanel-content')
|
||||
|
||||
if (scrollContainer) {
|
||||
scrollContainer.addEventListener('scroll', () => {
|
||||
scrolledToBottom.value =
|
||||
scrollContainer.scrollTop + scrollContainer.clientHeight ===
|
||||
scrollContainer.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
const scrollToBottom = () => {
|
||||
if (scrollContainer) {
|
||||
scrollContainer.scrollTop = scrollContainer.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
watch(log, () => {
|
||||
if (scrolledToBottom.value) {
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
|
||||
const fetchLogs = async () => {
|
||||
log.value = await api.getLogs()
|
||||
}
|
||||
|
||||
await fetchLogs()
|
||||
scrollToBottom()
|
||||
intervalId = window.setInterval(fetchLogs, 500)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.clearInterval(intervalId)
|
||||
})
|
||||
</script>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { DeviceStats } from '@/types/apiTypes'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { formatMemory } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
device: DeviceStats
|
||||
@@ -30,7 +30,7 @@ const formatValue = (value: any, field: string) => {
|
||||
field
|
||||
)
|
||||
) {
|
||||
return formatSize(value)
|
||||
return formatMemory(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<template>
|
||||
<component v-if="extension.type === 'vue'" :is="extension.component" />
|
||||
<div
|
||||
v-else
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el)
|
||||
mountCustomExtension(
|
||||
props.extension as CustomExtension,
|
||||
el as HTMLElement
|
||||
)
|
||||
}
|
||||
"
|
||||
></div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CustomExtension, VueExtension } from '@/types/extensionTypes'
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
extension: VueExtension | CustomExtension
|
||||
}>()
|
||||
|
||||
const mountCustomExtension = (extension: CustomExtension, el: HTMLElement) => {
|
||||
extension.render(el)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (props.extension.type === 'custom' && props.extension.destroy) {
|
||||
props.extension.destroy()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,44 +0,0 @@
|
||||
<!-- A file download button with a label and a size hint -->
|
||||
<template>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<div class="file-info">
|
||||
<div class="file-details">
|
||||
<span class="file-type" :title="hint">{{ label }}</span>
|
||||
</div>
|
||||
<div v-if="props.error" class="file-error">
|
||||
{{ props.error }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="file-action">
|
||||
<Button
|
||||
class="file-action-button"
|
||||
:label="$t('download') + ' (' + fileSize + ')'"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="props.error"
|
||||
@click="download.triggerBrowserDownload"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useDownload } from '@/hooks/downloadHooks'
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
url: string
|
||||
hint?: string
|
||||
label?: string
|
||||
error?: string
|
||||
}>()
|
||||
|
||||
const label = computed(() => props.label || props.url.split('/').pop())
|
||||
const hint = computed(() => props.hint || props.url)
|
||||
const download = useDownload(props.url)
|
||||
const fileSize = computed(() =>
|
||||
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
|
||||
)
|
||||
</script>
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="no-results-placeholder p-8 h-full" :class="props.class">
|
||||
<div class="no-results-placeholder">
|
||||
<Card>
|
||||
<template #content>
|
||||
<div class="flex flex-col items-center">
|
||||
@@ -22,8 +22,7 @@
|
||||
import Card from 'primevue/card'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const props = defineProps<{
|
||||
class?: string
|
||||
defineProps<{
|
||||
icon?: string
|
||||
title: string
|
||||
message: string
|
||||
@@ -34,6 +33,11 @@ defineEmits(['action'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.no-results-placeholder {
|
||||
height: 100%;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.no-results-placeholder :deep(.p-card) {
|
||||
background-color: var(--surface-ground);
|
||||
text-align: center;
|
||||
|
||||
@@ -35,7 +35,7 @@ import TabPanel from 'primevue/tabpanel'
|
||||
import Divider from 'primevue/divider'
|
||||
import type { SystemStats } from '@/types/apiTypes'
|
||||
import DeviceInfo from '@/components/common/DeviceInfo.vue'
|
||||
import { formatSize } from '@/utils/formatUtil'
|
||||
import { formatMemory } from '@/utils/formatUtil'
|
||||
|
||||
const props = defineProps<{
|
||||
stats: SystemStats
|
||||
@@ -58,7 +58,7 @@ const systemColumns = [
|
||||
|
||||
const formatValue = (value: any, field: string) => {
|
||||
if (['ram_total', 'ram_free'].includes(field)) {
|
||||
return formatSize(value)
|
||||
return formatMemory(value)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
@@ -46,14 +46,10 @@ const onUnmaximize = () => {
|
||||
maximized.value = false
|
||||
}
|
||||
|
||||
const contentProps = computed(() =>
|
||||
maximizable.value
|
||||
? {
|
||||
...dialogStore.props,
|
||||
maximized: maximized.value
|
||||
}
|
||||
: dialogStore.props
|
||||
)
|
||||
const contentProps = computed(() => ({
|
||||
...dialogStore.props,
|
||||
maximized: maximized.value
|
||||
}))
|
||||
|
||||
const headerId = `dialog-${Math.random().toString(36).substr(2, 9)}`
|
||||
</script>
|
||||
|
||||
@@ -1,46 +1,57 @@
|
||||
<template>
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
title="Missing Node Types"
|
||||
message="When loading the graph, the following node types were not found"
|
||||
/>
|
||||
<ListBox
|
||||
:options="uniqueNodes"
|
||||
optionLabel="label"
|
||||
scrollHeight="100%"
|
||||
class="comfy-missing-nodes"
|
||||
:pt="{
|
||||
list: { class: 'border-none' }
|
||||
}"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="flex align-items-center">
|
||||
<span class="node-type">{{ slotProps.option.label }}</span>
|
||||
<span v-if="slotProps.option.hint" class="node-hint">{{
|
||||
slotProps.option.hint
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="slotProps.option.action"
|
||||
@click="slotProps.option.action.callback"
|
||||
:label="slotProps.option.action.text"
|
||||
size="small"
|
||||
outlined
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
<div class="comfy-missing-nodes">
|
||||
<h4 class="warning-title">Warning: Missing Node Types</h4>
|
||||
<p class="warning-description">
|
||||
When loading the graph, the following node types were not found:
|
||||
</p>
|
||||
<ListBox
|
||||
:options="uniqueNodes"
|
||||
optionLabel="label"
|
||||
scrollHeight="100%"
|
||||
:class="'missing-nodes-list' + (props.maximized ? ' maximized' : '')"
|
||||
:pt="{
|
||||
list: { class: 'border-none' }
|
||||
}"
|
||||
>
|
||||
<template #option="slotProps">
|
||||
<div class="missing-node-item">
|
||||
<span class="node-type">{{ slotProps.option.label }}</span>
|
||||
<span v-if="slotProps.option.hint" class="node-hint">{{
|
||||
slotProps.option.hint
|
||||
}}</span>
|
||||
<Button
|
||||
v-if="slotProps.option.action"
|
||||
@click="slotProps.option.action.callback"
|
||||
:label="slotProps.option.action.text"
|
||||
class="p-button-sm p-button-outlined"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ListBox>
|
||||
<p v-if="hasAddedNodes" class="added-nodes-warning">
|
||||
Nodes that have failed to load will show as red on the graph.
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import Button from 'primevue/button'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
interface NodeType {
|
||||
type: string
|
||||
hint?: string
|
||||
action?: {
|
||||
text: string
|
||||
callback: () => void
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
missingNodeTypes: (string | NodeType)[]
|
||||
hasAddedNodes: boolean
|
||||
maximized: boolean
|
||||
}>()
|
||||
|
||||
const uniqueNodes = computed(() => {
|
||||
@@ -65,12 +76,51 @@ const uniqueNodes = computed(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
:root {
|
||||
--red-600: #dc3545;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style scoped>
|
||||
.comfy-missing-nodes {
|
||||
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-nodes-list {
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.missing-nodes-list.maximized {
|
||||
max-height: unset;
|
||||
}
|
||||
|
||||
.missing-node-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.node-type {
|
||||
font-weight: 600;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.node-hint {
|
||||
margin-left: 0.5rem;
|
||||
font-style: italic;
|
||||
@@ -80,4 +130,9 @@ const uniqueNodes = computed(() => {
|
||||
:deep(.p-button) {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.added-nodes-warning {
|
||||
margin-top: 1rem;
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,26 +1,96 @@
|
||||
<template>
|
||||
<NoResultsPlaceholder
|
||||
class="pb-0"
|
||||
icon="pi pi-exclamation-circle"
|
||||
title="Missing Models"
|
||||
message="When loading the graph, the following models were not found"
|
||||
/>
|
||||
<ListBox :options="missingModels" class="comfy-missing-models">
|
||||
<template #option="{ option }">
|
||||
<FileDownload
|
||||
:url="option.url"
|
||||
:label="option.label"
|
||||
:error="option.error"
|
||||
<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>
|
||||
<p class="warning-options">
|
||||
<Checkbox
|
||||
class="model-path-select-checkbox"
|
||||
v-model="showFolderSelect"
|
||||
label="Show folder selector"
|
||||
:binary="true"
|
||||
/>
|
||||
</template>
|
||||
</ListBox>
|
||||
Show folder selector
|
||||
</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">
|
||||
<Select
|
||||
class="model-path-select"
|
||||
v-if="
|
||||
slotProps.option.action &&
|
||||
!slotProps.option.downloading &&
|
||||
!slotProps.option.completed &&
|
||||
!slotProps.option.error &&
|
||||
showFolderSelect
|
||||
"
|
||||
v-model="slotProps.option.folderPath"
|
||||
:options="slotProps.option.paths"
|
||||
@change="updateFolderPath(slotProps.option, $event)"
|
||||
/>
|
||||
<Button
|
||||
v-if="
|
||||
slotProps.option.action &&
|
||||
!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 Checkbox from 'primevue/checkbox'
|
||||
import ListBox from 'primevue/listbox'
|
||||
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
|
||||
import FileDownload from '@/components/common/FileDownload.vue'
|
||||
import Select from 'primevue/select'
|
||||
import { SelectChangeEvent } from 'primevue/select'
|
||||
import Button from 'primevue/button'
|
||||
import { api } from '@/scripts/api'
|
||||
import { DownloadModelStatus } from '@/types/apiTypes'
|
||||
|
||||
const showFolderSelect = ref(false)
|
||||
|
||||
// TODO: Read this from server internal API rather than hardcoding here
|
||||
// as some installations may wish to use custom sources
|
||||
@@ -46,16 +116,90 @@ interface ModelInfo {
|
||||
const props = defineProps<{
|
||||
missingModels: ModelInfo[]
|
||||
paths: Record<string, string[]>
|
||||
maximized: boolean
|
||||
}>()
|
||||
|
||||
const modelDownloads = ref<Record<string, ModelInfo>>({})
|
||||
let lastModel: string | null = null
|
||||
|
||||
const updateFolderPath = (model: any, event: SelectChangeEvent) => {
|
||||
const downloadInfo = modelDownloads.value[model.name]
|
||||
downloadInfo.folder_path = event.value
|
||||
return false
|
||||
}
|
||||
const handleDownloadProgress = (detail: DownloadModelStatus) => {
|
||||
if (detail.download_path) {
|
||||
lastModel = detail.download_path
|
||||
}
|
||||
if (!lastModel) return
|
||||
if (detail.status === 'in_progress') {
|
||||
modelDownloads.value[lastModel] = {
|
||||
...modelDownloads.value[lastModel],
|
||||
downloading: true,
|
||||
progress: detail.progress_percentage,
|
||||
completed: false
|
||||
}
|
||||
} else if (detail.status === 'pending') {
|
||||
modelDownloads.value[lastModel] = {
|
||||
...modelDownloads.value[lastModel],
|
||||
downloading: true,
|
||||
progress: 0,
|
||||
completed: false
|
||||
}
|
||||
} else if (detail.status === 'completed') {
|
||||
modelDownloads.value[lastModel] = {
|
||||
...modelDownloads.value[lastModel],
|
||||
downloading: false,
|
||||
progress: 100,
|
||||
completed: true
|
||||
}
|
||||
} else if (detail.status === 'error') {
|
||||
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,
|
||||
folder_path: string
|
||||
) => {
|
||||
modelDownloads.value[filename] = {
|
||||
name: filename,
|
||||
directory,
|
||||
url,
|
||||
downloading: true,
|
||||
progress: 0
|
||||
}
|
||||
const download = await api.internalDownloadModel(
|
||||
url,
|
||||
directory,
|
||||
filename,
|
||||
1,
|
||||
folder_path
|
||||
)
|
||||
lastModel = filename
|
||||
handleDownloadProgress(download)
|
||||
}
|
||||
|
||||
api.addEventListener('download_progress', (event: CustomEvent) => {
|
||||
handleDownloadProgress(event.detail)
|
||||
})
|
||||
|
||||
const missingModels = computed(() => {
|
||||
return props.missingModels.map((model) => {
|
||||
const paths = props.paths[model.directory]
|
||||
if (model.directory_invalid || !paths) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
hint: model.url,
|
||||
error: 'Invalid directory specified (does this require custom nodes?)'
|
||||
}
|
||||
}
|
||||
@@ -73,35 +217,168 @@ const missingModels = computed(() => {
|
||||
if (!allowedSources.some((source) => model.url.startsWith(source))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
hint: model.url,
|
||||
error: `Download not allowed from source '${model.url}', only allowed from '${allowedSources.join("', '")}'`
|
||||
}
|
||||
}
|
||||
if (!allowedSuffixes.some((suffix) => model.name.endsWith(suffix))) {
|
||||
return {
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
url: model.url,
|
||||
hint: model.url,
|
||||
error: `Only allowed suffixes are: '${allowedSuffixes.join("', '")}'`
|
||||
}
|
||||
}
|
||||
return {
|
||||
url: model.url,
|
||||
label: `${model.directory} / ${model.name}`,
|
||||
hint: model.url,
|
||||
downloading: downloadInfo.downloading,
|
||||
completed: downloadInfo.completed,
|
||||
progress: downloadInfo.progress,
|
||||
error: downloadInfo.error,
|
||||
name: model.name,
|
||||
paths: paths,
|
||||
folderPath: downloadInfo.folder_path
|
||||
folderPath: downloadInfo.folder_path,
|
||||
action: {
|
||||
text: 'Download',
|
||||
callback: () =>
|
||||
triggerDownload(
|
||||
model.url,
|
||||
model.directory,
|
||||
model.name,
|
||||
downloadInfo.folder_path
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
</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;
|
||||
}
|
||||
|
||||
.warning-options {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.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>
|
||||
|
||||
@@ -4,9 +4,6 @@
|
||||
<template #side-bar-panel>
|
||||
<SideToolbar />
|
||||
</template>
|
||||
<template #bottom-panel>
|
||||
<BottomPanel />
|
||||
</template>
|
||||
<template #graph-canvas-panel>
|
||||
<GraphCanvasMenu v-if="canvasMenuEnabled" />
|
||||
</template>
|
||||
@@ -17,17 +14,14 @@
|
||||
</teleport>
|
||||
<NodeSearchboxPopover />
|
||||
<NodeTooltip v-if="tooltipEnabled" />
|
||||
<NodeBadge />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import TitleEditor from '@/components/graph/TitleEditor.vue'
|
||||
import SideToolbar from '@/components/sidebar/SideToolbar.vue'
|
||||
import BottomPanel from '@/components/bottomPanel/BottomPanel.vue'
|
||||
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
|
||||
import NodeSearchboxPopover from '@/components/searchbox/NodeSearchBoxPopover.vue'
|
||||
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
|
||||
import NodeBadge from '@/components/graph/NodeBadge.vue'
|
||||
import { ref, computed, onMounted, watchEffect } from 'vue'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
@@ -62,9 +56,7 @@ const workspaceStore = useWorkspaceStore()
|
||||
const canvasStore = useCanvasStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const betaMenuEnabled = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.UseNewMenu') !== 'Disabled' &&
|
||||
!workspaceStore.focusMode
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
const canvasMenuEnabled = computed(() =>
|
||||
settingStore.get('Comfy.Graph.CanvasMenu')
|
||||
@@ -107,14 +99,6 @@ watchEffect(() => {
|
||||
})
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
const linkRenderMode = settingStore.get('Comfy.LinkRenderMode')
|
||||
if (canvasStore.canvas) {
|
||||
canvasStore.canvas.links_render_mode = linkRenderMode
|
||||
canvasStore.canvas.setDirty(/* fg */ false, /* bg */ true)
|
||||
}
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
if (!canvasStore.canvas) return
|
||||
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- This component does not render anything visible. -->
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import {
|
||||
defaultColorPalette,
|
||||
getColorPalette
|
||||
} from '@/extensions/core/colorPalette'
|
||||
import { app } from '@/scripts/app'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { BadgePosition } from '@comfyorg/litegraph'
|
||||
import { LGraphBadge } from '@comfyorg/litegraph'
|
||||
import _ from 'lodash'
|
||||
import { NodeBadgeMode } from '@/types/nodeSource'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const nodeSourceBadgeMode = computed(
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeIdBadgeMode = computed(
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
const nodeLifeCycleBadgeMode = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeBadge.NodeLifeCycleBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
|
||||
watch([nodeSourceBadgeMode, nodeIdBadgeMode, nodeLifeCycleBadgeMode], () => {
|
||||
app.graph?.setDirtyCanvas(true, true)
|
||||
})
|
||||
|
||||
const colorPalette = computed(() =>
|
||||
getColorPalette(settingStore.get('Comfy.ColorPalette'))
|
||||
)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
function badgeTextVisible(
|
||||
nodeDef: ComfyNodeDefImpl | null,
|
||||
badgeMode: NodeBadgeMode
|
||||
): boolean {
|
||||
return !(
|
||||
badgeMode === NodeBadgeMode.None ||
|
||||
(nodeDef?.isCoreNode && badgeMode === NodeBadgeMode.HideBuiltIn)
|
||||
)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
app.registerExtension({
|
||||
name: 'Comfy.NodeBadge',
|
||||
nodeCreated(node: LGraphNode) {
|
||||
node.badgePosition = BadgePosition.TopRight
|
||||
|
||||
const badge = computed(() => {
|
||||
const nodeDef = nodeDefStore.fromLGraphNode(node)
|
||||
return new LGraphBadge({
|
||||
text: _.truncate(
|
||||
[
|
||||
badgeTextVisible(nodeDef, nodeIdBadgeMode.value)
|
||||
? `#${node.id}`
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeLifeCycleBadgeMode.value)
|
||||
? nodeDef?.nodeLifeCycleBadgeText ?? ''
|
||||
: '',
|
||||
badgeTextVisible(nodeDef, nodeSourceBadgeMode.value)
|
||||
? nodeDef?.nodeSource?.badgeText ?? ''
|
||||
: ''
|
||||
]
|
||||
.filter((s) => s.length > 0)
|
||||
.join(' '),
|
||||
{
|
||||
length: 31
|
||||
}
|
||||
),
|
||||
fgColor:
|
||||
colorPalette.value.colors.litegraph_base?.BADGE_FG_COLOR ||
|
||||
defaultColorPalette.colors.litegraph_base.BADGE_FG_COLOR,
|
||||
bgColor:
|
||||
colorPalette.value.colors.litegraph_base?.BADGE_BG_COLOR ||
|
||||
defaultColorPalette.colors.litegraph_base.BADGE_BG_COLOR
|
||||
})
|
||||
})
|
||||
|
||||
node.badges.push(() => badge.value)
|
||||
}
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -106,6 +106,7 @@ const canvasEventHandler = (event: LiteGraphCanvasEvent) => {
|
||||
const [x, y] = group.pos
|
||||
|
||||
const e = event.detail.originalEvent
|
||||
// @ts-expect-error LiteGraphCanvasEvent is not typed
|
||||
const relativeY = e.canvasY - y
|
||||
// Only allow editing if the click is on the title bar
|
||||
if (relativeY > group.titleHeight) {
|
||||
|
||||
@@ -17,11 +17,20 @@
|
||||
</div>
|
||||
</nav>
|
||||
</teleport>
|
||||
<div
|
||||
v-if="selectedTab"
|
||||
class="sidebar-content-container h-full overflow-y-auto overflow-x-hidden"
|
||||
>
|
||||
<ExtensionSlot :extension="selectedTab" />
|
||||
<div v-if="selectedTab" class="sidebar-content-container">
|
||||
<component v-if="selectedTab.type === 'vue'" :is="selectedTab.component" />
|
||||
<div
|
||||
v-else
|
||||
:ref="
|
||||
(el) => {
|
||||
if (el)
|
||||
mountCustomTab(
|
||||
selectedTab as CustomSidebarTabExtension,
|
||||
el as HTMLElement
|
||||
)
|
||||
}
|
||||
"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -29,11 +38,13 @@
|
||||
import SidebarIcon from './SidebarIcon.vue'
|
||||
import SidebarThemeToggleIcon from './SidebarThemeToggleIcon.vue'
|
||||
import SidebarSettingsToggleIcon from './SidebarSettingsToggleIcon.vue'
|
||||
import ExtensionSlot from '@/components/common/ExtensionSlot.vue'
|
||||
import { computed } from 'vue'
|
||||
import { computed, onBeforeUnmount } from 'vue'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import type { SidebarTabExtension } from '@/types/extensionTypes'
|
||||
import {
|
||||
CustomSidebarTabExtension,
|
||||
SidebarTabExtension
|
||||
} from '@/types/extensionTypes'
|
||||
import { useKeybindingStore } from '@/stores/keybindingStore'
|
||||
|
||||
const workspaceStore = useWorkspaceStore()
|
||||
@@ -51,9 +62,20 @@ const isSmall = computed(
|
||||
|
||||
const tabs = computed(() => workspaceStore.getSidebarTabs())
|
||||
const selectedTab = computed(() => workspaceStore.sidebarTab.activeSidebarTab)
|
||||
const mountCustomTab = (tab: CustomSidebarTabExtension, el: HTMLElement) => {
|
||||
tab.render(el)
|
||||
}
|
||||
const onTabClick = (item: SidebarTabExtension) => {
|
||||
workspaceStore.sidebarTab.toggleSidebarTab(item.id)
|
||||
}
|
||||
onBeforeUnmount(() => {
|
||||
tabs.value.forEach((tab) => {
|
||||
if (tab.type === 'custom' && tab.destroy) {
|
||||
tab.destroy()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const keybindingStore = useKeybindingStore()
|
||||
const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
||||
const keybinding = keybindingStore.getKeybindingByCommandId(
|
||||
@@ -93,4 +115,9 @@ const getTabTooltipSuffix = (tab: SidebarTabExtension) => {
|
||||
align-self: flex-end;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.sidebar-content-container {
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,28 +3,11 @@
|
||||
:title="$t('sideToolbar.modelLibrary')"
|
||||
class="bg-[var(--p-tree-background)]"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
icon="pi pi-refresh"
|
||||
@click="modelStore.loadModelFolders"
|
||||
severity="secondary"
|
||||
text
|
||||
v-tooltip="$t('refresh')"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-cloud-download"
|
||||
@click="modelStore.loadModels"
|
||||
severity="secondary"
|
||||
text
|
||||
v-tooltip="$t('loadAllFolders')"
|
||||
/>
|
||||
</template>
|
||||
<template #header>
|
||||
<SearchBox
|
||||
class="model-lib-search-box p-4"
|
||||
v-model:modelValue="searchQuery"
|
||||
:placeholder="$t('searchModels') + '...'"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
</template>
|
||||
<template #body>
|
||||
@@ -32,6 +15,7 @@
|
||||
class="model-lib-tree-explorer py-0"
|
||||
:roots="renderedRoot.children"
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
@nodeClick="handleNodeClick"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<ModelTreeLeaf :node="node" />
|
||||
@@ -43,17 +27,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import SearchBox from '@/components/common/SearchBox.vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
|
||||
import {
|
||||
ComfyModelDef,
|
||||
ModelFolder,
|
||||
ResourceState,
|
||||
useModelStore
|
||||
} from '@/stores/modelStore'
|
||||
import { ComfyModelDef, useModelStore } from '@/stores/modelStore'
|
||||
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useTreeExpansion } from '@/hooks/treeHooks'
|
||||
@@ -61,56 +40,77 @@ import type {
|
||||
RenderedTreeExplorerNode,
|
||||
TreeExplorerNode
|
||||
} from '@/types/treeExplorerTypes'
|
||||
import { computed, ref, watch, toRef, onMounted, nextTick } from 'vue'
|
||||
import { computed, ref, type ComputedRef, watch, toRef } from 'vue'
|
||||
import type { TreeNode } from 'primevue/treenode'
|
||||
import { app } from '@/scripts/app'
|
||||
import { buildTree } from '@/utils/treeUtil'
|
||||
const { t } = useI18n()
|
||||
const modelStore = useModelStore()
|
||||
const modelToNodeStore = useModelToNodeStore()
|
||||
const settingStore = useSettingStore()
|
||||
const searchQuery = ref<string>('')
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
const { toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
|
||||
const filteredModels = ref<ComfyModelDef[]>([])
|
||||
const handleSearch = async (query: string) => {
|
||||
if (!query) {
|
||||
filteredModels.value = []
|
||||
expandedKeys.value = {}
|
||||
return
|
||||
const root: ComputedRef<TreeNode> = computed(() => {
|
||||
let modelList: ComfyModelDef[] = []
|
||||
if (!modelStore.modelFolders.length) {
|
||||
modelStore.getModelFolders()
|
||||
}
|
||||
// Load all models to ensure we have the latest data
|
||||
await modelStore.loadModels()
|
||||
const search = query.toLocaleLowerCase()
|
||||
filteredModels.value = modelStore.models.filter((model: ComfyModelDef) => {
|
||||
return model.searchable.includes(search)
|
||||
})
|
||||
|
||||
nextTick(() => {
|
||||
expandNode(root.value)
|
||||
})
|
||||
}
|
||||
|
||||
type ModelOrFolder = ComfyModelDef | ModelFolder
|
||||
|
||||
const root = computed<TreeNode>(() => {
|
||||
const allNodes: ModelOrFolder[] = [
|
||||
...modelStore.modelFolders,
|
||||
...(searchQuery.value ? filteredModels.value : modelStore.models)
|
||||
]
|
||||
return buildTree(allNodes, (modelOrFolder: ModelOrFolder) =>
|
||||
modelOrFolder.key.split('/')
|
||||
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
|
||||
for (let folder of modelStore.modelFolders) {
|
||||
modelStore.getModelsInFolderCached(folder)
|
||||
}
|
||||
}
|
||||
for (let folder of modelStore.modelFolders) {
|
||||
const models = modelStore.modelStoreMap[folder]
|
||||
if (models) {
|
||||
if (Object.values(models.models).length) {
|
||||
modelList.push(...Object.values(models.models))
|
||||
} else {
|
||||
// ModelDef with key 'folder/a/b/c/' is treated as empty folder
|
||||
const fakeModel = new ComfyModelDef('', folder)
|
||||
fakeModel.is_fake_object = true
|
||||
modelList.push(fakeModel)
|
||||
}
|
||||
} else {
|
||||
const fakeModel = new ComfyModelDef('Loading', folder)
|
||||
fakeModel.is_fake_object = true
|
||||
modelList.push(fakeModel)
|
||||
}
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
const search = searchQuery.value.toLocaleLowerCase()
|
||||
modelList = modelList.filter((model: ComfyModelDef) => {
|
||||
return model.searchable.includes(search)
|
||||
})
|
||||
}
|
||||
const tree: TreeNode = buildTree(modelList, (model: ComfyModelDef) =>
|
||||
model.key.split('/')
|
||||
)
|
||||
return tree
|
||||
})
|
||||
|
||||
const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
const renderedRoot = computed<TreeExplorerNode<ComfyModelDef>>(() => {
|
||||
const nameFormat = settingStore.get('Comfy.ModelLibrary.NameFormat')
|
||||
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ModelOrFolder> => {
|
||||
const fillNodeInfo = (node: TreeNode): TreeExplorerNode<ComfyModelDef> => {
|
||||
const children = node.children?.map(fillNodeInfo)
|
||||
const model: ComfyModelDef | null =
|
||||
node.leaf && node.data ? node.data : null
|
||||
const folder: ModelFolder | null =
|
||||
!node.leaf && node.data ? node.data : null
|
||||
if (model?.is_fake_object) {
|
||||
if (model.file_name === 'Loading') {
|
||||
return {
|
||||
key: node.key,
|
||||
label: t('loading') + '...',
|
||||
leaf: true,
|
||||
data: node.data,
|
||||
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
|
||||
return 'pi pi-spin pi-spinner'
|
||||
},
|
||||
children: []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
key: node.key,
|
||||
@@ -121,29 +121,32 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
: node.label,
|
||||
leaf: node.leaf,
|
||||
data: node.data,
|
||||
getIcon: () => {
|
||||
if (model) {
|
||||
return model.image ? 'pi pi-image' : 'pi pi-file'
|
||||
getIcon: (node: TreeExplorerNode<ComfyModelDef>) => {
|
||||
if (node.leaf) {
|
||||
if (node.data && node.data.image) {
|
||||
return 'pi pi-fake-spacer'
|
||||
}
|
||||
return 'pi pi-file'
|
||||
}
|
||||
if (folder) {
|
||||
return folder.state === ResourceState.Loading
|
||||
? 'pi pi-spin pi-spinner'
|
||||
: 'pi pi-folder'
|
||||
}
|
||||
return 'pi pi-folder'
|
||||
},
|
||||
getBadgeText: () => {
|
||||
// Return null to apply default badge text
|
||||
// Return empty string to hide badge
|
||||
if (!folder) {
|
||||
getBadgeText: (node: TreeExplorerNode<ComfyModelDef>) => {
|
||||
if (node.leaf) {
|
||||
return null
|
||||
}
|
||||
return folder.state === ResourceState.Loaded ? null : ''
|
||||
if (node.children?.length === 1) {
|
||||
const onlyChild = node.children[0]
|
||||
if (onlyChild.data?.is_fake_object) {
|
||||
if (onlyChild.data.file_name === 'Loading') {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
children,
|
||||
draggable: node.leaf,
|
||||
handleClick: (
|
||||
node: RenderedTreeExplorerNode<ModelOrFolder>,
|
||||
node: RenderedTreeExplorerNode<ComfyModelDef>,
|
||||
e: MouseEvent
|
||||
) => {
|
||||
if (node.leaf) {
|
||||
@@ -159,8 +162,6 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
widget.value = model.file_name
|
||||
}
|
||||
}
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -168,6 +169,17 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
|
||||
return fillNodeInfo(root.value)
|
||||
})
|
||||
|
||||
const handleNodeClick = (
|
||||
node: RenderedTreeExplorerNode<ComfyModelDef>,
|
||||
e: MouseEvent
|
||||
) => {
|
||||
if (node.leaf) {
|
||||
// TODO
|
||||
} else {
|
||||
toggleNodeOnEvent(e, node)
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
toRef(expandedKeys, 'value'),
|
||||
(newExpandedKeys) => {
|
||||
@@ -176,19 +188,13 @@ watch(
|
||||
const folderPath = key.split('/').slice(1).join('/')
|
||||
if (folderPath && !folderPath.includes('/')) {
|
||||
// Trigger (async) load of model data for this folder
|
||||
modelStore.getLoadedModelFolder(folderPath)
|
||||
modelStore.getModelsInFolderCached(folderPath)
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
|
||||
onMounted(async () => {
|
||||
if (settingStore.get('Comfy.ModelLibrary.AutoLoadAll')) {
|
||||
await modelStore.loadModels()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
</div>
|
||||
<!-- h-0 to force scrollpanel to flex-grow -->
|
||||
<ScrollPanel class="comfy-vue-side-bar-body flex-grow h-0">
|
||||
<slot name="body"></slot>
|
||||
<div class="h-full">
|
||||
<slot name="body"></slot>
|
||||
</div>
|
||||
</ScrollPanel>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -57,6 +57,9 @@ const sidebarLocation = computed<'left' | 'right'>(() =>
|
||||
)
|
||||
|
||||
const handleModelHover = async () => {
|
||||
if (modelDef.value.is_fake_object) {
|
||||
return
|
||||
}
|
||||
const hoverTarget = modelContentElement.value
|
||||
const targetRect = hoverTarget.getBoundingClientRect()
|
||||
|
||||
@@ -84,6 +87,7 @@ const showPreview = computed(() => {
|
||||
return (
|
||||
isHovered.value &&
|
||||
modelDef.value &&
|
||||
!modelDef.value.is_fake_object &&
|
||||
modelDef.value.has_loaded_metadata &&
|
||||
(modelDef.value.author ||
|
||||
modelDef.value.simplified_file_name != modelDef.value.title ||
|
||||
@@ -95,6 +99,9 @@ const showPreview = computed(() => {
|
||||
})
|
||||
|
||||
const handleMouseEnter = async () => {
|
||||
if (modelDef.value.is_fake_object) {
|
||||
return
|
||||
}
|
||||
isHovered.value = true
|
||||
await nextTick()
|
||||
handleModelHover()
|
||||
@@ -106,7 +113,9 @@ onMounted(() => {
|
||||
modelContentElement.value = container.value?.closest('.p-tree-node-content')
|
||||
modelContentElement.value?.addEventListener('mouseenter', handleMouseEnter)
|
||||
modelContentElement.value?.addEventListener('mouseleave', handleMouseLeave)
|
||||
modelDef.value.load()
|
||||
if (!modelDef.value.is_fake_object) {
|
||||
modelDef.value.load()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
|
||||
@@ -144,7 +144,7 @@ const renderedBookmarkedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(
|
||||
return 'pi pi-circle-fill'
|
||||
}
|
||||
const customization =
|
||||
nodeBookmarkStore.bookmarksCustomization[node.data?.nodePath]
|
||||
nodeBookmarkStore.bookmarksCustomization[node.data.nodePath]
|
||||
return customization?.icon
|
||||
? 'pi ' + customization.icon
|
||||
: 'pi pi-bookmark-fill'
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="bottomPanelStore.bottomPanelTabs.length > 0"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="bottomPanelStore.toggleBottomPanel"
|
||||
v-tooltip="{ value: $t('menu.toggleBottomPanel'), showDelay: 300 }"
|
||||
>
|
||||
<template #icon>
|
||||
<i-material-symbols:dock-to-bottom
|
||||
v-if="bottomPanelStore.bottomPanelVisible"
|
||||
/>
|
||||
<i-material-symbols:dock-to-bottom-outline v-else />
|
||||
</template>
|
||||
</Button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
|
||||
import Button from 'primevue/button'
|
||||
|
||||
const bottomPanelStore = useBottomPanelStore()
|
||||
</script>
|
||||
@@ -14,7 +14,6 @@
|
||||
</div>
|
||||
<div class="comfyui-menu-right" ref="menuRight"></div>
|
||||
<Actionbar />
|
||||
<BottomPanelToggleButton />
|
||||
</div>
|
||||
</teleport>
|
||||
</template>
|
||||
@@ -24,22 +23,17 @@ import Divider from 'primevue/divider'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
|
||||
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton.vue'
|
||||
import { computed, onMounted, provide, ref } from 'vue'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useEventBus } from '@vueuse/core'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
|
||||
|
||||
const workspaceState = useWorkspaceStore()
|
||||
const settingStore = useSettingStore()
|
||||
const workflowTabsPosition = computed(() =>
|
||||
settingStore.get('Comfy.Workflow.WorkflowTabsPosition')
|
||||
)
|
||||
const betaMenuEnabled = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.UseNewMenu') !== 'Disabled' &&
|
||||
!workspaceState.focusMode
|
||||
() => settingStore.get('Comfy.UseNewMenu') !== 'Disabled'
|
||||
)
|
||||
const teleportTarget = computed(() =>
|
||||
settingStore.get('Comfy.UseNewMenu') === 'Top'
|
||||
|
||||
@@ -1,20 +1,13 @@
|
||||
// @ts-strict-ignore
|
||||
import { app } from '../../scripts/app'
|
||||
import { ComfyDialog, $el } from '../../scripts/ui'
|
||||
import { ComfyApp } from '../../scripts/app'
|
||||
|
||||
export class ClipspaceDialog extends ComfyDialog {
|
||||
static items: Array<
|
||||
HTMLButtonElement & {
|
||||
contextPredicate?: () => boolean
|
||||
}
|
||||
> = []
|
||||
static instance: ClipspaceDialog | null = null
|
||||
static items = []
|
||||
static instance = null
|
||||
|
||||
static registerButton(
|
||||
name: string,
|
||||
contextPredicate: () => boolean,
|
||||
callback: () => void
|
||||
) {
|
||||
static registerButton(name, contextPredicate, callback) {
|
||||
const item = $el('button', {
|
||||
type: 'button',
|
||||
textContent: name,
|
||||
@@ -54,9 +47,7 @@ export class ClipspaceDialog extends ComfyDialog {
|
||||
|
||||
if (self.element) {
|
||||
// update
|
||||
if (self.element.firstChild) {
|
||||
self.element.removeChild(self.element.firstChild)
|
||||
}
|
||||
self.element.removeChild(self.element.firstChild)
|
||||
self.element.appendChild(children)
|
||||
} else {
|
||||
// new
|
||||
@@ -104,7 +95,7 @@ export class ClipspaceDialog extends ComfyDialog {
|
||||
}
|
||||
|
||||
createImgSettings() {
|
||||
if (ComfyApp.clipspace?.imgs) {
|
||||
if (ComfyApp.clipspace.imgs) {
|
||||
const combo_items = []
|
||||
const imgs = ComfyApp.clipspace.imgs
|
||||
|
||||
@@ -116,13 +107,9 @@ export class ClipspaceDialog extends ComfyDialog {
|
||||
'select',
|
||||
{
|
||||
id: 'clipspace_img_selector',
|
||||
onchange: (event: Event) => {
|
||||
if (event.target && ComfyApp.clipspace) {
|
||||
ComfyApp.clipspace['selectedIndex'] = (
|
||||
event.target as HTMLSelectElement
|
||||
).selectedIndex
|
||||
ClipspaceDialog.invalidatePreview()
|
||||
}
|
||||
onchange: (event) => {
|
||||
ComfyApp.clipspace['selectedIndex'] = event.target.selectedIndex
|
||||
ClipspaceDialog.invalidatePreview()
|
||||
}
|
||||
},
|
||||
combo_items
|
||||
@@ -137,12 +124,8 @@ export class ClipspaceDialog extends ComfyDialog {
|
||||
'select',
|
||||
{
|
||||
id: 'clipspace_img_paste_mode',
|
||||
onchange: (event: Event) => {
|
||||
if (event.target && ComfyApp.clipspace) {
|
||||
ComfyApp.clipspace['img_paste_mode'] = (
|
||||
event.target as HTMLSelectElement
|
||||
).value
|
||||
}
|
||||
onchange: (event) => {
|
||||
ComfyApp.clipspace['img_paste_mode'] = event.target.value
|
||||
}
|
||||
},
|
||||
[
|
||||
@@ -172,7 +155,7 @@ export class ClipspaceDialog extends ComfyDialog {
|
||||
}
|
||||
|
||||
createImgPreview() {
|
||||
if (ComfyApp.clipspace?.imgs) {
|
||||
if (ComfyApp.clipspace.imgs) {
|
||||
return $el('img', { id: 'clipspace_preview', ondragstart: () => false })
|
||||
} else return []
|
||||
}
|
||||
@@ -188,6 +171,7 @@ export class ClipspaceDialog extends ComfyDialog {
|
||||
app.registerExtension({
|
||||
name: 'Comfy.Clipspace',
|
||||
init(app) {
|
||||
// @ts-expect-error Move to ComfyApp
|
||||
app.openClipspace = function () {
|
||||
if (!ClipspaceDialog.instance) {
|
||||
ClipspaceDialog.instance = new ClipspaceDialog()
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import { LiteGraph, LGraphCanvas } from '@comfyorg/litegraph'
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
@@ -32,14 +33,14 @@ const ext = {
|
||||
const clickedComboValue = currentNode.widgets
|
||||
?.filter(
|
||||
(w) =>
|
||||
w.type === 'combo' && w.options.values?.length === values.length
|
||||
w.type === 'combo' && w.options.values.length === values.length
|
||||
)
|
||||
.find((w) =>
|
||||
w.options.values?.every((v, i) => v === values[i])
|
||||
w.options.values.every((v, i) => v === values[i])
|
||||
)?.value
|
||||
|
||||
let selectedIndex = clickedComboValue
|
||||
? values.findIndex((v: string) => v === clickedComboValue)
|
||||
? values.findIndex((v) => v === clickedComboValue)
|
||||
: 0
|
||||
if (selectedIndex < 0) {
|
||||
selectedIndex = 0
|
||||
@@ -121,7 +122,7 @@ const ext = {
|
||||
// When filtering, recompute which items are visible for arrow up/down and maintain selection.
|
||||
displayedItems = items.filter((item) => {
|
||||
const isVisible =
|
||||
!term || item.textContent?.toLocaleLowerCase().includes(term)
|
||||
!term || item.textContent.toLocaleLowerCase().includes(term)
|
||||
item.style.display = isVisible ? 'block' : 'none'
|
||||
return isVisible
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import { app } from '../../scripts/app'
|
||||
|
||||
// Allows you to edit the attention weight by holding ctrl (or cmd) and using the up/down arrow keys
|
||||
@@ -17,22 +18,14 @@ app.registerExtension({
|
||||
defaultValue: 0.05
|
||||
})
|
||||
|
||||
function incrementWeight(weight: string, delta: number): string {
|
||||
function incrementWeight(weight, delta) {
|
||||
const floatWeight = parseFloat(weight)
|
||||
if (isNaN(floatWeight)) return weight
|
||||
const newWeight = floatWeight + delta
|
||||
return String(Number(newWeight.toFixed(10)))
|
||||
}
|
||||
|
||||
type Enclosure = {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
function findNearestEnclosure(
|
||||
text: string,
|
||||
cursorPos: number
|
||||
): Enclosure | null {
|
||||
function findNearestEnclosure(text, cursorPos) {
|
||||
let start = cursorPos,
|
||||
end = cursorPos
|
||||
let openCount = 0,
|
||||
@@ -45,7 +38,7 @@ app.registerExtension({
|
||||
if (text[start] === '(') openCount++
|
||||
if (text[start] === ')') closeCount++
|
||||
}
|
||||
if (start < 0) return null
|
||||
if (start < 0) return false
|
||||
|
||||
openCount = 0
|
||||
closeCount = 0
|
||||
@@ -57,12 +50,12 @@ app.registerExtension({
|
||||
if (text[end] === ')') closeCount++
|
||||
end++
|
||||
}
|
||||
if (end === text.length) return null
|
||||
if (end === text.length) return false
|
||||
|
||||
return { start: start + 1, end: end }
|
||||
}
|
||||
|
||||
function addWeightToParentheses(text: string): string {
|
||||
function addWeightToParentheses(text) {
|
||||
const parenRegex = /^\((.*)\)$/
|
||||
const parenMatch = text.match(parenRegex)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import './groupNodeManage'
|
||||
import './groupOptions'
|
||||
import './invertMenuScrolling'
|
||||
import './keybinds'
|
||||
import './linkRenderMode'
|
||||
import './maskeditor'
|
||||
import './nodeTemplates'
|
||||
import './noteNode'
|
||||
@@ -20,3 +21,4 @@ import './uploadImage'
|
||||
import './webcamCapture'
|
||||
import './widgetInputs'
|
||||
import './uploadAudio'
|
||||
import './nodeBadge'
|
||||
|
||||
28
src/extensions/core/linkRenderMode.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// @ts-strict-ignore
|
||||
import { app } from '../../scripts/app'
|
||||
import { LiteGraph } from '@comfyorg/litegraph'
|
||||
const id = 'Comfy.LinkRenderMode'
|
||||
const ext = {
|
||||
name: id,
|
||||
async setup(app) {
|
||||
app.ui.settings.addSetting({
|
||||
id,
|
||||
category: ['Comfy', 'Graph', 'LinkRenderMode'],
|
||||
name: 'Link Render Mode',
|
||||
defaultValue: 2,
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ value: LiteGraph.STRAIGHT_LINK, text: 'Straight' },
|
||||
{ value: LiteGraph.LINEAR_LINK, text: 'Linear' },
|
||||
{ value: LiteGraph.SPLINE_LINK, text: 'Spline' },
|
||||
{ value: LiteGraph.HIDDEN_LINK, text: 'Hidden' }
|
||||
],
|
||||
onChange(value: number) {
|
||||
app.canvas.links_render_mode = +value
|
||||
app.canvas.setDirty(/* fg */ false, /* bg */ true)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension(ext)
|
||||
150
src/extensions/core/nodeBadge.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// @ts-strict-ignore
|
||||
import { app, type ComfyApp } from '@/scripts/app'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { LGraphBadge } from '@comfyorg/litegraph'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { computed, ComputedRef, watch } from 'vue'
|
||||
import { NodeBadgeMode, NodeSource, NodeSourceType } from '@/types/nodeSource'
|
||||
import _ from 'lodash'
|
||||
import { getColorPalette, defaultColorPalette } from './colorPalette'
|
||||
import { BadgePosition } from '@comfyorg/litegraph'
|
||||
import type { Palette } from '@/types/colorPalette'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
|
||||
function getNodeSource(node: LGraphNode): NodeSource | null {
|
||||
const nodeDef = node.constructor.nodeData
|
||||
// Frontend-only nodes don't have nodeDef
|
||||
if (!nodeDef) {
|
||||
return null
|
||||
}
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
return nodeDefStore.nodeDefsByName[nodeDef.name]?.nodeSource ?? null
|
||||
}
|
||||
|
||||
function isCoreNode(node: LGraphNode) {
|
||||
return getNodeSource(node)?.type === NodeSourceType.Core
|
||||
}
|
||||
|
||||
function badgeTextVisible(node: LGraphNode, badgeMode: NodeBadgeMode): boolean {
|
||||
return (
|
||||
badgeMode === NodeBadgeMode.None ||
|
||||
(isCoreNode(node) && badgeMode === NodeBadgeMode.HideBuiltIn)
|
||||
)
|
||||
}
|
||||
|
||||
function getNodeIdBadgeText(node: LGraphNode, nodeIdBadgeMode: NodeBadgeMode) {
|
||||
return badgeTextVisible(node, nodeIdBadgeMode) ? '' : `#${node.id}`
|
||||
}
|
||||
|
||||
function getNodeSourceBadgeText(
|
||||
node: LGraphNode,
|
||||
nodeSourceBadgeMode: NodeBadgeMode
|
||||
) {
|
||||
const nodeSource = getNodeSource(node)
|
||||
return badgeTextVisible(node, nodeSourceBadgeMode)
|
||||
? ''
|
||||
: nodeSource?.badgeText ?? ''
|
||||
}
|
||||
|
||||
function getNodeLifeCycleBadgeText(
|
||||
node: LGraphNode,
|
||||
nodeLifeCycleBadgeMode: NodeBadgeMode
|
||||
) {
|
||||
let text = ''
|
||||
const nodeDef = node.constructor.nodeData
|
||||
|
||||
// Frontend-only nodes don't have nodeDef
|
||||
if (!nodeDef) {
|
||||
return ''
|
||||
}
|
||||
|
||||
if (nodeDef.deprecated) {
|
||||
text = '[DEPR]'
|
||||
}
|
||||
|
||||
if (nodeDef.experimental) {
|
||||
text = '[BETA]'
|
||||
}
|
||||
|
||||
return badgeTextVisible(node, nodeLifeCycleBadgeMode) ? '' : text
|
||||
}
|
||||
|
||||
class NodeBadgeExtension implements ComfyExtension {
|
||||
name = 'Comfy.NodeBadge'
|
||||
|
||||
constructor(
|
||||
public nodeIdBadgeMode: ComputedRef<NodeBadgeMode> | null = null,
|
||||
public nodeSourceBadgeMode: ComputedRef<NodeBadgeMode> | null = null,
|
||||
public nodeLifeCycleBadgeMode: ComputedRef<NodeBadgeMode> | null = null,
|
||||
public colorPalette: ComputedRef<Palette> | null = null
|
||||
) {}
|
||||
|
||||
init(app: ComfyApp) {
|
||||
const settingStore = useSettingStore()
|
||||
this.nodeSourceBadgeMode = computed(
|
||||
() =>
|
||||
settingStore.get('Comfy.NodeBadge.NodeSourceBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
this.nodeIdBadgeMode = computed(
|
||||
() => settingStore.get('Comfy.NodeBadge.NodeIdBadgeMode') as NodeBadgeMode
|
||||
)
|
||||
this.nodeLifeCycleBadgeMode = computed(
|
||||
() =>
|
||||
settingStore.get(
|
||||
'Comfy.NodeBadge.NodeLifeCycleBadgeMode'
|
||||
) as NodeBadgeMode
|
||||
)
|
||||
this.colorPalette = computed(() =>
|
||||
getColorPalette(settingStore.get('Comfy.ColorPalette'))
|
||||
)
|
||||
|
||||
watch(this.nodeSourceBadgeMode, () => {
|
||||
app.graph.setDirtyCanvas(true, true)
|
||||
})
|
||||
|
||||
watch(this.nodeIdBadgeMode, () => {
|
||||
app.graph.setDirtyCanvas(true, true)
|
||||
})
|
||||
watch(this.nodeLifeCycleBadgeMode, () => {
|
||||
app.graph.setDirtyCanvas(true, true)
|
||||
})
|
||||
}
|
||||
|
||||
nodeCreated(node: LGraphNode, app: ComfyApp) {
|
||||
node.badgePosition = BadgePosition.TopRight
|
||||
// @ts-expect-error Disable ComfyUI-Manager's badge drawing by setting badge_enabled to true. Remove this when ComfyUI-Manager's badge drawing is removed.
|
||||
node.badge_enabled = true
|
||||
|
||||
const badge = computed(
|
||||
() =>
|
||||
new LGraphBadge({
|
||||
text: _.truncate(
|
||||
[
|
||||
getNodeIdBadgeText(node, this.nodeIdBadgeMode.value),
|
||||
getNodeLifeCycleBadgeText(
|
||||
node,
|
||||
this.nodeLifeCycleBadgeMode.value
|
||||
),
|
||||
getNodeSourceBadgeText(node, this.nodeSourceBadgeMode.value)
|
||||
]
|
||||
.filter((s) => s.length > 0)
|
||||
.join(' '),
|
||||
{
|
||||
length: 31
|
||||
}
|
||||
),
|
||||
fgColor:
|
||||
this.colorPalette.value.colors.litegraph_base?.BADGE_FG_COLOR ||
|
||||
defaultColorPalette.colors.litegraph_base.BADGE_FG_COLOR,
|
||||
bgColor:
|
||||
this.colorPalette.value.colors.litegraph_base?.BADGE_BG_COLOR ||
|
||||
defaultColorPalette.colors.litegraph_base.BADGE_BG_COLOR
|
||||
})
|
||||
)
|
||||
|
||||
node.badges.push(() => badge.value)
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension(new NodeBadgeExtension())
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import { app } from '../../scripts/app'
|
||||
import { ComfyWidgets } from '../../scripts/widgets'
|
||||
import { LiteGraph } from '@comfyorg/litegraph'
|
||||
@@ -30,7 +31,7 @@ app.registerExtension({
|
||||
slot_types_default_in: {},
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
var nodeId = nodeData.name
|
||||
const inputs = nodeData['input']?.['required'] //only show required inputs to reduce the mess also not logical to create node with optional inputs
|
||||
const inputs = nodeData['input']['required'] //only show required inputs to reduce the mess also not logical to create node with optional inputs
|
||||
for (const inputKey in inputs) {
|
||||
var input = inputs[inputKey]
|
||||
if (typeof input[0] !== 'string') continue
|
||||
@@ -58,9 +59,9 @@ app.registerExtension({
|
||||
)
|
||||
}
|
||||
|
||||
var outputs = nodeData['output'] ?? []
|
||||
for (const el of outputs) {
|
||||
const type = el as string
|
||||
var outputs = nodeData['output']
|
||||
for (const key in outputs) {
|
||||
var type = outputs[key] as string
|
||||
if (!(type in this.slot_types_default_in)) {
|
||||
this.slot_types_default_in[type] = ['Reroute'] // ["Reroute", "Primitive"]; primitive doesn't always work :'()
|
||||
}
|
||||
@@ -77,11 +78,10 @@ app.registerExtension({
|
||||
LiteGraph.slot_types_out.push(type)
|
||||
}
|
||||
}
|
||||
|
||||
var maxNum = this.suggestionsNumber.value
|
||||
this.setDefaults(maxNum)
|
||||
},
|
||||
setDefaults(maxNum?: number | null) {
|
||||
setDefaults(maxNum) {
|
||||
LiteGraph.slot_types_default_out = {}
|
||||
LiteGraph.slot_types_default_in = {}
|
||||
|
||||
|
||||
@@ -100,7 +100,8 @@ app.registerExtension({
|
||||
} else {
|
||||
w = node.size[0]
|
||||
h = node.size[1]
|
||||
const titleMode = node.constructor.title_mode
|
||||
// @ts-expect-error
|
||||
let titleMode = node.constructor.title_mode
|
||||
if (
|
||||
titleMode !== LiteGraph.TRANSPARENT_TITLE &&
|
||||
titleMode !== LiteGraph.NO_TITLE
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import { app } from '../../scripts/app'
|
||||
import { ComfyNodeDef } from '@/types/apiTypes'
|
||||
|
||||
@@ -5,7 +6,7 @@ import { ComfyNodeDef } from '@/types/apiTypes'
|
||||
|
||||
app.registerExtension({
|
||||
name: 'Comfy.UploadImage',
|
||||
beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef) {
|
||||
async beforeRegisterNodeDef(nodeType, nodeData: ComfyNodeDef, app) {
|
||||
if (nodeData?.input?.required?.image?.[1]?.image_upload === true) {
|
||||
nodeData.input.required.upload = ['IMAGEUPLOAD']
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { markRaw } from 'vue'
|
||||
import IntegratedTerminal from '@/components/bottomPanel/tabs/IntegratedTerminal.vue'
|
||||
import { BottomPanelExtension } from '@/types/extensionTypes'
|
||||
|
||||
export const useIntegratedTerminalTab = (): BottomPanelExtension => {
|
||||
const { t } = useI18n()
|
||||
return {
|
||||
id: 'integrated-terminal',
|
||||
title: t('terminal'),
|
||||
component: markRaw(IntegratedTerminal),
|
||||
type: 'vue'
|
||||
}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
|
||||
export function useDownload(url: string, fileName?: string) {
|
||||
const fileSize = ref<number | null>(null)
|
||||
|
||||
const fetchFileSize = async (): Promise<number | null> => {
|
||||
try {
|
||||
const response = await fetch(url, { method: 'HEAD' })
|
||||
if (!response.ok) throw new Error('Failed to fetch file size')
|
||||
|
||||
const size = response.headers.get('content-length')
|
||||
if (size) {
|
||||
return parseInt(size)
|
||||
} else {
|
||||
console.error('"content-length" header not found')
|
||||
return null
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error fetching file size:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger browser download
|
||||
*/
|
||||
const triggerBrowserDownload = () => {
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = fileName || url.split('/').pop() || 'download'
|
||||
link.target = '_blank' // Opens in new tab if download attribute is not supported
|
||||
link.rel = 'noopener noreferrer' // Security best practice for _blank links
|
||||
link.click()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
fileSize.value = await fetchFileSize()
|
||||
})
|
||||
|
||||
return {
|
||||
triggerBrowserDownload,
|
||||
fileSize
|
||||
}
|
||||
}
|
||||
18
src/i18n.ts
@@ -2,10 +2,6 @@ import { createI18n } from 'vue-i18n'
|
||||
|
||||
const messages = {
|
||||
en: {
|
||||
download: 'Download',
|
||||
loadAllFolders: 'Load All Folders',
|
||||
refresh: 'Refresh',
|
||||
terminal: 'Terminal',
|
||||
videoFailedToLoad: 'Video failed to load',
|
||||
extensionName: 'Extension Name',
|
||||
reloadToApplyChanges: 'Reload to apply changes',
|
||||
@@ -95,8 +91,7 @@ const messages = {
|
||||
refresh: 'Refresh node definitions',
|
||||
clipspace: 'Open Clipspace',
|
||||
resetView: 'Reset canvas view',
|
||||
clear: 'Clear workflow',
|
||||
toggleBottomPanel: 'Toggle Bottom Panel'
|
||||
clear: 'Clear workflow'
|
||||
},
|
||||
templateWorkflows: {
|
||||
title: 'Get Started with a Template',
|
||||
@@ -117,10 +112,6 @@ const messages = {
|
||||
}
|
||||
},
|
||||
zh: {
|
||||
download: '下载',
|
||||
loadAllFolders: '加载所有文件夹',
|
||||
refresh: '刷新',
|
||||
terminal: '终端',
|
||||
videoFailedToLoad: '视频加载失败',
|
||||
extensionName: '扩展名称',
|
||||
reloadToApplyChanges: '重新加载以应用更改',
|
||||
@@ -209,8 +200,7 @@ const messages = {
|
||||
refresh: '刷新节点',
|
||||
clipspace: '打开剪贴板',
|
||||
resetView: '重置画布视图',
|
||||
clear: '清空工作流',
|
||||
toggleBottomPanel: '底部面板'
|
||||
clear: '清空工作流'
|
||||
},
|
||||
templateWorkflows: {
|
||||
title: '从模板开始',
|
||||
@@ -231,10 +221,6 @@ const messages = {
|
||||
}
|
||||
},
|
||||
ru: {
|
||||
download: 'Скачать',
|
||||
refresh: 'Обновить',
|
||||
loadAllFolders: 'Загрузить все папки',
|
||||
terminal: 'Терминал',
|
||||
videoFailedToLoad: 'Видео не удалось загрузить',
|
||||
extensionName: 'Название расширения',
|
||||
reloadToApplyChanges: 'Перезагрузите, чтобы применить изменения',
|
||||
|
||||
@@ -1,17 +1,19 @@
|
||||
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
|
||||
// @ts-strict-ignore
|
||||
import { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
|
||||
import {
|
||||
type HistoryTaskItem,
|
||||
type PendingTaskItem,
|
||||
type RunningTaskItem,
|
||||
type ComfyNodeDef,
|
||||
type EmbeddingsResponse,
|
||||
type ExtensionsResponse,
|
||||
type PromptResponse,
|
||||
type SystemStats,
|
||||
type User,
|
||||
type Settings,
|
||||
type UserDataFullInfo,
|
||||
validateComfyNodeDef
|
||||
DownloadModelStatus,
|
||||
HistoryTaskItem,
|
||||
PendingTaskItem,
|
||||
RunningTaskItem,
|
||||
ComfyNodeDef,
|
||||
validateComfyNodeDef,
|
||||
EmbeddingsResponse,
|
||||
ExtensionsResponse,
|
||||
PromptResponse,
|
||||
SystemStats,
|
||||
User,
|
||||
Settings,
|
||||
UserDataFullInfo
|
||||
} from '@/types/apiTypes'
|
||||
import axios from 'axios'
|
||||
|
||||
@@ -33,23 +35,15 @@ class ComfyApi extends EventTarget {
|
||||
#registered = new Set()
|
||||
api_host: string
|
||||
api_base: string
|
||||
/**
|
||||
* The client id from the initial session storage.
|
||||
*/
|
||||
initialClientId: string | null
|
||||
/**
|
||||
* The current client id from websocket status updates.
|
||||
*/
|
||||
clientId?: string
|
||||
initialClientId: string
|
||||
user: string
|
||||
socket: WebSocket | null = null
|
||||
socket?: WebSocket
|
||||
clientId?: string
|
||||
|
||||
reportedUnknownMessageTypes = new Set<string>()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
// api.user is set by ComfyApp.setup()
|
||||
this.user = ''
|
||||
this.api_host = location.host
|
||||
this.api_base = location.pathname.split('/').slice(0, -1).join('/')
|
||||
console.log('Running on', this.api_host)
|
||||
@@ -78,14 +72,7 @@ class ComfyApi extends EventTarget {
|
||||
if (!options.cache) {
|
||||
options.cache = 'no-cache'
|
||||
}
|
||||
|
||||
if (Array.isArray(options.headers)) {
|
||||
options.headers.push(['Comfy-User', this.user])
|
||||
} else if (options.headers instanceof Headers) {
|
||||
options.headers.set('Comfy-User', this.user)
|
||||
} else {
|
||||
options.headers['Comfy-User'] = this.user
|
||||
}
|
||||
options.headers['Comfy-User'] = this.user
|
||||
return fetch(this.apiURL(route), options)
|
||||
}
|
||||
|
||||
@@ -193,10 +180,9 @@ class ComfyApi extends EventTarget {
|
||||
switch (msg.type) {
|
||||
case 'status':
|
||||
if (msg.data.sid) {
|
||||
const clientId = msg.data.sid
|
||||
this.clientId = clientId
|
||||
window.name = clientId // use window name so it isnt reused when duplicating tabs
|
||||
sessionStorage.setItem('clientId', clientId) // store in session storage so duplicate tab can load correct workflow
|
||||
this.clientId = msg.data.sid
|
||||
window.name = this.clientId // use window name so it isnt reused when duplicating tabs
|
||||
sessionStorage.setItem('clientId', this.clientId) // store in session storage so duplicate tab can load correct workflow
|
||||
}
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('status', { detail: msg.data.status })
|
||||
@@ -239,6 +225,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(
|
||||
@@ -317,13 +308,10 @@ class ComfyApi extends EventTarget {
|
||||
*/
|
||||
async queuePrompt(
|
||||
number: number,
|
||||
{
|
||||
output,
|
||||
workflow
|
||||
}: { output: Record<number, any>; workflow: ComfyWorkflowJSON }
|
||||
{ output, workflow }
|
||||
): Promise<PromptResponse> {
|
||||
const body: QueuePromptRequestBody = {
|
||||
client_id: this.clientId ?? '', // TODO: Unify clientId access
|
||||
client_id: this.clientId,
|
||||
prompt: output,
|
||||
extra_data: { extra_pnginfo: { workflow } }
|
||||
}
|
||||
@@ -358,12 +346,9 @@ class ComfyApi extends EventTarget {
|
||||
async getModelFolders(): Promise<string[]> {
|
||||
const res = await this.fetchApi(`/models`)
|
||||
if (res.status === 404) {
|
||||
return []
|
||||
return null
|
||||
}
|
||||
const folderBlacklist = ['configs', 'custom_nodes']
|
||||
return (await res.json()).filter(
|
||||
(folder: string) => !folderBlacklist.includes(folder)
|
||||
)
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -371,10 +356,10 @@ class ComfyApi extends EventTarget {
|
||||
* @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): Promise<string[]> {
|
||||
async getModels(folder: string) {
|
||||
const res = await this.fetchApi(`/models/${folder}`)
|
||||
if (res.status === 404) {
|
||||
return []
|
||||
return null
|
||||
}
|
||||
return await res.json()
|
||||
}
|
||||
@@ -407,6 +392,36 @@ class ComfyApi extends EventTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
folder_path: string
|
||||
): 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,
|
||||
folder_path
|
||||
})
|
||||
})
|
||||
return await res.json()
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a list of items (queue or history)
|
||||
* @param {string} type The type of items to load, queue or history
|
||||
@@ -432,12 +447,12 @@ class ComfyApi extends EventTarget {
|
||||
const data = await res.json()
|
||||
return {
|
||||
// Running action uses a different endpoint for cancelling
|
||||
Running: data.queue_running.map((prompt: Record<number, any>) => ({
|
||||
Running: data.queue_running.map((prompt) => ({
|
||||
taskType: 'Running',
|
||||
prompt,
|
||||
remove: { name: 'Cancel', cb: () => api.interrupt() }
|
||||
})),
|
||||
Pending: data.queue_pending.map((prompt: Record<number, any>) => ({
|
||||
Pending: data.queue_pending.map((prompt) => ({
|
||||
taskType: 'Pending',
|
||||
prompt
|
||||
}))
|
||||
@@ -677,15 +692,12 @@ class ComfyApi extends EventTarget {
|
||||
recurse: boolean,
|
||||
split?: false
|
||||
): Promise<string[]>
|
||||
/**
|
||||
* @deprecated Use `listUserDataFullInfo` instead.
|
||||
*/
|
||||
async listUserData(dir: string, recurse: boolean, split?: boolean) {
|
||||
async listUserData(dir, recurse, split) {
|
||||
const resp = await this.fetchApi(
|
||||
`/userdata?${new URLSearchParams({
|
||||
recurse: recurse ? 'true' : 'false',
|
||||
recurse,
|
||||
dir,
|
||||
split: split ? 'true' : 'false'
|
||||
split
|
||||
})}`
|
||||
)
|
||||
if (resp.status === 404) return []
|
||||
|
||||
@@ -15,7 +15,7 @@ import { addDomClippingSetting } from './domWidget'
|
||||
import { createImageHost, calculateImageGrid } from './ui/imagePreview'
|
||||
import { DraggableList } from './ui/draggableList'
|
||||
import { applyTextReplacements, addStylesheet } from './utils'
|
||||
import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import {
|
||||
type ComfyWorkflowJSON,
|
||||
type NodeId,
|
||||
@@ -75,15 +75,6 @@ function sanitizeNodeName(string) {
|
||||
})
|
||||
}
|
||||
|
||||
type Clipspace = {
|
||||
widgets?: { type?: string; name?: string; value?: any }[] | null
|
||||
imgs?: HTMLImageElement[] | null
|
||||
original_imgs?: HTMLImageElement[] | null
|
||||
images?: any[] | null
|
||||
selectedIndex: number
|
||||
img_paste_mode: string
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {import("types/comfy").ComfyExtension} ComfyExtension
|
||||
*/
|
||||
@@ -104,8 +95,8 @@ export class ComfyApp {
|
||||
* Content Clipboard
|
||||
* @type {serialized node object}
|
||||
*/
|
||||
static clipspace: Clipspace | null = null
|
||||
static clipspace_invalidate_handler: (() => void) | null = null
|
||||
static clipspace = null
|
||||
static clipspace_invalidate_handler = null
|
||||
static open_maskeditor = null
|
||||
static clipspace_return_node = null
|
||||
|
||||
@@ -148,8 +139,6 @@ export class ComfyApp {
|
||||
canvasContainer: HTMLElement
|
||||
menu: ComfyAppMenu
|
||||
bypassBgColor: string
|
||||
// Set by Comfy.Clipspace extension
|
||||
openClipspace: () => void = () => {}
|
||||
|
||||
/**
|
||||
* @deprecated Use useExecutionStore().executingNodeId instead
|
||||
@@ -1139,7 +1128,7 @@ export class ComfyApp {
|
||||
|
||||
// No image found. Look for node data
|
||||
data = data.getData('text/plain')
|
||||
let workflow: ComfyWorkflowJSON | null = null
|
||||
let workflow: ComfyWorkflowJSON
|
||||
try {
|
||||
data = data.slice(data.indexOf('{'))
|
||||
workflow = JSON.parse(data)
|
||||
@@ -1149,7 +1138,7 @@ export class ComfyApp {
|
||||
data = data.slice(data.indexOf('{'))
|
||||
workflow = JSON.parse(data)
|
||||
} catch (error) {
|
||||
workflow = null
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1401,7 +1390,8 @@ export class ComfyApp {
|
||||
size,
|
||||
fgcolor,
|
||||
bgcolor,
|
||||
selected
|
||||
selected,
|
||||
mouse_over
|
||||
) {
|
||||
const res = origDrawNodeShape.apply(this, arguments)
|
||||
|
||||
@@ -1426,6 +1416,7 @@ export class ComfyApp {
|
||||
|
||||
if (color) {
|
||||
const shape =
|
||||
// @ts-expect-error
|
||||
node._shape || node.constructor.shape || LiteGraph.ROUND_SHAPE
|
||||
ctx.lineWidth = lineWidth
|
||||
ctx.globalAlpha = 0.8
|
||||
@@ -1909,7 +1900,8 @@ export class ComfyApp {
|
||||
|
||||
// Save current workflow automatically
|
||||
setInterval(() => {
|
||||
const workflow = JSON.stringify(this.serializeGraph())
|
||||
const sortNodes = useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
|
||||
const workflow = JSON.stringify(this.graph.serialize({ sortNodes }))
|
||||
localStorage.setItem('workflow', workflow)
|
||||
if (api.clientId) {
|
||||
sessionStorage.setItem(`workflow:${api.clientId}`, workflow)
|
||||
@@ -1969,7 +1961,7 @@ export class ComfyApp {
|
||||
const nodeDefArray: ComfyNodeDef[] = Object.values(allNodeDefs)
|
||||
this.#invokeExtensions('beforeRegisterVueAppNodeDefs', nodeDefArray, this)
|
||||
nodeDefStore.updateNodeDefs(nodeDefArray)
|
||||
nodeDefStore.widgets = this.widgets
|
||||
nodeDefStore.updateWidgets(this.widgets)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2188,9 +2180,12 @@ export class ComfyApp {
|
||||
localStorage.setItem('litegrapheditor_clipboard', old)
|
||||
}
|
||||
|
||||
#showMissingNodesError(missingNodeTypes: MissingNodeType[]) {
|
||||
#showMissingNodesError(missingNodeTypes, hasAddedNodes = true) {
|
||||
if (useSettingStore().get('Comfy.Workflow.ShowMissingNodesWarning')) {
|
||||
showLoadWorkflowWarning({ missingNodeTypes })
|
||||
showLoadWorkflowWarning({
|
||||
missingNodeTypes,
|
||||
hasAddedNodes
|
||||
})
|
||||
}
|
||||
|
||||
this.logging.addEntry('Comfy.App', 'warn', {
|
||||
@@ -2231,7 +2226,9 @@ export class ComfyApp {
|
||||
clean: boolean = true,
|
||||
restore_view: boolean = true,
|
||||
workflow: string | null | ComfyWorkflow = null,
|
||||
{ showMissingNodesDialog = true, showMissingModelsDialog = true } = {}
|
||||
{ showMissingNodesDialog = true, showMissingModelsDialog = true } = {},
|
||||
droppedImageBlobUrl: string | undefined = undefined,
|
||||
output_node_id = undefined
|
||||
) {
|
||||
if (clean !== false) {
|
||||
this.clean()
|
||||
@@ -2268,7 +2265,7 @@ export class ComfyApp {
|
||||
graphData = validatedGraphData ?? graphData
|
||||
}
|
||||
|
||||
const missingNodeTypes: MissingNodeType[] = []
|
||||
const missingNodeTypes = []
|
||||
const missingModels = []
|
||||
await this.#invokeExtensionsAsync(
|
||||
'beforeConfigureGraph',
|
||||
@@ -2293,8 +2290,8 @@ export class ComfyApp {
|
||||
graphData.models &&
|
||||
useSettingStore().get('Comfy.Workflow.ShowMissingModelsWarning')
|
||||
) {
|
||||
for (const m of graphData.models) {
|
||||
const models_available = await useModelStore().getLoadedModelFolder(
|
||||
for (let m of graphData.models) {
|
||||
const models_available = await useModelStore().getModelsInFolderCached(
|
||||
m.directory
|
||||
)
|
||||
if (models_available === null) {
|
||||
@@ -2422,6 +2419,12 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
// Show dropped image on its original output node
|
||||
if (droppedImageBlobUrl && String(node.id) === output_node_id) {
|
||||
// @ts-expect-error
|
||||
this.nodePreviewImages[output_node_id] = [droppedImageBlobUrl]
|
||||
}
|
||||
|
||||
this.#invokeExtensions('loadedGraphNode', node)
|
||||
}
|
||||
|
||||
@@ -2440,18 +2443,7 @@ export class ComfyApp {
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes a graph using preferred user settings.
|
||||
* @param graph The litegraph to serialize.
|
||||
* @returns A serialized graph (aka workflow) with preferred user settings.
|
||||
*/
|
||||
serializeGraph(graph: LGraph = this.graph) {
|
||||
const sortNodes = useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
|
||||
return graph.serialize({ sortNodes })
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts the current graph workflow for sending to the API.
|
||||
* Note: Node widgets are updated before serialization to prepare queueing.
|
||||
* Converts the current graph workflow for sending to the API
|
||||
* @returns The workflow and node links
|
||||
*/
|
||||
async graphToPrompt(graph = this.graph, clean = true) {
|
||||
@@ -2477,7 +2469,9 @@ export class ComfyApp {
|
||||
}
|
||||
}
|
||||
|
||||
const workflow = this.serializeGraph(graph)
|
||||
const sortNodes = useSettingStore().get('Comfy.Workflow.SortNodeIdOnSave')
|
||||
|
||||
const workflow = graph.serialize({ sortNodes })
|
||||
const output = {}
|
||||
// Process nodes in order of execution
|
||||
for (const outerNode of graph.computeExecutionOrder(false)) {
|
||||
@@ -2722,7 +2716,10 @@ export class ComfyApp {
|
||||
JSON.parse(pngInfo.workflow),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
fileName,
|
||||
undefined,
|
||||
URL.createObjectURL(file),
|
||||
pngInfo.output_node_id
|
||||
)
|
||||
} else if (pngInfo?.prompt) {
|
||||
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
|
||||
@@ -2740,7 +2737,15 @@ export class ComfyApp {
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
this.loadGraphData(
|
||||
JSON.parse(workflow),
|
||||
true,
|
||||
true,
|
||||
fileName,
|
||||
undefined,
|
||||
URL.createObjectURL(file),
|
||||
pngInfo.output_node_id
|
||||
)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
@@ -2820,7 +2825,8 @@ export class ComfyApp {
|
||||
if (missingNodeTypes.length) {
|
||||
this.#showMissingNodesError(
|
||||
// @ts-expect-error
|
||||
missingNodeTypes.map((t) => t.class_type)
|
||||
missingNodeTypes.map((t) => t.class_type),
|
||||
false
|
||||
)
|
||||
return
|
||||
}
|
||||
@@ -2929,6 +2935,7 @@ export class ComfyApp {
|
||||
}
|
||||
if (this.vueAppReady) {
|
||||
useToastStore().add(requestToastMessage)
|
||||
useModelStore().clearCache()
|
||||
}
|
||||
|
||||
const defs = await api.getNodeDefs({
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
// @ts-strict-ignore
|
||||
import type { ComfyApp } from './app'
|
||||
import { api } from './api'
|
||||
import { clone } from './utils'
|
||||
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
|
||||
import { ComfyWorkflow } from './workflows'
|
||||
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { ExecutedWsMessage } from '@/types/apiTypes'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
|
||||
export class ChangeTracker {
|
||||
static MAX_HISTORY = 50
|
||||
#app?: ComfyApp
|
||||
undoQueue: ComfyWorkflowJSON[] = []
|
||||
redoQueue: ComfyWorkflowJSON[] = []
|
||||
activeState: ComfyWorkflowJSON | null = null
|
||||
isOurLoad: boolean = false
|
||||
changeCount: number = 0
|
||||
#app: ComfyApp
|
||||
undoQueue = []
|
||||
redoQueue = []
|
||||
activeState = null
|
||||
isOurLoad = false
|
||||
workflow: ComfyWorkflow | null
|
||||
changeCount = 0
|
||||
|
||||
ds?: { scale: number; offset: [number, number] }
|
||||
nodeOutputs?: Record<string, any>
|
||||
ds: { scale: number; offset: [number, number] }
|
||||
nodeOutputs: any
|
||||
|
||||
get app(): ComfyApp {
|
||||
// Global tracker has #app set, while other trackers have workflow bounded
|
||||
get app() {
|
||||
return this.#app ?? this.workflow.manager.app
|
||||
}
|
||||
|
||||
constructor(public workflow: ComfyWorkflow) {}
|
||||
constructor(workflow: ComfyWorkflow) {
|
||||
this.workflow = workflow
|
||||
}
|
||||
|
||||
#setApp(app: ComfyApp) {
|
||||
#setApp(app) {
|
||||
this.#app = app
|
||||
}
|
||||
|
||||
@@ -70,10 +69,10 @@ export class ChangeTracker {
|
||||
}
|
||||
}
|
||||
|
||||
async updateState(source: ComfyWorkflowJSON[], target: ComfyWorkflowJSON[]) {
|
||||
async updateState(source, target) {
|
||||
const prevState = source.pop()
|
||||
if (prevState) {
|
||||
target.push(this.activeState!)
|
||||
target.push(this.activeState)
|
||||
this.isOurLoad = true
|
||||
await this.app.loadGraphData(prevState, false, false, this.workflow, {
|
||||
showMissingModelsDialog: false,
|
||||
@@ -91,7 +90,7 @@ export class ChangeTracker {
|
||||
await this.updateState(this.redoQueue, this.undoQueue)
|
||||
}
|
||||
|
||||
async undoRedo(e: KeyboardEvent) {
|
||||
async undoRedo(e) {
|
||||
if (e.ctrlKey || e.metaKey) {
|
||||
if (e.key === 'y' || e.key == 'Z') {
|
||||
await this.redo()
|
||||
@@ -119,8 +118,8 @@ export class ChangeTracker {
|
||||
globalTracker.#setApp(app)
|
||||
|
||||
const loadGraphData = app.loadGraphData
|
||||
app.loadGraphData = async function (...args) {
|
||||
const v = await loadGraphData.apply(this, args)
|
||||
app.loadGraphData = async function () {
|
||||
const v = await loadGraphData.apply(this, arguments)
|
||||
const ct = changeTracker()
|
||||
if (ct.isOurLoad) {
|
||||
ct.isOurLoad = false
|
||||
@@ -140,12 +139,12 @@ export class ChangeTracker {
|
||||
|
||||
const activeEl = document.activeElement
|
||||
requestAnimationFrame(async () => {
|
||||
let bindInputEl: Element | null = null
|
||||
let bindInputEl
|
||||
// If we are auto queue in change mode then we do want to trigger on inputs
|
||||
if (!app.ui.autoQueueEnabled || app.ui.autoQueueMode === 'instant') {
|
||||
if (
|
||||
activeEl?.tagName === 'INPUT' ||
|
||||
(activeEl && 'type' in activeEl && activeEl.type === 'textarea')
|
||||
activeEl?.['type'] === 'textarea'
|
||||
) {
|
||||
// Ignore events on inputs, they have their native history
|
||||
return
|
||||
@@ -195,26 +194,21 @@ export class ChangeTracker {
|
||||
// Handle litegraph clicks
|
||||
const processMouseUp = LGraphCanvas.prototype.processMouseUp
|
||||
LGraphCanvas.prototype.processMouseUp = function (e) {
|
||||
const v = processMouseUp.apply(this, [e])
|
||||
const v = processMouseUp.apply(this, arguments)
|
||||
changeTracker().checkState()
|
||||
return v
|
||||
}
|
||||
const processMouseDown = LGraphCanvas.prototype.processMouseDown
|
||||
LGraphCanvas.prototype.processMouseDown = function (e) {
|
||||
const v = processMouseDown.apply(this, [e])
|
||||
const v = processMouseDown.apply(this, arguments)
|
||||
changeTracker().checkState()
|
||||
return v
|
||||
}
|
||||
|
||||
// Handle litegraph dialog popup for number/string widgets
|
||||
const prompt = LGraphCanvas.prototype.prompt
|
||||
LGraphCanvas.prototype.prompt = function (
|
||||
title: string,
|
||||
value: any,
|
||||
callback: (v: any) => void,
|
||||
event: any
|
||||
) {
|
||||
const extendedCallback = (v: any) => {
|
||||
LGraphCanvas.prototype.prompt = function (title, value, callback, event) {
|
||||
const extendedCallback = (v) => {
|
||||
callback(v)
|
||||
changeTracker().checkState()
|
||||
}
|
||||
@@ -223,16 +217,16 @@ export class ChangeTracker {
|
||||
|
||||
// Handle litegraph context menu for COMBO widgets
|
||||
const close = LiteGraph.ContextMenu.prototype.close
|
||||
LiteGraph.ContextMenu.prototype.close = function (e: MouseEvent) {
|
||||
const v = close.apply(this, [e])
|
||||
LiteGraph.ContextMenu.prototype.close = function (e) {
|
||||
const v = close.apply(this, arguments)
|
||||
changeTracker().checkState()
|
||||
return v
|
||||
}
|
||||
|
||||
// Detects nodes being added via the node search dialog
|
||||
const onNodeAdded = LiteGraph.LGraph.prototype.onNodeAdded
|
||||
LiteGraph.LGraph.prototype.onNodeAdded = function (node: LGraphNode) {
|
||||
const v = onNodeAdded?.apply(this, [node])
|
||||
LiteGraph.LGraph.prototype.onNodeAdded = function () {
|
||||
const v = onNodeAdded?.apply(this, arguments)
|
||||
if (!app?.configuringGraph) {
|
||||
const ct = changeTracker()
|
||||
if (!ct.isOurLoad) {
|
||||
@@ -243,24 +237,20 @@ export class ChangeTracker {
|
||||
}
|
||||
|
||||
// Handle multiple commands as a single transaction
|
||||
document.addEventListener('litegraph:canvas', (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail
|
||||
if (detail.subType === 'before-change') {
|
||||
document.addEventListener('litegraph:canvas', (e: CustomEvent) => {
|
||||
if (e.detail.subType === 'before-change') {
|
||||
changeTracker().beforeChange()
|
||||
} else if (detail.subType === 'after-change') {
|
||||
} else if (e.detail.subType === 'after-change') {
|
||||
changeTracker().afterChange()
|
||||
}
|
||||
})
|
||||
|
||||
// Store node outputs
|
||||
api.addEventListener('executed', (e: CustomEvent<ExecutedWsMessage>) => {
|
||||
const detail = e.detail
|
||||
const workflow =
|
||||
useExecutionStore().queuedPrompts[detail.prompt_id]?.workflow
|
||||
const changeTracker = workflow?.changeTracker
|
||||
if (!changeTracker) return
|
||||
changeTracker.nodeOutputs ??= {}
|
||||
const nodeOutputs = changeTracker.nodeOutputs
|
||||
api.addEventListener('executed', ({ detail }) => {
|
||||
const prompt =
|
||||
app.workflowManager.executionStore.queuedPrompts[detail.prompt_id]
|
||||
if (!prompt?.workflow) return
|
||||
const nodeOutputs = (prompt.workflow.changeTracker.nodeOutputs ??= {})
|
||||
const output = nodeOutputs[detail.node]
|
||||
if (detail.merge && output) {
|
||||
for (const k in detail.output ?? {}) {
|
||||
@@ -277,30 +267,26 @@ export class ChangeTracker {
|
||||
})
|
||||
}
|
||||
|
||||
static bindInput(app: ComfyApp, activeEl: Element | null): boolean {
|
||||
static bindInput(app, activeEl) {
|
||||
if (
|
||||
!activeEl ||
|
||||
activeEl.tagName === 'CANVAS' ||
|
||||
activeEl.tagName === 'BODY'
|
||||
activeEl &&
|
||||
activeEl.tagName !== 'CANVAS' &&
|
||||
activeEl.tagName !== 'BODY'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const evt of ['change', 'input', 'blur']) {
|
||||
const htmlElement = activeEl as HTMLElement
|
||||
if (`on${evt}` in htmlElement) {
|
||||
const listener = () => {
|
||||
app.workflowManager.activeWorkflow?.changeTracker?.checkState()
|
||||
htmlElement.removeEventListener(evt, listener)
|
||||
for (const evt of ['change', 'input', 'blur']) {
|
||||
if (`on${evt}` in activeEl) {
|
||||
const listener = () => {
|
||||
app.workflowManager.activeWorkflow.changeTracker.checkState()
|
||||
activeEl.removeEventListener(evt, listener)
|
||||
}
|
||||
activeEl.addEventListener(evt, listener)
|
||||
return true
|
||||
}
|
||||
htmlElement.addEventListener(evt, listener)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
static graphEqual(a: any, b: any, path = '') {
|
||||
static graphEqual(a, b, path = '') {
|
||||
if (a === b) return true
|
||||
|
||||
if (typeof a == 'object' && a && typeof b == 'object' && b) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { TaskItem } from '@/types/apiTypes'
|
||||
import { showSettingsDialog } from '@/services/dialogService'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStateStore'
|
||||
|
||||
export const ComfyDialog = _ComfyDialog
|
||||
|
||||
@@ -351,6 +350,7 @@ export class ComfyUI {
|
||||
autoQueueMode: string
|
||||
graphHasChanged: boolean
|
||||
autoQueueEnabled: boolean
|
||||
menuHamburger: HTMLDivElement
|
||||
menuContainer: HTMLDivElement
|
||||
queueSize: Element
|
||||
restoreMenuPosition: () => void
|
||||
@@ -421,6 +421,18 @@ export class ComfyUI {
|
||||
}
|
||||
})
|
||||
|
||||
this.menuHamburger = $el(
|
||||
'div.comfy-menu-hamburger',
|
||||
{
|
||||
parent: containerElement,
|
||||
onclick: () => {
|
||||
this.menuContainer.style.display = 'block'
|
||||
this.menuHamburger.style.display = 'none'
|
||||
}
|
||||
},
|
||||
[$el('div'), $el('div'), $el('div')]
|
||||
) as HTMLDivElement
|
||||
|
||||
this.menuContainer = $el('div.comfy-menu', { parent: containerElement }, [
|
||||
$el(
|
||||
'div.drag-handle.comfy-menu-header',
|
||||
@@ -443,7 +455,8 @@ export class ComfyUI {
|
||||
$el('button.comfy-close-menu-btn', {
|
||||
textContent: '\u00d7',
|
||||
onclick: () => {
|
||||
useWorkspaceStore().focusMode = true
|
||||
this.menuContainer.style.display = 'none'
|
||||
this.menuHamburger.style.display = 'flex'
|
||||
}
|
||||
})
|
||||
])
|
||||
@@ -598,6 +611,7 @@ export class ComfyUI {
|
||||
$el('button', {
|
||||
id: 'comfy-clipspace-button',
|
||||
textContent: 'Clipspace',
|
||||
// @ts-expect-error Move to ComfyApp
|
||||
onclick: () => app.openClipspace()
|
||||
}),
|
||||
$el('button', {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ComfyApp } from '@/scripts/app'
|
||||
// @ts-strict-ignore
|
||||
import { $el } from '../ui'
|
||||
import { api } from '../api'
|
||||
import { ComfyDialog } from './dialog'
|
||||
import type { ComfyApp } from '../app'
|
||||
import type { Setting, SettingParams } from '@/types/settingTypes'
|
||||
import type { Settings } from '@/types/apiTypes'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import { Settings } from '@/types/apiTypes'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
|
||||
export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
@@ -14,10 +16,44 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
|
||||
constructor(app: ComfyApp) {
|
||||
super()
|
||||
const frontendVersion = window['__COMFYUI_FRONTEND_VERSION__']
|
||||
this.app = app
|
||||
this.settingsValues = {}
|
||||
this.settingsLookup = {}
|
||||
this.settingsParamLookup = {}
|
||||
this.element = $el(
|
||||
'dialog',
|
||||
{
|
||||
id: 'comfy-settings-dialog',
|
||||
parent: document.body
|
||||
},
|
||||
[
|
||||
$el('table.comfy-modal-content.comfy-table', [
|
||||
$el(
|
||||
'caption',
|
||||
{ textContent: `Settings (v${frontendVersion})` },
|
||||
$el('button.comfy-btn', {
|
||||
type: 'button',
|
||||
textContent: '\u00d7',
|
||||
onclick: () => {
|
||||
this.element.close()
|
||||
}
|
||||
})
|
||||
),
|
||||
$el('tbody', { $: (tbody) => (this.textElement = tbody) }),
|
||||
$el('button', {
|
||||
type: 'button',
|
||||
textContent: 'Close',
|
||||
style: {
|
||||
cursor: 'pointer'
|
||||
},
|
||||
onclick: () => {
|
||||
this.element.close()
|
||||
}
|
||||
})
|
||||
])
|
||||
]
|
||||
) as HTMLDialogElement
|
||||
}
|
||||
|
||||
get settings() {
|
||||
@@ -133,25 +169,17 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
this.#dispatchChange(id, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Deprecated for external callers/extensions. Use `ComfyExtension.settings` field instead.
|
||||
* Example:
|
||||
* ```ts
|
||||
* app.registerExtension({
|
||||
* name: 'My Extension',
|
||||
* settings: [
|
||||
* {
|
||||
* id: 'My.Setting',
|
||||
* name: 'My Setting',
|
||||
* type: 'text',
|
||||
* defaultValue: 'Hello, world!'
|
||||
* }
|
||||
* ]
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
addSetting(params: SettingParams) {
|
||||
const { id, name, type, defaultValue, onChange } = params
|
||||
const {
|
||||
id,
|
||||
name,
|
||||
type,
|
||||
defaultValue,
|
||||
onChange,
|
||||
attrs = {},
|
||||
tooltip = '',
|
||||
options = undefined
|
||||
} = params
|
||||
if (!id) {
|
||||
throw new Error('Settings must have an ID')
|
||||
}
|
||||
@@ -191,7 +219,164 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
onChange,
|
||||
name,
|
||||
render: () => {
|
||||
console.warn('[ComfyUI] Setting render is deprecated', id)
|
||||
if (type === 'hidden') return
|
||||
|
||||
const setter = (v) => {
|
||||
if (onChange) {
|
||||
onChange(v, value)
|
||||
}
|
||||
|
||||
this.setSettingValue(id, v)
|
||||
value = v
|
||||
}
|
||||
value = this.getSettingValue(id, defaultValue)
|
||||
|
||||
let element
|
||||
const htmlID = id.replaceAll('.', '-')
|
||||
|
||||
const labelCell = $el('td', [
|
||||
$el('label', {
|
||||
for: htmlID,
|
||||
classList: [tooltip !== '' ? 'comfy-tooltip-indicator' : ''],
|
||||
textContent: name
|
||||
})
|
||||
])
|
||||
|
||||
if (typeof type === 'function') {
|
||||
element = type(name, setter, value, attrs)
|
||||
} else {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
element = $el('tr', [
|
||||
labelCell,
|
||||
$el('td', [
|
||||
$el('input', {
|
||||
id: htmlID,
|
||||
type: 'checkbox',
|
||||
checked: value,
|
||||
onchange: (event) => {
|
||||
const isChecked = event.target.checked
|
||||
if (onChange !== undefined) {
|
||||
onChange(isChecked)
|
||||
}
|
||||
this.setSettingValue(id, isChecked)
|
||||
}
|
||||
})
|
||||
])
|
||||
])
|
||||
break
|
||||
case 'number':
|
||||
element = $el('tr', [
|
||||
labelCell,
|
||||
$el('td', [
|
||||
$el('input', {
|
||||
type,
|
||||
value,
|
||||
id: htmlID,
|
||||
oninput: (e) => {
|
||||
setter(e.target.value)
|
||||
},
|
||||
...attrs
|
||||
})
|
||||
])
|
||||
])
|
||||
break
|
||||
case 'slider':
|
||||
element = $el('tr', [
|
||||
labelCell,
|
||||
$el('td', [
|
||||
$el(
|
||||
'div',
|
||||
{
|
||||
style: {
|
||||
display: 'grid',
|
||||
gridAutoFlow: 'column'
|
||||
}
|
||||
},
|
||||
[
|
||||
$el('input', {
|
||||
...attrs,
|
||||
value,
|
||||
type: 'range',
|
||||
oninput: (e) => {
|
||||
setter(e.target.value)
|
||||
e.target.nextElementSibling.value = e.target.value
|
||||
}
|
||||
}),
|
||||
$el('input', {
|
||||
...attrs,
|
||||
value,
|
||||
id: htmlID,
|
||||
type: 'number',
|
||||
style: { maxWidth: '4rem' },
|
||||
oninput: (e) => {
|
||||
setter(e.target.value)
|
||||
e.target.previousElementSibling.value = e.target.value
|
||||
}
|
||||
})
|
||||
]
|
||||
)
|
||||
])
|
||||
])
|
||||
break
|
||||
case 'combo':
|
||||
element = $el('tr', [
|
||||
labelCell,
|
||||
$el('td', [
|
||||
$el(
|
||||
'select',
|
||||
{
|
||||
oninput: (e) => {
|
||||
setter(e.target.value)
|
||||
}
|
||||
},
|
||||
(typeof options === 'function'
|
||||
? options(value)
|
||||
: options || []
|
||||
).map((opt) => {
|
||||
if (typeof opt === 'string') {
|
||||
opt = { text: opt }
|
||||
}
|
||||
const v = opt.value ?? opt.text
|
||||
return $el('option', {
|
||||
value: v,
|
||||
textContent: opt.text,
|
||||
selected: value + '' === v + ''
|
||||
})
|
||||
})
|
||||
)
|
||||
])
|
||||
])
|
||||
break
|
||||
case 'text':
|
||||
default:
|
||||
if (type !== 'text') {
|
||||
console.warn(
|
||||
`Unsupported setting type '${type}, defaulting to text`
|
||||
)
|
||||
}
|
||||
|
||||
element = $el('tr', [
|
||||
labelCell,
|
||||
$el('td', [
|
||||
$el('input', {
|
||||
value,
|
||||
id: htmlID,
|
||||
oninput: (e) => {
|
||||
setter(e.target.value)
|
||||
},
|
||||
...attrs
|
||||
})
|
||||
])
|
||||
])
|
||||
break
|
||||
}
|
||||
}
|
||||
if (tooltip) {
|
||||
element.title = tooltip
|
||||
}
|
||||
|
||||
return element
|
||||
}
|
||||
} as Setting
|
||||
|
||||
@@ -205,4 +390,21 @@ export class ComfySettingsDialog extends ComfyDialog<HTMLDialogElement> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
show() {
|
||||
this.textElement.replaceChildren(
|
||||
$el(
|
||||
'tr',
|
||||
{
|
||||
style: { display: 'none' }
|
||||
},
|
||||
[$el('th'), $el('th', { style: { width: '33%' } })]
|
||||
),
|
||||
...this.settings
|
||||
.sort((a, b) => a.name.localeCompare(b.name))
|
||||
.map((s) => s.render())
|
||||
.filter(Boolean)
|
||||
)
|
||||
this.element.showModal()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -380,8 +380,8 @@ export class ComfyWorkflow {
|
||||
|
||||
path = appendJsonExt(path)
|
||||
|
||||
const workflow = this.manager.app.serializeGraph()
|
||||
const json = JSON.stringify(workflow, null, 2)
|
||||
const p = await this.manager.app.graphToPrompt()
|
||||
const json = JSON.stringify(p.workflow, null, 2)
|
||||
let resp = await api.storeUserData('workflows/' + path, json, {
|
||||
stringify: false,
|
||||
throwOnError: false,
|
||||
|
||||
@@ -11,16 +11,19 @@ import ExecutionErrorDialogContent from '@/components/dialog/content/ExecutionEr
|
||||
import TemplateWorkflowsContent from '@/components/templates/TemplateWorkflowsContent.vue'
|
||||
import PromptDialogContent from '@/components/dialog/content/PromptDialogContent.vue'
|
||||
import { i18n } from '@/i18n'
|
||||
import type { MissingNodeType } from '@/types/comfy'
|
||||
|
||||
export function showLoadWorkflowWarning(props: {
|
||||
missingNodeTypes: MissingNodeType[]
|
||||
missingNodeTypes: any[]
|
||||
hasAddedNodes: boolean
|
||||
[key: string]: any
|
||||
}) {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.showDialog({
|
||||
component: LoadWorkflowWarning,
|
||||
props
|
||||
props,
|
||||
dialogComponentProps: {
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -32,7 +35,10 @@ export function showMissingModelsWarning(props: {
|
||||
const dialogStore = useDialogStore()
|
||||
dialogStore.showDialog({
|
||||
component: MissingModelsWarning,
|
||||
props
|
||||
props,
|
||||
dialogComponentProps: {
|
||||
maximizable: true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-strict-ignore
|
||||
import { app } from '@/scripts/app'
|
||||
import { api } from '@/scripts/api'
|
||||
import { defineStore } from 'pinia'
|
||||
@@ -17,9 +18,7 @@ import { useTitleEditorStore } from './graphStore'
|
||||
import { useErrorHandling } from '@/hooks/errorHooks'
|
||||
import { useWorkflowStore } from './workflowStore'
|
||||
import { type KeybindingImpl, useKeybindingStore } from './keybindingStore'
|
||||
import { useBottomPanelStore } from './workspace/bottomPanelStore'
|
||||
import { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { useWorkspaceStore } from './workspaceStateStore'
|
||||
|
||||
export interface ComfyCommand {
|
||||
id: string
|
||||
@@ -123,7 +122,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
app.workflowManager.setWorkflow(null)
|
||||
app.clean()
|
||||
app.graph.clear()
|
||||
app.workflowManager.activeWorkflow?.track()
|
||||
app.workflowManager.activeWorkflow.track()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -149,7 +148,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
label: 'Save Workflow',
|
||||
menubarLabel: 'Save',
|
||||
function: () => {
|
||||
app.workflowManager.activeWorkflow?.save()
|
||||
app.workflowManager.activeWorkflow.save()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -158,7 +157,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
label: 'Save Workflow As',
|
||||
menubarLabel: 'Save As',
|
||||
function: () => {
|
||||
app.workflowManager.activeWorkflow?.save(true)
|
||||
app.workflowManager.activeWorkflow.save(true)
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -223,7 +222,7 @@ export const useCommandStore = defineStore('command', () => {
|
||||
icon: 'pi pi-clipboard',
|
||||
label: 'Clipspace',
|
||||
function: () => {
|
||||
app.openClipspace()
|
||||
app['openClipspace']?.()
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -274,10 +273,10 @@ export const useCommandStore = defineStore('command', () => {
|
||||
label: 'Zoom In',
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
ds.scale * 1.1,
|
||||
ds.element ? [ds.element.width / 2, ds.element.height / 2] : undefined
|
||||
)
|
||||
ds.changeScale(ds.scale * 1.1, [
|
||||
ds.element.width / 2,
|
||||
ds.element.height / 2
|
||||
])
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
},
|
||||
@@ -287,10 +286,10 @@ export const useCommandStore = defineStore('command', () => {
|
||||
label: 'Zoom Out',
|
||||
function: () => {
|
||||
const ds = app.canvas.ds
|
||||
ds.changeScale(
|
||||
ds.scale / 1.1,
|
||||
ds.element ? [ds.element.width / 2, ds.element.height / 2] : undefined
|
||||
)
|
||||
ds.changeScale(ds.scale / 1.1, [
|
||||
ds.element.width / 2,
|
||||
ds.element.height / 2
|
||||
])
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
},
|
||||
@@ -459,24 +458,6 @@ export const useCommandStore = defineStore('command', () => {
|
||||
}
|
||||
}
|
||||
})()
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleBottomPanel',
|
||||
icon: 'pi pi-list',
|
||||
label: 'Toggle Bottom Panel',
|
||||
versionAdded: '1.3.22',
|
||||
function: () => {
|
||||
useBottomPanelStore().toggleBottomPanel()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Workspace.ToggleFocusMode',
|
||||
icon: 'pi pi-eye',
|
||||
label: 'Toggle Focus Mode',
|
||||
versionAdded: '1.3.27',
|
||||
function: () => {
|
||||
useWorkspaceStore().toggleFocusMode()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -160,18 +160,5 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Mute',
|
||||
targetSelector: '#graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: '`',
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Workspace.ToggleBottomPanelTab.integrated-terminal'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'f'
|
||||
},
|
||||
commandId: 'Workspace.ToggleFocusMode'
|
||||
}
|
||||
]
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
LinkReleaseTriggerMode
|
||||
} from '@/types/searchBoxTypes'
|
||||
import type { SettingParams } from '@/types/settingTypes'
|
||||
import { LiteGraph } from '@comfyorg/litegraph'
|
||||
|
||||
export const CORE_SETTINGS: SettingParams[] = [
|
||||
{
|
||||
@@ -445,18 +444,5 @@ export const CORE_SETTINGS: SettingParams[] = [
|
||||
'Recommended for node developers. This will validate all node definitions on startup.',
|
||||
defaultValue: false,
|
||||
versionAdded: '1.3.14'
|
||||
},
|
||||
{
|
||||
id: 'Comfy.LinkRenderMode',
|
||||
category: ['Comfy', 'Graph', 'LinkRenderMode'],
|
||||
name: 'Link Render Mode',
|
||||
defaultValue: 2,
|
||||
type: 'combo',
|
||||
options: [
|
||||
{ value: LiteGraph.STRAIGHT_LINK, text: 'Straight' },
|
||||
{ value: LiteGraph.LINEAR_LINK, text: 'Linear' },
|
||||
{ value: LiteGraph.SPLINE_LINK, text: 'Spline' },
|
||||
{ value: LiteGraph.HIDDEN_LINK, text: 'Hidden' }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -2,53 +2,59 @@
|
||||
// Currently we need to bridge between legacy app code and Vue app with a Pinia store.
|
||||
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, shallowRef, type Component, markRaw } from 'vue'
|
||||
import { type Component, markRaw, nextTick } from 'vue'
|
||||
|
||||
interface DialogState {
|
||||
isVisible: boolean
|
||||
title: string
|
||||
headerComponent: Component | null
|
||||
component: Component | null
|
||||
// Props passing to the component
|
||||
props: Record<string, any>
|
||||
// Props passing to the Dialog component
|
||||
dialogComponentProps: DialogComponentProps
|
||||
}
|
||||
|
||||
interface DialogComponentProps {
|
||||
maximizable?: boolean
|
||||
onClose?: () => void
|
||||
}
|
||||
|
||||
export const useDialogStore = defineStore('dialog', () => {
|
||||
const isVisible = ref(false)
|
||||
const title = ref('')
|
||||
const headerComponent = shallowRef<Component | null>(null)
|
||||
const component = shallowRef<Component | null>(null)
|
||||
const props = ref<Record<string, any>>({})
|
||||
const dialogComponentProps = ref<DialogComponentProps>({})
|
||||
export const useDialogStore = defineStore('dialog', {
|
||||
state: (): DialogState => ({
|
||||
isVisible: false,
|
||||
title: '',
|
||||
headerComponent: null,
|
||||
component: null,
|
||||
props: {},
|
||||
dialogComponentProps: {}
|
||||
}),
|
||||
|
||||
function showDialog(options: {
|
||||
title?: string
|
||||
headerComponent?: Component
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
}) {
|
||||
isVisible.value = true
|
||||
title.value = options.title ?? ''
|
||||
headerComponent.value = options.headerComponent
|
||||
? markRaw(options.headerComponent)
|
||||
: null
|
||||
component.value = markRaw(options.component)
|
||||
props.value = options.props || {}
|
||||
dialogComponentProps.value = options.dialogComponentProps || {}
|
||||
}
|
||||
actions: {
|
||||
showDialog(options: {
|
||||
title?: string
|
||||
headerComponent?: Component
|
||||
component: Component
|
||||
props?: Record<string, any>
|
||||
dialogComponentProps?: DialogComponentProps
|
||||
}) {
|
||||
this.isVisible = true
|
||||
nextTick(() => {
|
||||
this.title = options.title ?? ''
|
||||
this.headerComponent = options.headerComponent
|
||||
? markRaw(options.headerComponent)
|
||||
: null
|
||||
this.component = markRaw(options.component)
|
||||
this.props = options.props || {}
|
||||
this.dialogComponentProps = options.dialogComponentProps || {}
|
||||
})
|
||||
},
|
||||
|
||||
function closeDialog() {
|
||||
if (dialogComponentProps.value.onClose) {
|
||||
dialogComponentProps.value.onClose()
|
||||
closeDialog() {
|
||||
if (this.dialogComponentProps.onClose) {
|
||||
this.dialogComponentProps.onClose()
|
||||
}
|
||||
this.isVisible = false
|
||||
}
|
||||
isVisible.value = false
|
||||
}
|
||||
|
||||
return {
|
||||
isVisible,
|
||||
title,
|
||||
headerComponent,
|
||||
component,
|
||||
props,
|
||||
dialogComponentProps,
|
||||
showDialog,
|
||||
closeDialog
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,21 +1,20 @@
|
||||
// @ts-strict-ignore
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { api } from '@/scripts/api'
|
||||
import { api } from '../scripts/api'
|
||||
import { ComfyWorkflow } from '@/scripts/workflows'
|
||||
import type { ComfyNode, ComfyWorkflowJSON } from '@/types/comfyWorkflow'
|
||||
import type {
|
||||
ExecutedWsMessage,
|
||||
ExecutingWsMessage,
|
||||
ExecutionCachedWsMessage,
|
||||
ExecutionStartWsMessage,
|
||||
ProgressWsMessage
|
||||
} from '@/types/apiTypes'
|
||||
|
||||
export interface QueuedPrompt {
|
||||
nodes: Record<string, boolean>
|
||||
workflow?: ComfyWorkflow
|
||||
}
|
||||
|
||||
interface NodeProgress {
|
||||
value: number
|
||||
max: number
|
||||
}
|
||||
|
||||
export const useExecutionStore = defineStore('execution', () => {
|
||||
const activePromptId = ref<string | null>(null)
|
||||
const queuedPrompts = ref<Record<string, QueuedPrompt>>({})
|
||||
@@ -23,22 +22,21 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
const executingNode = computed<ComfyNode | null>(() => {
|
||||
if (!executingNodeId.value) return null
|
||||
|
||||
const workflow: ComfyWorkflow | undefined = activePrompt.value?.workflow
|
||||
const workflow: ComfyWorkflow | null = activePrompt.value?.workflow
|
||||
if (!workflow) return null
|
||||
|
||||
const canvasState: ComfyWorkflowJSON | null =
|
||||
workflow.changeTracker?.activeState ?? null
|
||||
workflow.changeTracker?.activeState
|
||||
if (!canvasState) return null
|
||||
|
||||
return (
|
||||
canvasState.nodes.find(
|
||||
(n: ComfyNode) => String(n.id) === executingNodeId.value
|
||||
) ?? null
|
||||
canvasState.nodes.find((n) => String(n.id) === executingNodeId.value) ??
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
// This is the progress of the currently executing node, if any
|
||||
const _executingNodeProgress = ref<ProgressWsMessage | null>(null)
|
||||
const _executingNodeProgress = ref<NodeProgress | null>(null)
|
||||
const executingNodeProgress = computed(() =>
|
||||
_executingNodeProgress.value
|
||||
? Math.round(
|
||||
@@ -49,23 +47,21 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
: null
|
||||
)
|
||||
|
||||
const activePrompt = computed<QueuedPrompt | undefined>(
|
||||
() => queuedPrompts.value[activePromptId.value ?? '']
|
||||
)
|
||||
const activePrompt = computed(() => queuedPrompts.value[activePromptId.value])
|
||||
|
||||
const totalNodesToExecute = computed<number>(() => {
|
||||
const totalNodesToExecute = computed(() => {
|
||||
if (!activePrompt.value) return 0
|
||||
return Object.values(activePrompt.value.nodes).length
|
||||
})
|
||||
|
||||
const isIdle = computed<boolean>(() => !activePromptId.value)
|
||||
const isIdle = computed(() => !activePromptId.value)
|
||||
|
||||
const nodesExecuted = computed<number>(() => {
|
||||
const nodesExecuted = computed(() => {
|
||||
if (!activePrompt.value) return 0
|
||||
return Object.values(activePrompt.value.nodes).filter(Boolean).length
|
||||
})
|
||||
|
||||
const executionProgress = computed<number>(() => {
|
||||
const executionProgress = computed(() => {
|
||||
if (!activePrompt.value) return 0
|
||||
const total = totalNodesToExecute.value
|
||||
const done = nodesExecuted.value
|
||||
@@ -73,70 +69,56 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
})
|
||||
|
||||
function bindExecutionEvents() {
|
||||
api.addEventListener(
|
||||
'execution_start',
|
||||
handleExecutionStart as EventListener
|
||||
)
|
||||
api.addEventListener(
|
||||
'execution_cached',
|
||||
handleExecutionCached as EventListener
|
||||
)
|
||||
api.addEventListener('executed', handleExecuted as EventListener)
|
||||
api.addEventListener('executing', handleExecuting as EventListener)
|
||||
api.addEventListener('progress', handleProgress as EventListener)
|
||||
api.addEventListener('execution_start', handleExecutionStart)
|
||||
api.addEventListener('execution_cached', handleExecutionCached)
|
||||
api.addEventListener('executed', handleExecuted)
|
||||
api.addEventListener('executing', handleExecuting)
|
||||
api.addEventListener('progress', handleProgress)
|
||||
}
|
||||
|
||||
function unbindExecutionEvents() {
|
||||
api.removeEventListener(
|
||||
'execution_start',
|
||||
handleExecutionStart as EventListener
|
||||
)
|
||||
api.removeEventListener(
|
||||
'execution_cached',
|
||||
handleExecutionCached as EventListener
|
||||
)
|
||||
api.removeEventListener('executed', handleExecuted as EventListener)
|
||||
api.removeEventListener('executing', handleExecuting as EventListener)
|
||||
api.removeEventListener('progress', handleProgress as EventListener)
|
||||
api.removeEventListener('execution_start', handleExecutionStart)
|
||||
api.removeEventListener('execution_cached', handleExecutionCached)
|
||||
api.removeEventListener('executed', handleExecuted)
|
||||
api.removeEventListener('executing', handleExecuting)
|
||||
api.removeEventListener('progress', handleProgress)
|
||||
}
|
||||
|
||||
function handleExecutionStart(e: CustomEvent<ExecutionStartWsMessage>) {
|
||||
function handleExecutionStart(e: CustomEvent) {
|
||||
activePromptId.value = e.detail.prompt_id
|
||||
queuedPrompts.value[activePromptId.value] ??= { nodes: {} }
|
||||
}
|
||||
|
||||
function handleExecutionCached(e: CustomEvent<ExecutionCachedWsMessage>) {
|
||||
function handleExecutionCached(e: CustomEvent) {
|
||||
if (!activePrompt.value) return
|
||||
for (const n of e.detail.nodes) {
|
||||
activePrompt.value.nodes[n] = true
|
||||
}
|
||||
}
|
||||
|
||||
function handleExecuted(e: CustomEvent<ExecutedWsMessage>) {
|
||||
function handleExecuted(e: CustomEvent) {
|
||||
if (!activePrompt.value) return
|
||||
activePrompt.value.nodes[e.detail.node] = true
|
||||
}
|
||||
|
||||
function handleExecuting(e: CustomEvent<ExecutingWsMessage>) {
|
||||
function handleExecuting(e: CustomEvent) {
|
||||
// Clear the current node progress when a new node starts executing
|
||||
_executingNodeProgress.value = null
|
||||
|
||||
if (!activePrompt.value) return
|
||||
|
||||
if (executingNodeId.value && activePrompt.value) {
|
||||
if (executingNodeId.value) {
|
||||
// Seems sometimes nodes that are cached fire executing but not executed
|
||||
activePrompt.value.nodes[executingNodeId.value] = true
|
||||
}
|
||||
executingNodeId.value = e.detail ? String(e.detail) : null
|
||||
if (!executingNodeId.value) {
|
||||
if (activePromptId.value) {
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
}
|
||||
delete queuedPrompts.value[activePromptId.value]
|
||||
activePromptId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function handleProgress(e: CustomEvent<ProgressWsMessage>) {
|
||||
function handleProgress(e: CustomEvent) {
|
||||
_executingNodeProgress.value = e.detail
|
||||
}
|
||||
|
||||
@@ -147,12 +129,12 @@ export const useExecutionStore = defineStore('execution', () => {
|
||||
}: {
|
||||
nodes: string[]
|
||||
id: string
|
||||
workflow: ComfyWorkflow
|
||||
workflow: any
|
||||
}) {
|
||||
queuedPrompts.value[id] ??= { nodes: {} }
|
||||
const queuedPrompt = queuedPrompts.value[id]
|
||||
queuedPrompt.nodes = {
|
||||
...nodes.reduce((p: Record<string, boolean>, n) => {
|
||||
...nodes.reduce((p, n) => {
|
||||
p[n] = false
|
||||
return p
|
||||
}, {}),
|
||||
|
||||