Compare commits

..

2 Commits

Author SHA1 Message Date
filtered
782d93a7a0 Add awaits to various tests 2024-11-15 01:28:48 +11:00
filtered
7be14c5189 Add nodeTemplate tests
Test failure confirmed when links are not connected
2024-11-15 01:28:18 +11:00
127 changed files with 1894 additions and 9704 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
[
{
"name": "Three Nodes Template",
"data": "{\"nodes\":[{\"id\":7,\"type\":\"CLIPTextEncode\",\"pos\":[413,389],\"size\":[425.27801513671875,180.6060791015625],\"flags\":{},\"order\":3,\"mode\":0,\"inputs\":[{\"name\":\"clip\",\"type\":\"CLIP\",\"link\":null}],\"outputs\":[{\"name\":\"CONDITIONING\",\"type\":\"CONDITIONING\",\"links\":[],\"slot_index\":0}],\"properties\":{\"Node name for S&R\":\"CLIPTextEncode\"},\"widgets_values\":[\"text, watermark\"]},{\"id\":6,\"type\":\"CLIPTextEncode\",\"pos\":[415,186],\"size\":[422.84503173828125,164.31304931640625],\"flags\":{},\"order\":2,\"mode\":0,\"inputs\":[{\"name\":\"clip\",\"type\":\"CLIP\",\"link\":null}],\"outputs\":[{\"name\":\"CONDITIONING\",\"type\":\"CONDITIONING\",\"links\":[],\"slot_index\":0}],\"properties\":{\"Node name for S&R\":\"CLIPTextEncode\"},\"widgets_values\":[\"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,\"]},{\"id\":4,\"type\":\"CheckpointLoaderSimple\",\"pos\":[26,474],\"size\":[315,98],\"flags\":{},\"order\":1,\"mode\":0,\"inputs\":[],\"outputs\":[{\"name\":\"MODEL\",\"type\":\"MODEL\",\"links\":[],\"slot_index\":0},{\"name\":\"CLIP\",\"type\":\"CLIP\",\"links\":[],\"slot_index\":1},{\"name\":\"VAE\",\"type\":\"VAE\",\"links\":[],\"slot_index\":2}],\"properties\":{\"Node name for S&R\":\"CheckpointLoaderSimple\"},\"widgets_values\":[\"v1-5-pruned-emaonly.ckpt\"]}],\"groups\":[],\"reroutes\":[],\"links\":[{\"id\":5,\"origin_id\":4,\"origin_slot\":1,\"target_id\":7,\"target_slot\":0,\"type\":\"CLIP\"},{\"id\":3,\"origin_id\":4,\"origin_slot\":1,\"target_id\":6,\"target_slot\":0,\"type\":\"CLIP\"}]}"
},
{
"name": "Completely empty template",
"data": "{\"nodes\":[],\"groups\":[],\"reroutes\":[],\"links\":[]}"
}
]

View File

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

View File

@@ -0,0 +1,6 @@
[
{
"name": "vintageClipboard Template",
"data": "{\"nodes\":[{\"id\":-1,\"type\":\"CheckpointLoaderSimple\",\"pos\":[26,474],\"size\":[315,98],\"flags\":{},\"order\":1,\"mode\":0,\"inputs\":[],\"outputs\":[{\"name\":\"MODEL\",\"type\":\"MODEL\",\"links\":[],\"slot_index\":0},{\"name\":\"CLIP\",\"type\":\"CLIP\",\"links\":[],\"slot_index\":1},{\"name\":\"VAE\",\"type\":\"VAE\",\"links\":[],\"slot_index\":2}],\"properties\":{\"Node name for S&R\":\"CheckpointLoaderSimple\"},\"widgets_values\":[\"v1-5-pruned-emaonly.ckpt\"]},{\"id\":-1,\"type\":\"CLIPTextEncode\",\"pos\":[415,186],\"size\":[422.84503173828125,164.31304931640625],\"flags\":{},\"order\":2,\"mode\":0,\"inputs\":[{\"name\":\"clip\",\"type\":\"CLIP\",\"link\":null}],\"outputs\":[{\"name\":\"CONDITIONING\",\"type\":\"CONDITIONING\",\"links\":[],\"slot_index\":0}],\"properties\":{\"Node name for S&R\":\"CLIPTextEncode\"},\"widgets_values\":[\"beautiful scenery nature glass bottle landscape, , purple galaxy bottle,\"]},{\"id\":-1,\"type\":\"CLIPTextEncode\",\"pos\":[413,389],\"size\":[425.27801513671875,180.6060791015625],\"flags\":{},\"order\":3,\"mode\":0,\"inputs\":[{\"name\":\"clip\",\"type\":\"CLIP\",\"link\":null}],\"outputs\":[{\"name\":\"CONDITIONING\",\"type\":\"CONDITIONING\",\"links\":[],\"slot_index\":0}],\"properties\":{\"Node name for S&R\":\"CLIPTextEncode\"},\"widgets_values\":[\"text, watermark\"]}],\"links\":[[0,1,1,0,4],[0,1,2,0,4]]}"
}
]

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 145 KiB

After

Width:  |  Height:  |  Size: 145 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 138 KiB

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 158 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 152 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 157 KiB

After

Width:  |  Height:  |  Size: 158 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 152 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 96 KiB

View File

@@ -94,7 +94,7 @@ test.describe('Settings', () => {
test('Can change canvas zoom speed setting', async ({ comfyPage }) => {
const maxSpeed = 2.5
await comfyPage.setSetting('Comfy.Graph.ZoomSpeed', maxSpeed)
test.step('Setting should persist', async () => {
await test.step('Setting should persist', async () => {
expect(await comfyPage.getSetting('Comfy.Graph.ZoomSpeed')).toBe(maxSpeed)
})
})

View File

@@ -77,6 +77,7 @@ export class ComfyPage {
// All canvas position operations are based on default view of canvas.
public readonly canvas: Locator
public readonly widgetTextBox: Locator
public readonly contextMenu: Locator
// Buttons
public readonly resetViewButton: Locator
@@ -107,6 +108,7 @@ export class ComfyPage {
this.url = process.env.PLAYWRIGHT_TEST_URL || 'http://localhost:8188'
this.canvas = page.locator('#graph-canvas')
this.widgetTextBox = page.getByPlaceholder('text').nth(1)
this.contextMenu = page.locator('.litegraph.litecontextmenu')
this.resetViewButton = page.getByRole('button', { name: 'Reset View' })
this.queueButton = page.getByRole('button', { name: 'Queue Prompt' })
this.workflowUploadInput = page.locator('#comfy-file-input')
@@ -148,6 +150,12 @@ export class ComfyPage {
})
}
async getGraphSelectedItemsCount(): Promise<number | undefined> {
return await this.page.evaluate(() => {
return window['app']?.canvas?.selectedItems?.size
})
}
async setupWorkflowsDirectory(structure: FolderStructure) {
const resp = await this.request.post(
`${this.url}/api/devtools/setup_folder_structure`,
@@ -191,6 +199,39 @@ export class ComfyPage {
return await resp.json()
}
async clearNodeTemplates() {
const resp = await this.request.delete(
`${this.url}/api/userdata/comfy.templates.json`,
{
headers: { 'Comfy-User': this.id }
}
)
const status = resp.status()
if (status !== 204 && status !== 404)
throw new Error(`Failed to delete node templates: ${await resp.text()}`)
}
async setNodeTemplates(fileName: string) {
const path = this.assetPath(fileName)
const data = fs.readFileSync(path, 'utf-8')
const resp = await this.request.post(
`${this.url}/api/userdata/comfy.templates.json`,
{
headers: {
'Comfy-User': this.id,
overwrite: 'true',
full_info: 'true'
},
data
}
)
if (resp.status() !== 200)
throw new Error(`Failed to upload node templates: ${await resp.text()}`)
}
async setupSettings(settings: Record<string, any>) {
const resp = await this.request.post(
`${this.url}/api/devtools/set_settings`,
@@ -204,15 +245,13 @@ export class ComfyPage {
}
}
async setup({ clearStorage = true }: { clearStorage?: boolean } = {}) {
async setup() {
await this.goto()
if (clearStorage) {
await this.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, this.id)
}
await this.page.evaluate((id) => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('Comfy.userId', id)
}, this.id)
await this.goto()
// Unify font for consistent screenshots.
@@ -316,9 +355,9 @@ export class ComfyPage {
}, settingId)
}
async reload({ clearStorage = true }: { clearStorage?: boolean } = {}) {
async reload() {
await this.page.reload({ timeout: 15000 })
await this.setup({ clearStorage })
await this.setup()
}
async goto() {
@@ -401,11 +440,17 @@ export class ComfyPage {
await this.nextFrame()
}
async dragAndDrop(source: Position, target: Position) {
async dragAndDrop(
source: Position,
target: Position,
modifierKey?: 'ControlOrMeta' | 'Control' | 'Alt' | 'Shift'
) {
if (modifierKey) await this.page.keyboard.down(modifierKey)
await this.page.mouse.move(source.x, source.y)
await this.page.mouse.down()
await this.page.mouse.move(target.x, target.y)
await this.page.mouse.up()
if (modifierKey) await this.page.keyboard.up(modifierKey)
await this.nextFrame()
}
@@ -526,6 +571,9 @@ export class ComfyPage {
safeSpot = safeSpot || { x: 10, y: 10 }
await this.page.mouse.move(safeSpot.x, safeSpot.y)
await this.page.mouse.down()
// TEMPORARY HACK: Multiple pans open the search menu, so cheat and keep it closed.
// TODO: Fix that (double-click at not-the-same-coordinations should not open the menu)
await this.page.keyboard.press('Escape')
await this.page.mouse.move(offset.x + safeSpot.x, offset.y + safeSpot.y)
await this.page.mouse.up()
await this.nextFrame()
@@ -550,13 +598,11 @@ export class ComfyPage {
}
async rightClickCanvas() {
await this.page.mouse.click(10, 10, { button: 'right' })
await this.nextFrame()
}
async clickContextMenuItem(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name }).click()
await this.nextFrame()
await this.canvas.click({
position: { x: 10, y: 10 },
button: 'right'
})
await expect(this.contextMenu).toBeVisible()
}
async doubleClickCanvas() {
@@ -571,7 +617,7 @@ export class ComfyPage {
y: 625
}
})
this.page.mouse.move(10, 10)
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -583,10 +629,14 @@ export class ComfyPage {
},
button: 'right'
})
this.page.mouse.move(10, 10)
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
async clickContextMenuItem(name: string): Promise<void> {
await this.page.getByRole('menuitem', { name }).click()
}
async select2Nodes() {
// Select 2 CLIP nodes.
await this.page.keyboard.down('Control')
@@ -737,19 +787,6 @@ export class ComfyPage {
)
}
async confirmDialog(prompt: string, text: string = 'Yes') {
const modal = this.page.locator(
`.comfy-modal-content:has-text("${prompt}")`
)
await expect(modal).toBeVisible()
await modal
.locator('.comfyui-button', {
hasText: text
})
.click()
await expect(modal).toBeHidden()
}
async convertAllNodesToGroupNode(groupNodeName: string) {
this.page.on('dialog', async (dialog) => {
await dialog.accept(groupNodeName)

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 45 KiB

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 104 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

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

View File

@@ -0,0 +1,70 @@
import {
comfyPageFixture as test,
comfyExpect as expect
} from './fixtures/ComfyPage'
// Old `nodeTemplate.ts` system
test.describe('Node Template', () => {
test.afterEach(async ({ comfyPage }) => {
await comfyPage.clearNodeTemplates()
})
test('Can create and use node template', async ({ comfyPage }) => {
const templateName = 'Can create node template template'
await comfyPage.clearNodeTemplates()
await comfyPage.reload()
// TODO: Flaky test. Right click requires delay after reload, but other interactions do not.
await comfyPage.page.waitForTimeout(500)
// Enter filename when prompt dialog shown
comfyPage.page.on('dialog', (dialog) => dialog.accept(templateName))
// Ctrl + drag over 3 nodes
await comfyPage.dragAndDrop(
{ x: 175, y: 252 },
{ x: 483, y: 564 },
'ControlOrMeta'
)
expect(await comfyPage.getGraphSelectedItemsCount()).toEqual(3)
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Save Selected as Template')
await comfyPage.nextFrame()
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Node Templates >')
await comfyPage.clickContextMenuItem(templateName)
await expect(comfyPage.canvas).toHaveScreenshot()
})
test('Can load old format template', async ({ comfyPage }) => {
await comfyPage.setNodeTemplates('vintage_clipboard_template.json')
await comfyPage.reload()
// TODO: Flaky test. Right click requires delay after reload, but other interactions do not.
await comfyPage.page.waitForTimeout(500)
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Node Templates >')
await comfyPage.clickContextMenuItem('vintageClipboard Template')
await expect(comfyPage.canvas).toHaveScreenshot()
})
test('Can load new format template', async ({ comfyPage }) => {
await comfyPage.setNodeTemplates('node_template_templates.json')
await comfyPage.reload()
// TODO: Flaky test. Right click requires delay after reload, but other interactions do not.
await comfyPage.page.waitForTimeout(500)
await comfyPage.rightClickCanvas()
await comfyPage.clickContextMenuItem('Node Templates >')
await comfyPage.clickContextMenuItem('Three Nodes Template')
await expect(comfyPage.canvas).toHaveScreenshot()
})
})

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 96 KiB

After

Width:  |  Height:  |  Size: 97 KiB

View File

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

View File

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

119
package-lock.json generated
View File

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

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 373 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 410 B

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
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'
}
}

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