Compare commits

..

64 Commits

Author SHA1 Message Date
Austin Mroz
867c50f7cb Prune dead code, tighten interval, add screenshot
Code related to possible options has been pruned. It was pointless for
updateNode to take a node as input. Collapsing documentation items does
not make sense given the greater space of a sidebar tab compared to a
floating window on the graph, so the corresponding code has been fully
pruned as well.

The topmost node is used instead of the current_node. While current_node
displayed the desired properties when canvas was still shallowReactive
of notifying a change in node, it's not intended to be used external to
litegraph and at best, tracks the topmost visible node instead of the
actual topmost node.

A test expectation screenshot has been added for verifying theming works
for the documentation tab.
2024-11-28 10:45:23 -06:00
Austin Mroz
89027ea969 Update tests 2024-11-28 02:43:25 -06:00
Austin Mroz
ce9cfdb975 Check for swapped node on interval.
Not ideal, but implementation is low cost and ensures the displayed
documentation properly updates.
2024-11-28 02:23:50 -06:00
Austin Mroz
ef191033c8 Merge sidebar-documentation onto main
The sidebar-documentation branch has diverged enough from main to
merging non-trivial

Remove deprecated type usage.
Move localization to language file.
2024-11-27 18:04:36 -06:00
Chenlei Hu
e79013dcfe Remove deprecated type def on ComfyNodeDef (#1720) 2024-11-27 16:16:50 -05:00
Chenlei Hu
08f3370828 Use auto inferred type on electronAPI.Terminal (#1719) 2024-11-27 16:14:30 -05:00
Chenlei Hu
c4d3c672ad Enforce ComfyExtension types (#1718)
* Enforce extension types

* nit
2024-11-27 15:35:18 -05:00
Chenlei Hu
39eaa2e850 [Electron] Add not supported hardware page (#1717) 2024-11-27 10:46:24 -05:00
oto-ciulis-tt
2d022e4e49 feat: Remove successful model downloads (#1710)
* feat: Remove successful model downloads

* PR comments

---------

Co-authored-by: Oto Ciulis <oto.ciulis@gmail.com>
2024-11-27 10:40:43 -05:00
Chenlei Hu
1ac6d6529f Add empty workflows placeholder (#1712) 2024-11-26 17:22:05 -05:00
Chenlei Hu
86fec820ac Remove app.multiUserServer flag (#1711) 2024-11-26 16:06:58 -05:00
Chenlei Hu
030d5845db 1.4.13 (#1709) 2024-11-26 14:27:55 -05:00
Chenlei Hu
dd1c878fdf [Electron] Fix path validation on typing in input box (#1708) 2024-11-26 14:26:46 -05:00
Chenlei Hu
3942603a38 [Electron] Add version number to error state (#1707) 2024-11-26 14:21:37 -05:00
Chenlei Hu
244578db96 1.4.12 (#1706) 2024-11-26 14:02:43 -05:00
Chenlei Hu
6b6edfde9f chore: update litegraph to 0.8.37 (#1704) 2024-11-26 13:20:21 -05:00
Chenlei Hu
c54b675a48 Revert Filter cached/canceled results (#1586) (#1703)
* Revert "Filter cached/canceled results (#1586)"

This reverts commit 6fbf1248f4.

* nit
2024-11-26 13:17:26 -05:00
Chenlei Hu
b7008dfc5c Revert "nit: Fix import of OutputFilters in queue sidebar (#1680)" (#1702)
This reverts commit f97b673481.
2024-11-26 13:11:28 -05:00
Chenlei Hu
d0ad4af51c Revert "Move queueStore update to GraphView (#1679)" (#1701)
This reverts commit c8d5a6f154.
2024-11-26 13:08:42 -05:00
Chenlei Hu
4a182014e1 Revert "Fix queue sidebar tab task filter (#1682)" (#1700)
This reverts commit c1c5573e7f.
2024-11-26 13:08:19 -05:00
Chenlei Hu
46cd522384 Fix save temporary workflow loop on overwrite (#1699) 2024-11-26 10:44:25 -05:00
Hayden
c977667a15 Change dialog to multi-window mode (#1695)
Fixed Dropdown's z-index being below the dialog
2024-11-26 10:11:15 -05:00
Chenlei Hu
d531bc34c4 Make ChangeTracker detect changes in workflow.extra (Except ds) (#1692)
* Compare workflow.extra content

* Add tests
2024-11-25 21:59:27 -05:00
Chenlei Hu
adfbec2744 Add setting to adjust queue MaxHistoryItems (#1689)
* Add MaxHistoryItems

* nit
2024-11-25 18:49:40 -05:00
Chenlei Hu
23521559bf 1.4.11 (#1688) 2024-11-25 18:43:17 -05:00
Chenlei Hu
51f57aba17 Revert "Change dialog to multi-window mode (#1672)" (#1686)
This reverts commit 43c23e526c.
2024-11-25 13:29:47 -05:00
Chenlei Hu
97bab053df Split i18n locales to multiple files (#1683) 2024-11-25 13:18:14 -05:00
Chenlei Hu
c1c5573e7f Fix queue sidebar tab task filter (#1682)
* Fix queue sidebar tab task filter

* nit
2024-11-25 12:59:10 -05:00
Chenlei Hu
16d2a95760 chore: update litegraph to 0.8.36 (#1681) 2024-11-25 11:55:42 -05:00
Chenlei Hu
f97b673481 nit: Fix import of OutputFilters in queue sidebar (#1680) 2024-11-25 11:49:49 -05:00
Chenlei Hu
c8d5a6f154 Move queueStore update to GraphView (#1679) 2024-11-25 11:47:35 -05:00
Chenlei Hu
3708afaf21 [Electron] Add fp32 and fp64 to unet inference precision options (#1678) 2024-11-25 11:20:49 -05:00
Hayden
43c23e526c Change dialog to multi-window mode (#1672) 2024-11-24 21:36:30 -05:00
Chenlei Hu
a80eb84df1 1.4.10 (#1676) 2024-11-24 21:36:00 -05:00
Chenlei Hu
f89898b3d0 Add searchbox for extensions panel (#1675) 2024-11-24 21:33:43 -05:00
Chenlei Hu
af21142602 Use setting panel template (#1674)
* PanelTemplate

* Use panel template
2024-11-24 21:24:13 -05:00
Chenlei Hu
4b91860227 [Refactor] Extract SettingsPanel (#1673)
* [Refactor] Extract SettingsPanel

* nit
2024-11-24 20:48:35 -05:00
Chenlei Hu
e53bafbca6 [Electron] Add custom node migration placeholder (#1670) 2024-11-24 20:16:39 -05:00
Chenlei Hu
e01c8f06c7 [Electron] Show server launch args in server config panel (#1669)
* Move revertChanges

* Show launch args

* Explicit ServerConfigValue type

* nit

* nit

* Add tests
2024-11-24 18:14:05 -05:00
Chenlei Hu
c61ed4da37 Add server config modified message to prompt restart (#1668)
* Server config changed message

* Write to settings on unmount

* nit

* Highlight modified config

* Move modified logic to store

* Add jest test

* nit
2024-11-24 16:13:37 -05:00
Terry Jia
4a4d6d070a restore camera state (#1666) 2024-11-24 16:09:58 -05:00
Tristan Sommer
4bedd873a1 improved mouse brush adjustment, added zoom level indicator with reset, added invert button, bug fixes (#1664) 2024-11-24 11:32:13 -05:00
filtered
f8bd910e63 Fix terminal resizes incorrectly in flex parent (#1663)
Pushes siblings or self off-screen
2024-11-24 11:31:29 -05:00
Austin Mroz
293f4295a8 Add tests for advanced description formats 2024-11-15 15:34:14 -06:00
Austin Mroz
3252d62edf Fix missing await 2024-10-18 21:46:10 -05:00
Austin Mroz
1dfcc7a0d4 Migrate tooltip tracking to a pinia store
While I was concerned that doing this would remove the capability to
suppress tooltips on the active node, clearing the hoveredItem when it
used for documentation functions without even producing a temporary
tooltip.

A future commit will likely be made so that disabling tooltips for nodes
doesn't also prevent the hovered item from being tracked in the store.
2024-10-18 18:56:53 -05:00
Austin Mroz
f48594fbd5 Properly mirror the new description type
Remove errant logging
2024-10-18 15:53:04 -05:00
Austin Mroz
9263330379 Update tests for vue port
Mostly minor changes to selectors

Also fixes the glaringly obvious omission of the description field
2024-10-15 13:41:59 -05:00
Austin Mroz
214f48a6c4 Functional node swaps under vue, icon update 2024-10-15 13:41:59 -05:00
Austin Mroz
f8ba0ab24f Migrate to new sidebar registration 2024-10-15 13:41:59 -05:00
Austin Mroz
b2ef66e058 Update tooltip handling 2024-10-15 13:41:58 -05:00
Austin Mroz
95a4fe7e08 Port sidebar documentation to vue component 2024-10-15 13:41:58 -05:00
Austin Mroz
95cec85c3f Move css to style.css
Since the the css is now static the clutter of an added style element is
no longer needed
2024-10-15 13:41:58 -05:00
Austin Mroz
bc6630742b Move render callback to trigger on node change 2024-10-15 13:41:58 -05:00
Austin Mroz
3b679f1194 Return styling to body, streamline tests
Styling was moved to the sidebar element for better organization, but
this caused errors when the new menu was not in use.
2024-10-15 13:41:58 -05:00
Austin Mroz
52933e13f5 Properly handle theme with css variables 2024-10-15 13:41:58 -05:00
Austin Mroz
44f900ef56 Typing fixes, initial tests 2024-10-15 13:41:58 -05:00
Austin Mroz
7a5d39f41f Temporarily highlight item doc item on selection 2024-10-15 13:41:58 -05:00
Austin Mroz
1d0ae76f8c Connect hover functionality, scroll fixes
Basic connecting for using the existing documentation hover code to
select an item from the active help pane.

Scrolling on selection will now properly perform the minium required
scroll to place the element on screen
2024-10-15 13:41:58 -05:00
Austin Mroz
a8ac7296c2 Theming, pruning, and optional callbacks
Basic styling has been added to the display of documentation for nodes
using the existing tooltip system. This will need another pass to ensure
that style updates immediately when the light/dark toggle is hit instead
of requiring a change of node.

VHS specific namings have been replaced and the code for determining
what the mouse is hovering over has been removed. The existing tooltip
implementation is cleaner and will need to be integrated anyways so
tooltips are temporarily suppressed for the node actively being
displayed in the documentation sidebar.

Optional callbacks have been added for the initial sidebar display and a
user selecting a node element by hovering over it. While selection is
not yet implemented, this should cover any developer needs from more
involved collapsables to automated seeking to video timestamps.
2024-10-15 13:41:58 -05:00
Austin Mroz
4aa04d1419 type implementation for detailed descriptions
Previously, description was a simple string, but supporting more complex
descriptions requires that new data be passed.

The type of a nodes description has been updated to be either a simple
string as before, or an array consisting of short description string, an
html string for the full description, and a placeholder dict for future
usage.

Definitions and usage points for description have been updated to
accommodate this change
2024-10-15 13:41:58 -05:00
Austin Mroz
8160ca0342 Formatting improvements,
The formatting of ndoes using the existing standardized tooltips has
been improved.

Experiemental work for assisting nodes with display of more detailed
descriptions
2024-10-15 13:41:58 -05:00
Austin Mroz
da936d69b6 Remove unused styling code, unwrap 2024-10-15 13:41:58 -05:00
Austin Mroz
7eaa54fe3f Initial sidebar documentation implementation 2024-10-15 13:41:58 -05:00
70 changed files with 2318 additions and 1104 deletions

View File

@@ -3,9 +3,6 @@ import {
comfyPageFixture as test,
comfyExpect as expect
} from './fixtures/ComfyPage'
import type { useWorkspaceStore } from '../src/stores/workspaceStore'
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
async function beforeChange(comfyPage: ComfyPage) {
await comfyPage.page.evaluate(() => {
@@ -26,64 +23,41 @@ test.describe('Change Tracker', () => {
})
test('Can undo multiple operations', async ({ comfyPage }) => {
function isModified() {
return comfyPage.page.evaluate(async () => {
return !!(window['app'].extensionManager as WorkspaceStore).workflow
.activeWorkflow?.isModified
})
}
function getUndoQueueSize() {
return comfyPage.page.evaluate(() => {
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.undoQueue.length
})
}
function getRedoQueueSize() {
return comfyPage.page.evaluate(() => {
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.redoQueue.length
})
}
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(0)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
// Save, confirm no errors & workflow modified flag removed
await comfyPage.menu.topbar.saveWorkflow('undo-redo-test')
expect(await comfyPage.getToastErrorCount()).toBe(0)
expect(await isModified()).toBe(false)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.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 getRedoQueueSize()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
await comfyPage.ctrlB()
await expect(node).toBeBypassed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(2)
expect(await getRedoQueueSize()).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(2)
expect(await comfyPage.getRedoQueueSize()).toBe(0)
await comfyPage.ctrlZ()
await expect(node).not.toBeBypassed()
expect(await isModified()).toBe(true)
expect(await getUndoQueueSize()).toBe(1)
expect(await getRedoQueueSize()).toBe(1)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(true)
expect(await comfyPage.getUndoQueueSize()).toBe(1)
expect(await comfyPage.getRedoQueueSize()).toBe(1)
await comfyPage.ctrlZ()
await expect(node).not.toBeCollapsed()
expect(await isModified()).toBe(false)
expect(await getUndoQueueSize()).toBe(0)
expect(await getRedoQueueSize()).toBe(2)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
expect(await comfyPage.getUndoQueueSize()).toBe(0)
expect(await comfyPage.getRedoQueueSize()).toBe(2)
})
})
@@ -174,4 +148,20 @@ test.describe('Change Tracker', () => {
await expect(node).toBePinned()
await expect(node).toBeCollapsed()
})
test('Can detect changes in workflow.extra', async ({ comfyPage }) => {
expect(await comfyPage.getUndoQueueSize()).toBe(0)
await comfyPage.page.evaluate(() => {
window['app'].graph.extra.foo = 'bar'
})
// Click empty space to trigger a change detection.
await comfyPage.clickEmptySpace()
expect(await comfyPage.getUndoQueueSize()).toBe(1)
})
test('Ignores changes in workflow.ds', async ({ comfyPage }) => {
expect(await comfyPage.getUndoQueueSize()).toBe(0)
await comfyPage.pan({ x: 10, y: 10 })
expect(await comfyPage.getUndoQueueSize()).toBe(0)
})
})

View File

@@ -0,0 +1,125 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from './fixtures/ComfyPage'
const nodeDef = {
title: 'TestNodeAdvancedDoc'
}
test.describe('Documentation Sidebar', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.loadWorkflow('default')
})
test.afterEach(async ({ comfyPage }) => {
const currentThemeId = await comfyPage.menu.getThemeId()
if (currentThemeId !== 'dark') {
await comfyPage.menu.toggleTheme()
}
})
test('Sidebar registered', async ({ comfyPage }) => {
await expect(
comfyPage.page.locator('.documentation-tab-button')
).toBeVisible()
})
test('Parses help for basic node', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
//Check that each independently parsed element exists
await expect(docPane).toContainText('Load Checkpoint')
await expect(docPane).toContainText('Loads a diffusion model')
await expect(docPane).toContainText('The name of the checkpoint')
await expect(docPane).toContainText('The VAE model used')
})
test('Responds to hovering over node', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.page.mouse.move(321, 593)
const tooltipTimeout = 500
await comfyPage.page.waitForTimeout(tooltipTimeout + 16)
await expect(comfyPage.page.locator('.node-tooltip')).not.toBeVisible()
await expect(
comfyPage.page.locator('.sidebar-content-container>div>div:nth-child(4)')
).toBeFocused()
})
test('Updates when a new node is selected', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.page.mouse.click(557, 440)
await expect(docPane).not.toContainText('Load Checkpoint')
await expect(docPane).toContainText('CLIP Text Encode (Prompt)')
await expect(docPane).toContainText('The text to be encoded')
await expect(docPane).toContainText(
'A conditioning containing the embedded text'
)
})
test('Responds to a change in theme', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.menu.toggleTheme()
await expect(docPane).toHaveScreenshot(
'documentation-sidebar-light-theme.png'
)
})
})
test.describe('Advanced Description tests', () => {
test.beforeEach(async ({ comfyPage }) => {
//register test node and add to graph
await comfyPage.page.evaluate(async (node) => {
const app = window['app']
await app.registerNodeDef(node.name, node)
app.addNodeOnGraph(node)
}, advDocNode)
})
test('Description displays as raw html', async ({ comfyPage }) => {
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container>div')
await expect(docPane).toHaveJSProperty(
'innerHTML',
advDocNode.description[1]
)
})
test('selected function', async ({ comfyPage }) => {
await comfyPage.page.evaluate(() => {
const app = window['app']
const desc =
LiteGraph.registered_node_types['Test_AdvancedDescription'].nodeData
.description
desc[2].select = (element, name, value) => {
element.children[0].innerText = name + ' ' + value
}
})
await comfyPage.page.locator('.documentation-tab-button').click()
const docPane = comfyPage.page.locator('.sidebar-content-container')
await comfyPage.page.mouse.move(307, 80)
const tooltipTimeout = 500
await comfyPage.page.waitForTimeout(tooltipTimeout + 16)
await expect(comfyPage.page.locator('.node-tooltip')).not.toBeVisible()
await expect(docPane).toContainText('int_input 0')
})
})
const advDocNode = {
display_name: 'Node With Advanced Description',
name: 'Test_AdvancedDescription',
input: {
required: {
int_input: [
'INT',
{ tooltip: "an input tooltip that won't be displayed in sidebar" }
]
}
},
output: ['INT'],
output_name: ['int_output'],
output_tooltips: ["An output tooltip that won't be displayed in the sidebar"],
output_is_list: false,
description: [
'A node with description in the advanced format',
`
A long form description that will be displayed in the sidebar.
<div doc_title="INT">Can include arbitrary html</div>
<div doc_title="int_input">or out of order widgets</div>
`,
{}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

View File

@@ -17,8 +17,11 @@ import {
import { Topbar } from './components/Topbar'
import { NodeReference } from './utils/litegraphUtils'
import type { Position, Size } from './types'
import type { useWorkspaceStore } from '../../src/stores/workspaceStore'
import { SettingDialog } from './components/SettingDialog'
type WorkspaceStore = ReturnType<typeof useWorkspaceStore>
class ComfyMenu {
public readonly sideToolbar: Locator
public readonly themeToggleButton: Locator
@@ -788,6 +791,26 @@ export class ComfyPage {
async moveMouseToEmptyArea() {
await this.page.mouse.move(10, 10)
}
async getUndoQueueSize() {
return this.page.evaluate(() => {
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.undoQueue.length
})
}
async getRedoQueueSize() {
return this.page.evaluate(() => {
const workflow = (window['app'].extensionManager as WorkspaceStore)
.workflow.activeWorkflow
return workflow?.changeTracker.redoQueue.length
})
}
async isCurrentWorkflowModified() {
return this.page.evaluate(() => {
return (window['app'].extensionManager as WorkspaceStore).workflow
.activeWorkflow?.isModified
})
}
}
export const comfyPageFixture = base.extend<{ comfyPage: ComfyPage }>({

View File

@@ -465,6 +465,20 @@ test.describe('Menu', () => {
).toEqual(['workflow5.json'])
})
test('Can save temporary workflow with unmodified name', async ({
comfyPage
}) => {
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
await comfyPage.menu.topbar.saveWorkflow('Unsaved Workflow')
// Should not trigger the overwrite dialog
expect(
await comfyPage.page.locator('.comfy-modal-content:visible').count()
).toBe(0)
expect(await comfyPage.isCurrentWorkflowModified()).toBe(false)
})
test('Can overwrite other workflows with save as', async ({
comfyPage
}) => {

20
package-lock.json generated
View File

@@ -1,16 +1,16 @@
{
"name": "comfyui-frontend",
"version": "1.4.9",
"version": "1.4.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "comfyui-frontend",
"version": "1.4.9",
"version": "1.4.13",
"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.3.19",
"@comfyorg/litegraph": "^0.8.37",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",
@@ -1917,15 +1917,15 @@
"dev": true
},
"node_modules/@comfyorg/comfyui-electron-types": {
"version": "0.3.6",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.3.6.tgz",
"integrity": "sha512-wgMgESnCcRzvVkk8CwWiTAUJxC4LBvw5uTENxzaWkEL0qrnmiGrVLore00yX3cYz04hJaTA6PqasLqgVLDOenw==",
"version": "0.3.19",
"resolved": "https://registry.npmjs.org/@comfyorg/comfyui-electron-types/-/comfyui-electron-types-0.3.19.tgz",
"integrity": "sha512-VNr542eaLVmaeSJIvGv/Y5OiC2vYiLy+FtjwYtP+J8M5BSy88GCikX2K9NIkLPtcw9DLolVK3XWuIvFpsOK0zg==",
"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.37",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.8.37.tgz",
"integrity": "sha512-HI3msNigdlW1pz5HMU7+5UpLX0TkWkLD8qOeVBFTwq4tGjsEfqWs6lowyjsWSJcjef/0fVvjsKV5hsTbeVWVkA==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {

View File

@@ -1,7 +1,7 @@
{
"name": "comfyui-frontend",
"private": true,
"version": "1.4.9",
"version": "1.4.13",
"type": "module",
"scripts": {
"dev": "vite",
@@ -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.3.19",
"@comfyorg/litegraph": "^0.8.37",
"@primevue/themes": "^4.0.5",
"@vueuse/core": "^11.0.0",
"@xterm/addon-fit": "^0.10.0",

Binary file not shown.

After

Width:  |  Height:  |  Size: 174 KiB

View File

@@ -1,5 +1,5 @@
<template>
<div class="relative h-full w-full bg-black" ref="rootEl">
<div class="relative overflow-hidden 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>

View File

@@ -13,18 +13,7 @@ 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
}
const terminalApi = electronAPI().Terminal
let offData: IDisposable
let offOutput: () => void

View File

@@ -1,7 +1,7 @@
<!-- 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)]">
<span class="text-muted" :class="props.labelClass">
<slot name="name-prefix"></slot>
{{ props.item.name }}
<i
@@ -33,15 +33,11 @@ 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
}
)
const props = defineProps<{
item: FormItem
id?: string
labelClass?: string | Record<string, boolean>
}>()
function getFormAttrs(item: FormItem) {
const attrs = { ...(item.attrs || {}) }

View File

@@ -1,61 +1,51 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-model:visible="dialogStore.isVisible"
v-for="(item, index) in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
modal
closable
closeOnEscape
dismissableMask
:maximizable="maximizable"
:maximized="maximized"
@hide="dialogStore.closeDialog"
@maximize="onMaximize"
@unmaximize="onUnmaximize"
:aria-labelledby="headerId"
v-bind="item.dialogComponentProps"
:auto-z-index="false"
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:aria-labelledby="item.key"
>
<template #header>
<component
v-if="dialogStore.headerComponent"
:is="dialogStore.headerComponent"
:id="headerId"
v-if="item.headerComponent"
:is="item.headerComponent"
:id="item.key"
/>
<h3 v-else :id="headerId">{{ dialogStore.title || ' ' }}</h3>
<h3 v-else :id="item.key">{{ item.title || ' ' }}</h3>
</template>
<component :is="dialogStore.component" v-bind="contentProps" />
<component
:is="item.component"
v-bind="item.contentProps"
:maximized="item.dialogComponentProps.maximized"
/>
</Dialog>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { computed, onMounted } from 'vue'
import { ZIndex } from '@primeuix/utils/zindex'
import { usePrimeVue } from '@primevue/core'
import { useDialogStore } from '@/stores/dialogStore'
import Dialog from 'primevue/dialog'
const dialogStore = useDialogStore()
const maximizable = computed(
() => dialogStore.dialogComponentProps.maximizable ?? false
)
const maximized = ref(false)
const onMaximize = () => {
maximized.value = true
}
const primevue = usePrimeVue()
const onUnmaximize = () => {
maximized.value = false
}
const baseZIndex = computed(() => {
return primevue?.config?.zIndex?.modal ?? 1100
})
const contentProps = computed(() =>
maximizable.value
? {
...dialogStore.props,
maximized: maximized.value
}
: dialogStore.props
)
const headerId = `dialog-${Math.random().toString(36).substr(2, 9)}`
onMounted(() => {
const mask = document.createElement('div')
ZIndex.set('model', mask, baseZIndex.value)
})
</script>
<style>

View File

@@ -38,7 +38,6 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useClipboard } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
@@ -50,6 +49,7 @@ import type { ExecutionErrorWsMessage, SystemStats } from '@/types/apiTypes'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { isElectron } from '@/utils/envUtil'
import { useCopyToClipboard } from '@/hooks/clipboardHooks'
const props = defineProps<{
error: ExecutionErrorWsMessage
@@ -65,7 +65,6 @@ const showReport = () => {
const showSendError = isElectron()
const toast = useToast()
const { copy, isSupported } = useClipboard()
onMounted(async () => {
try {
@@ -140,30 +139,9 @@ ${workflowText}
`
}
const { copyToClipboard } = useCopyToClipboard()
const copyReportToClipboard = async () => {
if (isSupported) {
try {
await copy(reportContent.value)
toast.add({
severity: 'success',
summary: 'Success',
detail: 'Report copied to clipboard',
life: 3000
})
} catch (err) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to copy report'
})
}
} else {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Clipboard API not supported in your browser'
})
}
await copyToClipboard(reportContent.value)
}
const openNewGithubIssue = async () => {

View File

@@ -17,71 +17,46 @@
/>
</ScrollPanel>
<Divider layout="vertical" class="mx-1 2xl:mx-4" />
<ScrollPanel class="settings-content flex-grow">
<Tabs :value="tabValue" :lazy="true">
<FirstTimeUIMessage v-if="tabValue === 'Comfy'" />
<TabPanels class="settings-tab-panels">
<TabPanel key="search-results" value="Search Results">
<div v-if="searchResults.length > 0">
<SettingGroup
v-for="(group, i) in searchResults"
:key="group.label"
:divider="i !== 0"
:group="group"
/>
</div>
<NoResultsPlaceholder
v-else
icon="pi pi-search"
:title="$t('noResultsFound')"
:message="$t('searchFailedMessage')"
/>
</TabPanel>
<TabPanel
v-for="category in categories"
:key="category.key"
:value="category.label"
>
<SettingGroup
v-for="(group, i) in sortedGroups(category)"
:key="group.label"
:divider="i !== 0"
:group="{
label: group.label,
settings: flattenTree<SettingParams>(group)
}"
/>
</TabPanel>
<TabPanel key="about" value="About">
<AboutPanel />
</TabPanel>
<TabPanel key="keybinding" value="Keybinding">
<Suspense>
<KeybindingPanel />
<template #fallback>
<div>Loading keybinding panel...</div>
</template>
</Suspense>
</TabPanel>
<TabPanel key="extension" value="Extension">
<Suspense>
<ExtensionPanel />
<template #fallback>
<div>Loading extension panel...</div>
</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>
<Tabs :value="tabValue" :lazy="true" class="settings-content h-full w-full">
<TabPanels class="settings-tab-panels h-full w-full pr-0">
<PanelTemplate value="Search Results">
<SettingsPanel :settingGroups="searchResults" />
</PanelTemplate>
<PanelTemplate
v-for="category in settingCategories"
:key="category.key"
:value="category.label"
>
<template #header>
<FirstTimeUIMessage v-if="tabValue === 'Comfy'" />
</template>
<SettingsPanel :settingGroups="sortedGroups(category)" />
</PanelTemplate>
<AboutPanel />
<Suspense>
<KeybindingPanel />
<template #fallback>
<div>Loading keybinding panel...</div>
</template>
</Suspense>
<Suspense>
<ExtensionPanel />
<template #fallback>
<div>Loading extension panel...</div>
</template>
</Suspense>
<Suspense>
<ServerConfigPanel />
<template #fallback>
<div>Loading server config panel...</div>
</template>
</Suspense>
</TabPanels>
</Tabs>
</div>
</template>
@@ -90,17 +65,16 @@ import { ref, computed, onMounted, watch, defineAsyncComponent } from 'vue'
import Listbox from 'primevue/listbox'
import Tabs from 'primevue/tabs'
import TabPanels from 'primevue/tabpanels'
import TabPanel from 'primevue/tabpanel'
import Divider from 'primevue/divider'
import ScrollPanel from 'primevue/scrollpanel'
import { SettingTreeNode, useSettingStore } from '@/stores/settingStore'
import { SettingParams } from '@/types/settingTypes'
import SettingGroup from './setting/SettingGroup.vue'
import { ISettingGroup, SettingParams } from '@/types/settingTypes'
import SearchBox from '@/components/common/SearchBox.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { flattenTree } from '@/utils/treeUtil'
import SettingsPanel from './setting/SettingsPanel.vue'
import PanelTemplate from './setting/PanelTemplate.vue'
import AboutPanel from './setting/AboutPanel.vue'
import FirstTimeUIMessage from './setting/FirstTimeUIMessage.vue'
import { flattenTree } from '@/utils/treeUtil'
import { isElectron } from '@/utils/envUtil'
const KeybindingPanel = defineAsyncComponent(
@@ -113,11 +87,6 @@ const ServerConfigPanel = defineAsyncComponent(
() => import('./setting/ServerConfigPanel.vue')
)
interface ISettingGroup {
label: string
settings: SettingParams[]
}
const aboutPanelNode: SettingTreeNode = {
key: 'about',
label: 'About',
@@ -158,8 +127,11 @@ const serverConfigPanelNodeList = computed<SettingTreeNode[]>(() => {
const settingStore = useSettingStore()
const settingRoot = computed<SettingTreeNode>(() => settingStore.settingTree)
const settingCategories = computed<SettingTreeNode[]>(
() => settingRoot.value.children ?? []
)
const categories = computed<SettingTreeNode[]>(() => [
...(settingRoot.value.children || []),
...settingCategories.value,
keybindingPanelNode,
...extensionPanelNodeList.value,
...serverConfigPanelNodeList.value,
@@ -178,10 +150,13 @@ onMounted(() => {
activeCategory.value = categories.value[0]
})
const sortedGroups = (category: SettingTreeNode) => {
return [...(category.children || [])].sort((a, b) =>
a.label.localeCompare(b.label)
)
const sortedGroups = (category: SettingTreeNode): ISettingGroup[] => {
return [...(category.children ?? [])]
.sort((a, b) => a.label.localeCompare(b.label))
.map((group) => ({
label: group.label,
settings: flattenTree<SettingParams>(group)
}))
}
const searchQuery = ref<string>('')

View File

@@ -1,5 +1,5 @@
<template>
<div class="about-container">
<PanelTemplate value="About" class="about-container">
<h2 class="text-2xl font-bold mb-2">{{ $t('about') }}</h2>
<div class="space-y-2">
<a
@@ -26,10 +26,11 @@
v-if="systemStatsStore.systemStats"
:stats="systemStatsStore.systemStats"
/>
</div>
</PanelTemplate>
</template>
<script setup lang="ts">
import PanelTemplate from './PanelTemplate.vue'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import Tag from 'primevue/tag'

View File

@@ -1,6 +1,35 @@
<template>
<div class="extension-panel">
<DataTable :value="extensionStore.extensions" stripedRows size="small">
<PanelTemplate value="Extension" class="extension-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('searchExtensions') + '...'"
/>
<Message v-if="hasChanges" severity="info" pt:text="w-full">
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
<div class="flex justify-end">
<Button
:label="$t('reloadToApplyChanges')"
@click="applyChanges"
outlined
severity="danger"
/>
</div>
</Message>
</template>
<DataTable
:value="extensionStore.extensions"
stripedRows
size="small"
:filters="filters"
>
<Column field="name" :header="$t('extensionName')" sortable></Column>
<Column
:pt="{
@@ -15,28 +44,7 @@
</template>
</Column>
</DataTable>
<div class="mt-4">
<Message v-if="hasChanges" severity="info">
<ul>
<li v-for="ext in changedExtensions" :key="ext.name">
<span>
{{ extensionStore.isExtensionEnabled(ext.name) ? '[-]' : '[+]' }}
</span>
{{ ext.name }}
</li>
</ul>
</Message>
<Button
:label="$t('reloadToApplyChanges')"
icon="pi pi-refresh"
@click="applyChanges"
:disabled="!hasChanges"
text
fluid
severity="danger"
/>
</div>
</div>
</PanelTemplate>
</template>
<script setup lang="ts">
@@ -48,6 +56,13 @@ import Column from 'primevue/column'
import ToggleSwitch from 'primevue/toggleswitch'
import Button from 'primevue/button'
import Message from 'primevue/message'
import { FilterMatchMode } from '@primevue/core/api'
import PanelTemplate from './PanelTemplate.vue'
import SearchBox from '@/components/common/SearchBox.vue'
const filters = ref({
global: { value: '', matchMode: FilterMatchMode.CONTAINS }
})
const extensionStore = useExtensionStore()
const settingStore = useSettingStore()

View File

@@ -1,5 +1,12 @@
<template>
<div class="keybinding-panel">
<PanelTemplate value="Keybinding" class="keybinding-panel">
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('searchKeybindings') + '...'"
/>
</template>
<DataTable
:value="commandsData"
v-model:selection="selectedCommandData"
@@ -11,12 +18,6 @@
header: 'px-0'
}"
>
<template #header>
<SearchBox
v-model="filters['global'].value"
:placeholder="$t('searchKeybindings') + '...'"
/>
</template>
<Column field="actions" header="">
<template #body="slotProps">
<div class="actions invisible flex flex-row">
@@ -109,7 +110,7 @@
text
@click="resetKeybindings"
/>
</div>
</PanelTemplate>
</template>
<script setup lang="ts">
@@ -127,6 +128,7 @@ import Dialog from 'primevue/dialog'
import InputText from 'primevue/inputtext'
import Message from 'primevue/message'
import Tag from 'primevue/tag'
import PanelTemplate from './PanelTemplate.vue'
import KeyComboDisplay from './keybinding/KeyComboDisplay.vue'
import SearchBox from '@/components/common/SearchBox.vue'
import { useToast } from 'primevue/usetoast'

View File

@@ -0,0 +1,21 @@
<template>
<TabPanel :value="props.value" class="h-full w-full" :class="props.class">
<div class="flex flex-col h-full w-full gap-2">
<slot name="header" />
<ScrollPanel class="flex-grow h-0 pr-2">
<slot />
</ScrollPanel>
<slot name="footer" />
</div>
</TabPanel>
</template>
<script setup lang="ts">
import TabPanel from 'primevue/tabpanel'
import ScrollPanel from 'primevue/scrollpanel'
const props = defineProps<{
value: string
class?: string
}>()
</script>

View File

@@ -1,37 +1,105 @@
<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" />
<PanelTemplate value="Server-Config" class="server-config-panel">
<template #header>
<div class="flex flex-col gap-2">
<Message
v-if="modifiedConfigs.length > 0"
severity="info"
pt:text="w-full"
>
<p>
{{ $t('serverConfig.modifiedConfigs') }}
</p>
<ul>
<li v-for="config in modifiedConfigs" :key="config.id">
{{ config.name }}: {{ config.initialValue }} {{ config.value }}
</li>
</ul>
<div class="flex justify-end gap-2">
<Button
:label="$t('serverConfig.revertChanges')"
@click="revertChanges"
outlined
/>
<Button
:label="$t('serverConfig.restart')"
@click="restartApp"
outlined
severity="danger"
/>
</div>
</Message>
<Message v-if="commandLineArgs" severity="secondary" pt:text="w-full">
<template #icon>
<i-lucide:terminal class="text-xl font-bold" />
</template>
<div class="flex items-center justify-between">
<p>{{ commandLineArgs }}</p>
<Button
icon="pi pi-clipboard"
@click="copyCommandLineArgs"
severity="secondary"
text
/>
</div>
</Message>
</div>
</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"
:id="item.id"
:labelClass="{
'text-highlight': item.initialValue !== item.value
}"
/>
</div>
</div>
</div>
</PanelTemplate>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import Message from 'primevue/message'
import Divider from 'primevue/divider'
import FormItem from '@/components/common/FormItem.vue'
import PanelTemplate from './PanelTemplate.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'
import { electronAPI } from '@/utils/envUtil'
import { useSettingStore } from '@/stores/settingStore'
import { watch } from 'vue'
import { useCopyToClipboard } from '@/hooks/clipboardHooks'
const settingStore = useSettingStore()
const serverConfigStore = useServerConfigStore()
const { serverConfigsByCategory, launchArgs, serverConfigValues } =
storeToRefs(serverConfigStore)
const {
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
modifiedConfigs
} = storeToRefs(serverConfigStore)
onMounted(() => {
serverConfigStore.loadServerConfig(
SERVER_CONFIG_ITEMS,
settingStore.get('Comfy.Server.ServerConfigValues')
)
})
const revertChanges = () => {
serverConfigStore.revertChanges()
}
const restartApp = () => {
electronAPI().restartApp()
}
watch(launchArgs, (newVal) => {
settingStore.set('Comfy.Server.LaunchArgs', newVal)
@@ -40,4 +108,9 @@ watch(launchArgs, (newVal) => {
watch(serverConfigValues, (newVal) => {
settingStore.set('Comfy.Server.ServerConfigValues', newVal)
})
const { copyToClipboard } = useCopyToClipboard()
const copyCommandLineArgs = async () => {
await copyToClipboard(commandLineArgs.value)
}
</script>

View File

@@ -0,0 +1,26 @@
<template>
<div v-if="props.settingGroups.length > 0">
<SettingGroup
v-for="(group, i) in props.settingGroups"
:key="group.label"
:divider="i !== 0"
:group="group"
/>
</div>
<NoResultsPlaceholder
v-else
icon="pi pi-search"
:title="$t('noResultsFound')"
:message="$t('searchFailedMessage')"
/>
</template>
<script setup lang="ts">
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import SettingGroup from './SettingGroup.vue'
import { ISettingGroup } from '@/types/settingTypes'
const props = defineProps<{
settingGroups: ISettingGroup[]
}>()
</script>

View File

@@ -10,20 +10,23 @@
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import { nextTick, ref, watch } from 'vue'
import { LiteGraph } from '@comfyorg/litegraph'
import { app as comfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useHoveredItemStore } from '@/stores/graphStore'
import { useEventListener } from '@vueuse/core'
let idleTimeout: number
const nodeDefStore = useNodeDefStore()
const hoveredItemStore = useHoveredItemStore()
const tooltipRef = ref<HTMLDivElement>()
const tooltipText = ref('')
const left = ref<string>()
const top = ref<string>()
const hideTooltip = () => (tooltipText.value = null)
const clearHovered = () => (hoveredItemStore.value = null)
const showTooltip = async (tooltip: string | null | undefined) => {
if (!tooltip) return
@@ -43,6 +46,36 @@ const showTooltip = async (tooltip: string | null | undefined) => {
top.value = comfyApp.canvas.mouse[1] + rect.height + 'px'
}
}
watch(hoveredItemStore, (hoveredItem) => {
if (!hoveredItem.value) {
return hideTooltip()
}
const item = hoveredItem.value
const nodeDef =
nodeDefStore.nodeDefsByName[item.node.type] ??
LiteGraph.registered_node_types[item.node.type]?.nodeData
if (item.type == 'Title') {
let description = nodeDef.description
if (Array.isArray(description)) {
description = description[0]
}
return showTooltip(description)
} else if (item.type == 'Input') {
showTooltip(nodeDef.input.getInput(item.inputName)?.tooltip)
} else if (item.type == 'Output') {
showTooltip(nodeDef?.output?.all?.[item.outputSlot]?.tooltip)
} else if (item.type == 'Widget') {
showTooltip(
item.widget.tooltip ??
(
nodeDef.input.optional?.[item.widget.name] ??
nodeDef.input.required?.[item.widget.name]
)?.tooltip
)
} else {
hideTooltip()
}
})
const onIdle = () => {
const { canvas } = comfyApp
@@ -50,13 +83,15 @@ const onIdle = () => {
if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
const nodeDef = nodeDefStore.nodeDefsByName[node.type]
const nodeDef =
nodeDefStore.nodeDefsByName[node.type] ??
LiteGraph.registered_node_types[node.type]?.nodeData
if (
ctor.title_mode !== LiteGraph.NO_TITLE &&
canvas.graph_mouse[1] < node.pos[1] // If we are over a node, but not within the node then we are on its title
) {
return showTooltip(nodeDef.description)
hoveredItemStore.value = { node, type: 'Title' }
}
if (node.flags?.collapsed) return
@@ -69,7 +104,7 @@ const onIdle = () => {
)
if (inputSlot !== -1) {
const inputName = node.inputs[inputSlot].name
return showTooltip(nodeDef.input.getInput(inputName)?.tooltip)
hoveredItemStore.value = { node, type: 'Input', inputName }
}
const outputSlot = canvas.isOverNodeOutput(
@@ -79,20 +114,18 @@ const onIdle = () => {
[0, 0]
)
if (outputSlot !== -1) {
return showTooltip(nodeDef.output.all?.[outputSlot]?.tooltip)
hoveredItemStore.value = { node, type: 'Output', outputSlot }
}
const widget = comfyApp.canvas.getWidgetAtCursor()
// Dont show for DOM widgets, these use native browser tooltips as we dont get proper mouse events on these
if (widget && !widget.element) {
return showTooltip(
widget.tooltip ?? nodeDef.input.getInput(widget.name)?.tooltip
)
hoveredItemStore.value = { node, type: 'Widget', widget }
}
}
const onMouseMove = (e: MouseEvent) => {
hideTooltip()
clearHovered()
clearTimeout(idleTimeout)
if ((e.target as Node).nodeName !== 'CANVAS') return
@@ -100,7 +133,7 @@ const onMouseMove = (e: MouseEvent) => {
}
useEventListener(window, 'mousemove', onMouseMove)
useEventListener(window, 'click', hideTooltip)
useEventListener(window, 'click', clearHovered)
</script>
<style lang="css" scoped>

View File

@@ -16,7 +16,7 @@
v-model="installPath"
class="w-full"
:class="{ 'p-invalid': pathError }"
@change="validatePath"
@update:modelValue="validatePath"
/>
<InputIcon
class="pi pi-info-circle"

View File

@@ -16,7 +16,7 @@
placeholder="Select existing ComfyUI installation (optional)"
class="flex-1"
:class="{ 'p-invalid': pathError }"
@change="validateSource"
@update:modelValue="validateSource"
/>
<Button icon="pi pi-folder" @click="browsePath" class="w-12" />
</div>
@@ -57,6 +57,18 @@
</p>
</div>
</div>
<div class="flex items-center gap-3 p-2 rounded cursor-not-allowed">
<Checkbox disabled :binary="true" />
<div>
<label class="text-neutral-200 font-medium">
{{ $t('install.customNodes') }}
<Tag severity="secondary"> {{ $t('comingSoon') }}... </Tag>
</label>
<p class="text-sm text-neutral-400 my-1">
{{ $t('install.customNodesDescription') }}
</p>
</div>
</div>
</div>
</div>
@@ -70,6 +82,7 @@
<script setup lang="ts">
import { ref, computed, watchEffect } from 'vue'
import { electronAPI } from '@/utils/envUtil'
import Tag from 'primevue/tag'
import InputText from 'primevue/inputtext'
import Button from 'primevue/button'
import Checkbox from 'primevue/checkbox'

View File

@@ -74,7 +74,11 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
backgroundColor: litegraphColors.WIDGET_BGCOLOR
}"
>
{{ nodeDef.description }}
{{
Array.isArray(nodeDef.description)
? nodeDef.description[0]
: nodeDef.description
}}
</div>
</div>
</template>

View File

@@ -0,0 +1,196 @@
<template>
<div v-if="!hasAnyDoc()">Select a node to see documentation.</div>
<div v-else-if="rawDoc" ref="docElement" v-html="rawDoc"></div>
<div v-else ref="docElement">
<div class="doc-node">{{ title }}</div>
<div>{{ description }}</div>
<div v-if="inputs.length" class="doc-section">Inputs</div>
<div
v-if="inputs.length"
v-for="input in inputs"
tabindex="-1"
class="doc-item"
>
{{ input[0] }}
<div>{{ input[1] }}</div>
</div>
<div v-if="outputs.length" class="doc-section">Outputs</div>
<div
v-if="outputs.length"
v-for="output in outputs"
tabindex="-1"
class="doc-item"
>
{{ output[0] }}
<div>{{ output[1] }}</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount, isReactive } from 'vue'
import { app } from '@/scripts/app'
import { useCanvasStore } from '@/stores/graphStore'
import { useHoveredItemStore } from '@/stores/graphStore'
const hoveredItemStore = useHoveredItemStore()
const canvasStore = useCanvasStore()
const docElement = ref(null)
let def
const rawDoc = ref(null)
const description = ref(null)
const title = ref(null)
const inputs = ref([])
const outputs = ref([])
function selectHelp(name: string, value?: string) {
if (!docElement.value || !name) {
return null
}
if (def.description[2]?.select) {
return def.description[2].select(docElement.value, name, value)
}
//attempt to navigate to name in help
function collapseUnlessMatch(items, t) {
var match = items.querySelector('[doc_title="' + t + '"]')
if (!match) {
for (let i of items.children) {
if (i.innerHTML.slice(0, t.length + 5).includes(t)) {
match = i
break
}
}
}
if (!match) {
return null
}
//For longer documentation items with fewer collapsable elements,
//scroll to make sure the entirety of the selected item is visible
match.scrollIntoView({ block: 'nearest' })
//The previous floating help implementation would try to scroll the window
//itself if the display was partiall offscreen. As the sidebar documentation
//does not pan with the canvas, this should no longer be needed
//window.scrollTo(0, 0)
for (let i of items.querySelectorAll('.doc_collapse')) {
if (i.contains(match)) {
setCollapse(i, false)
} else {
setCollapse(i, true)
}
}
return match
}
let target = collapseUnlessMatch(docElement.value, name)
if (target) {
target.focus()
if (value) {
collapseUnlessMatch(target, value)
}
}
}
function updateNode() {
//Grab the topmost node.
//current_node is topmost on screen and
//selectedItems is unordered
const node = app?.graph?._nodes[app?.graph?._nodes.length - 1]
if (!node) {
// Graph has no nodes
return
}
const nodeDef = LiteGraph.getNodeType(node.type).nodeData
if (def == nodeDef) {
return
}
def = nodeDef
title.value = def.display_name
if (Array.isArray(def.description)) {
rawDoc.value = def.description[1]
outputs.value = []
inputs.value = []
return
} else {
rawDoc.value = null
}
description.value = def.description
let input_temp = []
for (let k in def?.input?.required) {
if (def.input.required[k][1]?.tooltip) {
input_temp.push([k, def.input.required[k][1].tooltip])
}
}
for (let k in def?.optional?.required) {
if (def.input.optional[k][1]?.tooltip) {
input_temp.push([k, def.input.optional[k][1].tooltip])
}
}
inputs.value = input_temp
if (def.output_tooltips) {
const outputs_temp = []
const output_name = def.output_name || def.output
for (let i = 0; i < def.output_tooltips.length; i++) {
outputs_temp[i] = [output_name[i], def.output_tooltips[i]]
}
outputs.value = outputs_temp
} else {
outputs.value = []
}
}
function hasAnyDoc() {
return def?.description || inputs.value.length || outputs.value.length
}
watch(hoveredItemStore, (hoveredItem) => {
if (!hoveredItem.value) {
return
}
const item = hoveredItem.value
const nodeDef = LiteGraph.getNodeType(item.node.type).nodeData
if (nodeDef != def) {
return
}
if (item.type == 'DESCRIPTION') {
return
} else if (item.type == 'Input') {
selectHelp(item.inputName)
hoveredItem.value = null
} else if (item.type == 'Output') {
selectHelp(nodeDef?.output?.all?.[item.outputSlot]?.name)
hoveredItem.value = null
} else if (item.type == 'Widget') {
selectHelp(item.widget.name, item.widget.value)
hoveredItem.value = null
}
})
if (isReactive(canvasStore?.canvas)) {
watch(() => canvasStore.canvas?.current_node, updateNode)
} else {
let interval = setInterval(updateNode, 300)
onBeforeUnmount(() => clearInterval(this.interval))
}
updateNode()
</script>
<style scoped>
.doc-node {
font-size: 1.5em;
}
.doc-section {
background-color: var(--comfy-menu-bg);
}
.doc-item div {
margin-inline-start: 1vw;
}
@keyframes selectAnimation {
0% {
background-color: #5555;
}
80% {
background-color: #5555;
}
100% {
background-color: #0000;
}
}
.doc-item:focus {
animation: selectAnimation 2s;
}
</style>

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

@@ -95,11 +95,18 @@
renderTreeNode(workflowsTree, WorkflowTreeType.Browse).children
"
v-model:expandedKeys="expandedKeys"
v-if="workflowStore.persistedWorkflows.length > 0"
>
<template #node="{ node }">
<WorkflowTreeLeaf :node="node" />
</template>
</TreeExplorer>
<NoResultsPlaceholder
v-else
icon="pi pi-folder"
:title="$t('empty')"
:message="$t('noWorkflowsFound')"
/>
</div>
</div>
<div class="comfyui-workflows-search-panel" v-else>
@@ -120,6 +127,7 @@
<script setup lang="ts">
import SearchBox from '@/components/common/SearchBox.vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import WorkflowTreeLeaf from '@/components/sidebar/tabs/workflows/WorkflowTreeLeaf.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'

View File

@@ -1,10 +1,10 @@
<template>
<div class="mx-6 mb-4" v-if="downloads.length > 0">
<div class="mx-6 mb-4" v-if="inProgressDownloads.length > 0">
<div class="text-lg my-4">
{{ $t('electronFileDownload.inProgress') }}
</div>
<template v-for="download in downloads" :key="download.url">
<template v-for="download in inProgressDownloads" :key="download.url">
<DownloadItem :download="download" />
</template>
</div>
@@ -16,5 +16,5 @@ import { useElectronDownloadStore } from '@/stores/electronDownloadStore'
import { storeToRefs } from 'pinia'
const electronDownloadStore = useElectronDownloadStore()
const { downloads } = storeToRefs(electronDownloadStore)
const { inProgressDownloads } = storeToRefs(electronDownloadStore)
</script>

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

@@ -198,25 +198,6 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: 'cover'
},
// Hidden setting used by the queue for if the results should be grouped by prompt
{
id: 'Comfy.Queue.ShowFlatList',
name: 'Queue show flat list',
type: 'hidden',
defaultValue: false
},
// Hidden setting used by the queue to filter certain results
{
id: 'Comfy.Queue.Filter',
name: 'Queue output filters',
type: 'hidden',
defaultValue: {
hideCanceled: false,
hideCached: false
},
versionAdded: '1.4.3'
},
{
id: 'Comfy.GroupSelectedNodes.Padding',
category: ['LiteGraph', 'Group', 'Padding'],
@@ -642,5 +623,18 @@ export const CORE_SETTINGS: SettingParams[] = [
type: 'hidden',
defaultValue: {} as Record<string, string>,
versionAdded: '1.4.8'
},
{
id: 'Comfy.Queue.MaxHistoryItems',
name: 'Queue history size',
tooltip: 'The maximum number of tasks that show in the queue history.',
type: 'slider',
attrs: {
min: 16,
max: 256,
step: 16
},
defaultValue: 64,
versionAdded: '1.4.12'
}
]

View File

@@ -10,15 +10,17 @@ import {
VramManagement
} from '@/types/serverArgs'
export type ServerConfigValue = string | number | true | null | undefined
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>
getValue?: (value: T) => Record<string, ServerConfigValue>
}
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<any>[] = [
export const WEB_ONLY_CONFIG_ITEMS: ServerConfig<ServerConfigValue>[] = [
// We only need these settings in the web version. Desktop app manages them already.
{
id: 'listen',
@@ -43,21 +45,21 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'TLS Key File: Path to TLS key file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: undefined
defaultValue: ''
},
{
id: 'tls-certfile',
name: 'TLS Certificate File: Path to TLS certificate file for HTTPS',
category: ['Network'],
type: 'text',
defaultValue: undefined
defaultValue: ''
},
{
id: 'enable-cors-header',
name: 'Enable CORS header: Use "*" for all origins or specify domain',
category: ['Network'],
type: 'text',
defaultValue: undefined
defaultValue: ''
},
{
id: 'max-upload-size',
@@ -97,7 +99,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'CUDA device index to use',
category: ['CUDA'],
type: 'number',
defaultValue: undefined
defaultValue: null
},
{
id: 'cuda-malloc',
@@ -161,6 +163,8 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
type: 'combo',
options: [
FloatingPointPrecision.AUTO,
FloatingPointPrecision.FP64,
FloatingPointPrecision.FP32,
FloatingPointPrecision.FP16,
FloatingPointPrecision.BF16,
FloatingPointPrecision.FP8E4M3FN,
@@ -253,7 +257,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'DirectML device index',
category: ['Memory'],
type: 'number',
defaultValue: undefined
defaultValue: null
},
{
id: 'disable-ipex-optimize',
@@ -295,10 +299,10 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
},
{
id: 'cache-lru',
name: 'Use LRU caching with a maximum of N node results cached. (0 to disable).',
name: 'Use LRU caching with a maximum of N node results cached.',
category: ['Cache'],
type: 'number',
defaultValue: 0,
defaultValue: null,
tooltip: 'May use more RAM/VRAM.'
},
@@ -366,7 +370,7 @@ export const SERVER_CONFIG_ITEMS: ServerConfig<any>[] = [
name: 'Reserved VRAM (GB)',
category: ['Memory'],
type: 'number',
defaultValue: undefined,
defaultValue: null,
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.'
},

View File

@@ -17,9 +17,11 @@ app.registerExtension({
if (node.widgets) {
// Locate dynamic prompt text widgets
// Include any widgets with dynamicPrompts set to true, and customtext
// @ts-expect-error dynamicPrompts is not typed
const widgets = node.widgets.filter((n) => n.dynamicPrompts)
for (const widget of widgets) {
// Override the serialization of the value to resolve dynamic prompts for all widgets supporting it in this node
// @ts-expect-error hacky override
widget.serializeValue = (workflowNode, widgetIndex) => {
let prompt = stripComments(widget.value)
while (

View File

@@ -107,6 +107,14 @@ import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
// TODO(huchenlei): Add a confirmation dialog.
electronAPI.reinstall()
}
},
{
id: 'Comfy-Desktop.Restart',
label: 'Restart',
icon: 'pi pi-refresh',
function() {
electronAPI.restartApp()
}
}
],

View File

@@ -1,10 +1,10 @@
// @ts-strict-ignore
import { app } from '@/scripts/app'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { GLTF } from 'three/examples/jsm/loaders/GLTFLoader'
import { GLTF, GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
@@ -185,6 +185,49 @@ class Load3d {
this.startAnimation()
}
getCameraState() {
const currentType = this.getCurrentCameraType()
return {
position: this.activeCamera.position.clone(),
target: this.controls.target.clone(),
zoom:
this.activeCamera instanceof THREE.OrthographicCamera
? this.activeCamera.zoom
: (this.activeCamera as THREE.PerspectiveCamera).zoom,
cameraType: currentType
}
}
setCameraState(state: {
position: THREE.Vector3
target: THREE.Vector3
zoom: number
cameraType: 'perspective' | 'orthographic'
}) {
if (
this.activeCamera !==
(state.cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera)
) {
this.toggleCamera(state.cameraType)
}
this.activeCamera.position.copy(state.position)
this.controls.target.copy(state.target)
if (this.activeCamera instanceof THREE.OrthographicCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
} else if (this.activeCamera instanceof THREE.PerspectiveCamera) {
this.activeCamera.zoom = state.zoom
this.activeCamera.updateProjectionMatrix()
}
this.controls.update()
}
setUpDirection(
direction: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z'
) {
@@ -358,26 +401,24 @@ class Load3d {
toggleCamera(cameraType?: 'perspective' | 'orthographic') {
const oldCamera = this.activeCamera
const position = this.activeCamera.position.clone()
const rotation = this.activeCamera.rotation.clone()
const position = oldCamera.position.clone()
const rotation = oldCamera.rotation.clone()
const target = this.controls.target.clone()
if (!cameraType) {
this.activeCamera =
this.activeCamera === this.perspectiveCamera
oldCamera === this.perspectiveCamera
? this.orthographicCamera
: this.perspectiveCamera
} else {
const requestedCamera =
this.activeCamera =
cameraType === 'perspective'
? this.perspectiveCamera
: this.orthographicCamera
if (this.activeCamera === requestedCamera) {
if (oldCamera === this.activeCamera) {
return
}
this.activeCamera = requestedCamera
}
this.activeCamera.position.copy(position)
@@ -394,6 +435,12 @@ class Load3d {
this.handleResize()
}
getCurrentCameraType(): 'perspective' | 'orthographic' {
return this.activeCamera === this.perspectiveCamera
? 'perspective'
: 'orthographic'
}
toggleGrid(showGrid: boolean) {
if (this.gridHelper) {
this.gridHelper.visible = showGrid
@@ -988,11 +1035,16 @@ function configureLoad3D(
bgColor: IWidget,
lightIntensity: IWidget,
upDirection: IWidget,
cameraState?: any,
postModelUpdateFunc?: (load3d: Load3d) => void
) {
const onModelWidgetUpdate = async () => {
if (modelWidget.value) {
const filename = modelWidget.value as string
const createModelUpdateHandler = () => {
let isFirstLoad = true
return async (value: string | number | boolean | object) => {
if (!value) return
const filename = value as string
const modelUrl = api.apiURL(
getResourceURL(...splitFilePath(filename), loadFolder)
)
@@ -1017,11 +1069,22 @@ function configureLoad3D(
if (postModelUpdateFunc) {
postModelUpdateFunc(load3d)
}
if (isFirstLoad && cameraState && typeof cameraState === 'object') {
try {
load3d.setCameraState(cameraState)
} catch (error) {
console.warn('Failed to restore camera state:', error)
}
isFirstLoad = false
}
}
}
const onModelWidgetUpdate = createModelUpdateHandler()
if (modelWidget.value) {
onModelWidgetUpdate()
onModelWidgetUpdate(modelWidget.value)
}
modelWidget.callback = onModelWidgetUpdate
@@ -1079,6 +1142,8 @@ app.registerExtension({
LOAD_3D(node, inputName) {
let load3dNode = app.graph._nodes.filter((wi) => wi.type == 'Load3D')
node.addProperty('Camera Info', '')
const container = document.createElement('div')
container.id = `comfy-load-3d-${load3dNode.length}`
container.classList.add('comfy-load-3d')
@@ -1212,6 +1277,21 @@ app.registerExtension({
(w: IWidget) => w.name === 'up_direction'
)
let cameraState
try {
const cameraInfo = node.properties['Camera Info']
if (
cameraInfo &&
typeof cameraInfo === 'string' &&
cameraInfo.trim() !== ''
) {
cameraState = JSON.parse(cameraInfo)
}
} catch (error) {
console.warn('Failed to parse camera state:', error)
cameraState = undefined
}
configureLoad3D(
load3d,
'input',
@@ -1222,13 +1302,17 @@ app.registerExtension({
material,
bgColor,
lightIntensity,
upDirection
upDirection,
cameraState
)
const w = node.widgets.find((w: IWidget) => w.name === 'width')
const h = node.widgets.find((w: IWidget) => w.name === 'height')
// @ts-expect-error hacky override
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = JSON.stringify(load3d.getCameraState())
const imageData = await load3d.captureScene(w.value, h.value)
const blob = await fetch(imageData).then((r) => r.blob())
@@ -1267,6 +1351,8 @@ app.registerExtension({
(wi) => wi.type == 'Load3DAnimation'
)
node.addProperty('Camera Info', '')
const container = document.createElement('div')
container.id = `comfy-load-3d-animation-${load3dNode.length}`
container.classList.add('comfy-load-3d-animation')
@@ -1466,6 +1552,21 @@ app.registerExtension({
}
}
let cameraState
try {
const cameraInfo = node.properties['Camera Info']
if (
cameraInfo &&
typeof cameraInfo === 'string' &&
cameraInfo.trim() !== ''
) {
cameraState = JSON.parse(cameraInfo)
}
} catch (error) {
console.warn('Failed to parse camera state:', error)
cameraState = undefined
}
configureLoad3D(
load3d,
'input',
@@ -1477,6 +1578,7 @@ app.registerExtension({
bgColor,
lightIntensity,
upDirection,
cameraState,
(load3d: Load3d) => {
const animationLoad3d = load3d as Load3dAnimation
const names = animationLoad3d.getAnimationNames()
@@ -1495,7 +1597,10 @@ app.registerExtension({
const w = node.widgets.find((w: IWidget) => w.name === 'width')
const h = node.widgets.find((w: IWidget) => w.name === 'height')
// @ts-expect-error hacky override
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = JSON.stringify(load3d.getCameraState())
load3d.toggleAnimation(false)
const imageData = await load3d.captureScene(w.value, h.value)

View File

@@ -275,11 +275,7 @@ var styles = `
justify-content: center;
align-items: center;
position: relative;
transition: background-color border 0.2s;
}
.maskEditor_toolPanelContainer:hover {
background-color: var(--p-overlaybadge-outline-color);
border: none;
transition: background-color 0.2s;
}
.maskEditor_toolPanelContainerSelected svg {
fill: var(--p-button-text-primary-color) !important;
@@ -292,6 +288,15 @@ var styles = `
aspect-ratio: 1/1;
fill: var(--p-button-text-secondary-color);
}
.maskEditor_toolPanelContainerDark:hover {
background-color: var(--p-surface-800);
}
.maskEditor_toolPanelContainerLight:hover {
background-color: var(--p-surface-300);
}
.maskEditor_toolPanelIndicator {
display: none;
height: 100%;
@@ -379,16 +384,56 @@ var styles = `
}
#maskEditor_topBarShortcutsContainer {
display: flex;
gap: 10px;
margin-left: 5px;
}
.maskEditor_topPanelIconButton {
width: 53.3px;
.maskEditor_topPanelIconButton_dark {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--p-surface-800);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_dark:hover {
background-color: var(--p-surface-900);
}
.maskEditor_topPanelIconButton_dark svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelIconButton_light {
width: 50px;
height: 30px;
pointer-events: auto;
display: flex;
justify-content: center;
align-items: center;
transition: background-color 0.1s;
background: var(--comfy-menu-bg);
border: 1px solid var(--p-form-field-border-color);
border-radius: 10px;
}
.maskEditor_topPanelIconButton_light:hover {
background-color: var(--p-surface-300);
}
.maskEditor_topPanelIconButton_light svg {
width: 25px;
height: 25px;
pointer-events: none;
fill: var(--input-text);
}
.maskEditor_topPanelButton_dark {
@@ -657,6 +702,24 @@ var styles = `
margin-left: 15px;
}
.maskEditor_toolPanelZoomIndicator {
width: var(--sidebar-width);
height: var(--sidebar-width);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
color: var(--p-button-text-secondary-color);
position: absolute;
bottom: 0;
transition: background-color 0.2s;
}
#maskEditor_toolPanelDimensionsText {
font-size: 12px;
}
`
var styleSheet = document.createElement('style')
@@ -1229,6 +1292,8 @@ class PaintBucketTool {
this.messageBroker.subscribe('paintBucketFill', (point: Point) =>
this.floodFill(point)
)
this.messageBroker.subscribe('invert', () => this.invertMask())
}
private addPullTopics() {
@@ -1374,6 +1439,48 @@ class PaintBucketTool {
getTolerance(): number {
return this.tolerance
}
//invert mask
private invertMask() {
const imageData = this.ctx.getImageData(
0,
0,
this.canvas.width,
this.canvas.height
)
const data = imageData.data
// Find first non-transparent pixel to get mask color
let maskR = 0,
maskG = 0,
maskB = 0
for (let i = 0; i < data.length; i += 4) {
if (data[i + 3] > 0) {
maskR = data[i]
maskG = data[i + 1]
maskB = data[i + 2]
break
}
}
// Process each pixel
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]
// Invert alpha channel (0 becomes 255, 255 becomes 0)
data[i + 3] = 255 - alpha
// If this was originally transparent (now opaque), fill with mask color
if (alpha === 0) {
data[i] = maskR
data[i + 1] = maskG
data[i + 2] = maskB
}
}
this.ctx.putImageData(imageData, 0, 0)
this.messageBroker.publish('saveState')
}
}
class ColorSelectTool {
@@ -1809,10 +1916,15 @@ class BrushTool {
smoothingLastDrawTime!: Date
maskCtx: CanvasRenderingContext2D | null = null
brushStrokeCanvas: HTMLCanvasElement | null = null
brushStrokeCtx: CanvasRenderingContext2D | null = null
//brush adjustment
isBrushAdjusting: boolean = false
brushPreviewGradient: HTMLElement | null = null
initialPoint: Point | null = null
useDominantAxis: boolean = false
brushAdjustmentSpeed: number = 1.0
maskEditor: MaskEditorDialog
messageBroker: MessageBroker
@@ -1823,6 +1935,13 @@ class BrushTool {
this.createListeners()
this.addPullTopics()
this.useDominantAxis = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseDominantAxis'
)
this.brushAdjustmentSpeed = app.extensionManager.setting.get(
'Comfy.MaskEditor.BrushAdjustmentSpeed'
)
this.brushSettings = {
size: 10,
opacity: 100,
@@ -1860,7 +1979,7 @@ class BrushTool {
)
//drawing
this.messageBroker.subscribe('drawStart', (event: PointerEvent) =>
this.start_drawing(event)
this.startDrawing(event)
)
this.messageBroker.subscribe('draw', (event: PointerEvent) =>
this.handleDrawing(event)
@@ -1897,12 +2016,27 @@ class BrushTool {
)
}
private async start_drawing(event: PointerEvent) {
private async createBrushStrokeCanvas() {
if (this.brushStrokeCanvas !== null) {
return
}
const maskCanvas = await this.messageBroker.pull('maskCanvas')
const canvas = document.createElement('canvas')
canvas.width = maskCanvas.width
canvas.height = maskCanvas.height
this.brushStrokeCanvas = canvas
this.brushStrokeCtx = canvas.getContext('2d')!
}
private async startDrawing(event: PointerEvent) {
this.isDrawing = true
let compositionOp: CompositionOperation
let currentTool = await this.messageBroker.pull('currentTool')
let coords = { x: event.offsetX, y: event.offsetY }
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
await this.createBrushStrokeCanvas()
//set drawing mode
if (currentTool === Tools.Eraser || event.buttons == 2) {
@@ -1931,15 +2065,6 @@ class BrushTool {
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
let currentTool = await this.messageBroker.pull('currentTool')
/* move to draw
if (event instanceof PointerEvent && event.pointerType == 'pen') {
brush_size *= event.pressure
this.last_pressure = event.pressure
} else {
brush_size = this.brush_size //this is the problem with pen pressure
}
*/
if (diff > 20 && !this.isDrawing)
requestAnimationFrame(() => {
this.init_shape(CompositionOperation.SourceOver)
@@ -2051,29 +2176,62 @@ class BrushTool {
private async handleBrushAdjustment(event: PointerEvent) {
const coords = { x: event.offsetX, y: event.offsetY }
const brushDeadZone = 5
let coords_canvas = await this.messageBroker.pull('screenToCanvas', coords)
const delta_x = coords_canvas.x - this.initialPoint!.x
const delta_y = coords_canvas.y - this.initialPoint!.y
// Adjust brush size (horizontal movement)
const effectiveDeltaX = Math.abs(delta_x) < brushDeadZone ? 0 : delta_x
const effectiveDeltaY = Math.abs(delta_y) < brushDeadZone ? 0 : delta_y
// New dominant axis logic
let finalDeltaX = effectiveDeltaX
let finalDeltaY = effectiveDeltaY
console.log(this.useDominantAxis)
if (this.useDominantAxis) {
// New setting flag
const ratio = Math.abs(effectiveDeltaX) / Math.abs(effectiveDeltaY)
const threshold = 2.0 // Configurable threshold
if (ratio > threshold) {
finalDeltaY = 0 // X is dominant
} else if (ratio < 1 / threshold) {
finalDeltaX = 0 // Y is dominant
}
}
const cappedDeltaX = Math.max(-100, Math.min(100, finalDeltaX))
const cappedDeltaY = Math.max(-100, Math.min(100, finalDeltaY))
// Rest of the function remains the same
const sizeDelta = cappedDeltaX / 40
const hardnessDelta = cappedDeltaY / 800
const newSize = Math.max(
1,
Math.min(100, this.brushSettings.size! + delta_x / 10)
Math.min(
100,
this.brushSettings.size! +
(cappedDeltaX / 35) * this.brushAdjustmentSpeed
)
)
// Adjust brush hardness (vertical movement)
const newHardness = Math.max(
0,
Math.min(1, this.brushSettings!.hardness - delta_y / 200)
Math.min(
1,
this.brushSettings!.hardness -
(cappedDeltaY / 4000) * this.brushAdjustmentSpeed
)
)
this.brushSettings.size = newSize
this.brushSettings.hardness = newHardness
this.messageBroker.publish('updateBrushPreview')
return
}
//helper functions
@@ -2263,8 +2421,8 @@ class BrushTool {
}
private setBrushSmoothingPrecision(precision: number) {
console.log('precision', precision)
// this.brushSettings.smoothingPrecision = precision
//console.log('precision', precision)
this.smoothingPrecision = precision
}
}
@@ -2300,6 +2458,9 @@ class UIManager {
private mask_opacity: number = 0.7
private maskBlendMode: MaskBlendMode = MaskBlendMode.Black
private zoomTextHTML!: HTMLSpanElement
private dimensionsTextHTML!: HTMLSpanElement
constructor(rootElement: HTMLElement, maskEditor: MaskEditorDialog) {
this.rootElement = rootElement
this.maskEditor = maskEditor
@@ -2332,6 +2493,10 @@ class UIManager {
)
this.messageBroker.subscribe('updateCursor', () => this.updateCursor())
this.messageBroker.subscribe('setZoomText', (text: string) =>
this.setZoomText(text)
)
}
addPullTopics() {
@@ -2975,11 +3140,17 @@ class UIManager {
return separator
}
//----------------
private async createTopBar() {
const buttonAccentColor = this.darkMode
? 'maskEditor_topPanelButton_dark'
: 'maskEditor_topPanelButton_light'
const iconButtonAccentColor = this.darkMode
? 'maskEditor_topPanelIconButton_dark'
: 'maskEditor_topPanelIconButton_light'
var top_bar = document.createElement('div')
top_bar.id = 'maskEditor_topBar'
@@ -2997,9 +3168,9 @@ class UIManager {
var top_bar_undo_button = document.createElement('div')
top_bar_undo_button.id = 'maskEditor_topBarUndoButton'
top_bar_undo_button.classList.add('maskEditor_topPanelIconButton')
top_bar_undo_button.classList.add(iconButtonAccentColor)
top_bar_undo_button.innerHTML =
'<svg viewBox="0 0 15 15" style="width: 36px;height: 36px;pointer-events: none;fill: var(--input-text);"><path d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"></path> </svg>'
'<svg viewBox="0 0 15 15"><path d="M8.77,12.18c-.25,0-.46-.2-.46-.46s.2-.46.46-.46c1.47,0,2.67-1.2,2.67-2.67,0-1.57-1.34-2.67-3.26-2.67h-3.98l1.43,1.43c.18.18.18.47,0,.64-.18.18-.47.18-.64,0l-2.21-2.21c-.18-.18-.18-.47,0-.64l2.21-2.21c.18-.18.47-.18.64,0,.18.18.18.47,0,.64l-1.43,1.43h3.98c2.45,0,4.17,1.47,4.17,3.58,0,1.97-1.61,3.58-3.58,3.58Z"></path> </svg>'
top_bar_undo_button.addEventListener('click', () => {
this.messageBroker.publish('undo')
@@ -3007,19 +3178,21 @@ class UIManager {
var top_bar_redo_button = document.createElement('div')
top_bar_redo_button.id = 'maskEditor_topBarRedoButton'
top_bar_redo_button.classList.add('maskEditor_topPanelIconButton')
top_bar_redo_button.classList.add(iconButtonAccentColor)
top_bar_redo_button.innerHTML =
'<svg viewBox="0 0 15 15" style="width: 36px;height: 36px;pointer-events: none;fill: var(--input-text);"> <path class="cls-1" d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"/></svg>'
'<svg viewBox="0 0 15 15"> <path class="cls-1" d="M6.23,12.18c-1.97,0-3.58-1.61-3.58-3.58,0-2.11,1.71-3.58,4.17-3.58h3.98l-1.43-1.43c-.18-.18-.18-.47,0-.64.18-.18.46-.18.64,0l2.21,2.21c.09.09.13.2.13.32s-.05.24-.13.32l-2.21,2.21c-.18.18-.47.18-.64,0-.18-.18-.18-.47,0-.64l1.43-1.43h-3.98c-1.92,0-3.26,1.1-3.26,2.67,0,1.47,1.2,2.67,2.67,2.67.25,0,.46.2.46.46s-.2.46-.46.46Z"/></svg>'
top_bar_redo_button.addEventListener('click', () => {
this.messageBroker.publish('redo')
})
top_bar_shortcuts_container.appendChild(top_bar_undo_button)
top_bar_shortcuts_container.appendChild(top_bar_redo_button)
var top_bar_button_container = document.createElement('div')
top_bar_button_container.id = 'maskEditor_topBarButtonContainer'
var top_bar_invert_button = document.createElement('button')
top_bar_invert_button.id = 'maskEditor_topBarInvertButton'
top_bar_invert_button.classList.add(buttonAccentColor)
top_bar_invert_button.innerText = 'Invert'
top_bar_invert_button.addEventListener('click', () => {
this.messageBroker.publish('invert')
})
var top_bar_clear_button = document.createElement('button')
top_bar_clear_button.id = 'maskEditor_topBarClearButton'
@@ -3055,23 +3228,26 @@ class UIManager {
this.maskEditor.close()
})
top_bar_button_container.appendChild(top_bar_clear_button)
top_bar_button_container.appendChild(top_bar_save_button)
top_bar_button_container.appendChild(top_bar_cancel_button)
top_bar_shortcuts_container.appendChild(top_bar_undo_button)
top_bar_shortcuts_container.appendChild(top_bar_redo_button)
top_bar_shortcuts_container.appendChild(top_bar_invert_button)
top_bar_shortcuts_container.appendChild(top_bar_clear_button)
top_bar_shortcuts_container.appendChild(top_bar_save_button)
top_bar_shortcuts_container.appendChild(top_bar_cancel_button)
top_bar.appendChild(top_bar_title_container)
top_bar.appendChild(top_bar_shortcuts_container)
top_bar.appendChild(top_bar_button_container)
return top_bar
}
//----------------
private createToolPanel() {
var pen_tool_panel = document.createElement('div')
pen_tool_panel.id = 'maskEditor_toolPanel'
this.toolPanel = pen_tool_panel
var tool_panel = document.createElement('div')
tool_panel.id = 'maskEditor_toolPanel'
this.toolPanel = tool_panel
var toolPanelHoverAccent = this.darkMode
? 'maskEditor_toolPanelContainerDark'
: 'maskEditor_toolPanelContainerLight'
var toolElements: HTMLElement[] = []
@@ -3082,6 +3258,7 @@ class UIManager {
toolPanel_brushToolContainer.classList.add(
'maskEditor_toolPanelContainerSelected'
)
toolPanel_brushToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_brushToolContainer.innerHTML = `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M34,13.93c0,.47-.19.94-.55,1.31l-13.02,13.04c-.09.07-.18.15-.27.22-.07-1.39-1.21-2.48-2.61-2.49.07-.12.16-.24.27-.34l13.04-13.04c.72-.72,1.89-.72,2.6,0,.35.35.55.83.55,1.3Z"/>
@@ -3116,6 +3293,7 @@ class UIManager {
var toolPanel_eraserToolContainer = document.createElement('div')
toolPanel_eraserToolContainer.classList.add('maskEditor_toolPanelContainer')
toolPanel_eraserToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_eraserToolContainer.innerHTML = `
<svg viewBox="0 0 44 44">
<g>
@@ -3155,6 +3333,7 @@ class UIManager {
toolPanel_paintBucketToolContainer.classList.add(
'maskEditor_toolPanelContainer'
)
toolPanel_paintBucketToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_paintBucketToolContainer.innerHTML = `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M33.4,21.76l-11.42,11.41-.04.05c-.61.61-1.6.61-2.21,0l-8.91-8.91c-.61-.61-.61-1.6,0-2.21l.04-.05.3-.29h22.24Z"/>
@@ -3198,6 +3377,7 @@ class UIManager {
toolPanel_colorSelectToolContainer.classList.add(
'maskEditor_toolPanelContainer'
)
toolPanel_colorSelectToolContainer.classList.add(toolPanelHoverAccent)
toolPanel_colorSelectToolContainer.innerHTML = `
<svg viewBox="0 0 44 44">
<path class="cls-1" d="M30.29,13.72c-1.09-1.1-2.85-1.09-3.94,0l-2.88,2.88-.75-.75c-.2-.19-.51-.19-.71,0-.19.2-.19.51,0,.71l1.4,1.4-9.59,9.59c-.35.36-.54.82-.54,1.32,0,.14,0,.28.05.41-.05.04-.1.08-.15.13-.39.39-.39,1.01,0,1.4.38.39,1.01.39,1.4,0,.04-.04.08-.09.11-.13.14.04.3.06.45.06.5,0,.97-.19,1.32-.55l9.59-9.59,1.38,1.38c.1.09.22.14.35.14s.26-.05.35-.14c.2-.2.2-.52,0-.71l-.71-.72,2.88-2.89c1.08-1.08,1.08-2.85-.01-3.94ZM19.43,25.82h-2.46l7.15-7.15,1.23,1.23-5.92,5.92Z"/>
@@ -3230,17 +3410,35 @@ class UIManager {
toolPanel_colorSelectToolIndicator
)
pen_tool_panel.appendChild(toolPanel_brushToolContainer)
pen_tool_panel.appendChild(toolPanel_eraserToolContainer)
pen_tool_panel.appendChild(toolPanel_paintBucketToolContainer)
pen_tool_panel.appendChild(toolPanel_colorSelectToolContainer)
//zoom indicator
var toolPanel_zoomIndicator = document.createElement('div')
toolPanel_zoomIndicator.classList.add('maskEditor_toolPanelZoomIndicator')
toolPanel_zoomIndicator.classList.add(toolPanelHoverAccent)
var pen_tool_panel_change_tool_button = document.createElement('button')
pen_tool_panel_change_tool_button.id =
'maskEditor_toolPanelChangeToolButton'
pen_tool_panel_change_tool_button.innerText = 'change to Eraser'
var toolPanel_zoomText = document.createElement('span')
toolPanel_zoomText.id = 'maskEditor_toolPanelZoomText'
toolPanel_zoomText.innerText = '100%'
this.zoomTextHTML = toolPanel_zoomText
return pen_tool_panel
var toolPanel_DimensionsText = document.createElement('span')
toolPanel_DimensionsText.id = 'maskEditor_toolPanelDimensionsText'
toolPanel_DimensionsText.innerText = ' '
this.dimensionsTextHTML = toolPanel_DimensionsText
toolPanel_zoomIndicator.appendChild(toolPanel_zoomText)
toolPanel_zoomIndicator.appendChild(toolPanel_DimensionsText)
toolPanel_zoomIndicator.addEventListener('click', () => {
this.messageBroker.publish('resetZoom')
})
tool_panel.appendChild(toolPanel_brushToolContainer)
tool_panel.appendChild(toolPanel_eraserToolContainer)
tool_panel.appendChild(toolPanel_paintBucketToolContainer)
tool_panel.appendChild(toolPanel_colorSelectToolContainer)
tool_panel.appendChild(toolPanel_zoomIndicator)
return tool_panel
}
private createPointerZone() {
@@ -3384,6 +3582,8 @@ class UIManager {
maskCanvas.width = this.image.width
maskCanvas.height = this.image.height
this.dimensionsTextHTML.innerText = `${this.image.width}x${this.image.height}`
await this.invalidateCanvas(this.image, mask_image)
this.messageBroker.publish('initZoomPan', [this.image, this.rootElement])
}
@@ -3638,6 +3838,14 @@ class UIManager {
this.updateBrushPreview()
this.setBrushPreviewGradientVisibility(false)
}
setZoomText(zoomText: string) {
this.zoomTextHTML.innerText = zoomText
}
setDimensionsText(dimensionsText: string) {
this.dimensionsTextHTML.innerText = dimensionsText
}
}
class ToolManager {
@@ -3814,6 +4022,7 @@ class PanAndZoomManager {
lastTouchPoint: Point = { x: 0, y: 0 }
zoom_ratio: number = 1
interpolatedZoomRatio: number = 1
pan_offset: Offset = { x: 0, y: 0 }
mouseDownPoint: Point | null = null
@@ -3821,8 +4030,11 @@ class PanAndZoomManager {
canvasContainer: HTMLElement | null = null
maskCanvas: HTMLCanvasElement | null = null
rootElement: HTMLElement | null = null
image: HTMLImageElement | null = null
imageRootWidth: number = 0
imageRootHeight: number = 0
cursorPoint: Point = { x: 0, y: 0 }
@@ -3878,6 +4090,11 @@ class PanAndZoomManager {
this.handleTouchEnd(event)
}
)
this.messageBroker.subscribe('resetZoom', async () => {
if (this.interpolatedZoomRatio === 1) return
await this.smoothResetView()
})
}
private addPullTopics() {
@@ -4054,14 +4271,25 @@ class PanAndZoomManager {
const mouseX = cursorPoint.x - rect.left
const mouseY = cursorPoint.y - rect.top
console.log(oldZoom, newZoom)
// Calculate new pan position
const scaleFactor = newZoom / oldZoom
this.pan_offset.x += mouseX - mouseX * scaleFactor
this.pan_offset.y += mouseY - mouseY * scaleFactor
console.log(this.imageRootWidth, this.imageRootHeight)
// Update pan and zoom immediately
await this.invalidatePanZoom()
const newImageWidth = maskCanvas.clientWidth
const zoomRatio = newImageWidth / this.imageRootWidth
this.interpolatedZoomRatio = zoomRatio
this.messageBroker.publish('setZoomText', `${Math.round(zoomRatio * 100)}%`)
// Update cursor position with new pan values
this.updateCursorPosition(cursorPoint)
@@ -4071,51 +4299,125 @@ class PanAndZoomManager {
})
}
private async smoothResetView(duration: number = 500) {
// Store initial state
const startZoom = this.zoom_ratio
const startPan = { ...this.pan_offset }
// Panel dimensions
const sidePanelWidth = 220
const toolPanelWidth = 64
const topBarHeight = 44
// Calculate available space
const availableWidth =
this.rootElement!.clientWidth - sidePanelWidth - toolPanelWidth
const availableHeight = this.rootElement!.clientHeight - topBarHeight
// Calculate target zoom
const zoomRatioWidth = availableWidth / this.image!.width
const zoomRatioHeight = availableHeight / this.image!.height
const targetZoom = Math.min(zoomRatioWidth, zoomRatioHeight)
// Calculate final dimensions
const aspectRatio = this.image!.width / this.image!.height
let finalWidth = 0
let finalHeight = 0
// Calculate target pan position
const targetPan = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
targetPan.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
targetPan.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
const startTime = performance.now()
const animate = (currentTime: number) => {
const elapsed = currentTime - startTime
const progress = Math.min(elapsed / duration, 1)
// Cubic easing out for smooth deceleration
const eased = 1 - Math.pow(1 - progress, 3)
// Calculate intermediate zoom and pan values
const currentZoom = startZoom + (targetZoom - startZoom) * eased
this.zoom_ratio = currentZoom
this.pan_offset.x = startPan.x + (targetPan.x - startPan.x) * eased
this.pan_offset.y = startPan.y + (targetPan.y - startPan.y) * eased
this.invalidatePanZoom()
const interpolatedZoomRatio = startZoom + (1.0 - startZoom) * eased
this.messageBroker.publish(
'setZoomText',
`${Math.round(interpolatedZoomRatio * 100)}%`
)
if (progress < 1) {
requestAnimationFrame(animate)
}
}
requestAnimationFrame(animate)
this.interpolatedZoomRatio = 1.0
}
async initializeCanvasPanZoom(
image: HTMLImageElement,
rootElement: HTMLElement
) {
// Get side panel width
let sidePanelWidth = 220
const toolPanelWidth = 64
let topBarHeight = 44
this.rootElement = rootElement
// Calculate available width accounting for both side panels
let availableWidth = rootElement.clientWidth - 2 * sidePanelWidth
let availableWidth =
rootElement.clientWidth - sidePanelWidth - toolPanelWidth
let availableHeight = rootElement.clientHeight - topBarHeight
// Initial dimensions
let drawWidth = image.width
let drawHeight = image.height
let zoomRatioWidth = availableWidth / image.width
let zoomRatioHeight = availableHeight / image.height
// First check if width needs scaling
if (drawWidth > availableWidth) {
drawWidth = availableWidth
drawHeight = (drawWidth / image.width) * image.height
}
let aspectRatio = image.width / image.height
// Then check if height needs scaling
if (drawHeight > availableHeight) {
drawHeight = availableHeight
drawWidth = (drawHeight / image.height) * image.width
let finalWidth = 0
let finalHeight = 0
let pan_offset: Offset = { x: toolPanelWidth, y: topBarHeight }
if (zoomRatioHeight > zoomRatioWidth) {
finalWidth = availableWidth
finalHeight = finalWidth / aspectRatio
pan_offset.y = (availableHeight - finalHeight) / 2 + topBarHeight
} else {
finalHeight = availableHeight
finalWidth = finalHeight * aspectRatio
pan_offset.x = (availableWidth - finalWidth) / 2 + toolPanelWidth
}
if (this.image === null) {
this.image = image
}
this.zoom_ratio = drawWidth / image.width
this.imageRootWidth = finalWidth
this.imageRootHeight = finalHeight
// Center the canvas in the available space
const canvasX = sidePanelWidth + (availableWidth - drawWidth) / 2
const canvasY = (availableHeight - drawHeight) / 2
this.pan_offset.x = canvasX
this.pan_offset.y = canvasY
this.zoom_ratio = Math.min(zoomRatioWidth, zoomRatioHeight)
this.pan_offset = pan_offset
await this.invalidatePanZoom()
}
//probably move to PanZoomManager
async invalidatePanZoom() {
// Single validation check upfront
if (
@@ -4132,10 +4434,6 @@ class PanAndZoomManager {
const raw_width = this.image.width * this.zoom_ratio
const raw_height = this.image.height * this.zoom_ratio
// Adjust pan offset
this.pan_offset.x = Math.max(10 - raw_width, this.pan_offset.x)
this.pan_offset.y = Math.max(10 - raw_height, this.pan_offset.y)
// Get canvas container
this.canvasContainer ??=
await this.messageBroker?.pull('getCanvasContainer')
@@ -4238,6 +4536,9 @@ class MessageBroker {
this.createPushTopic('setMaskBoundary')
this.createPushTopic('setMaskTolerance')
this.createPushTopic('setBrushSmoothingPrecision')
this.createPushTopic('setZoomText')
this.createPushTopic('resetZoom')
this.createPushTopic('invert')
}
/**
@@ -4425,12 +4726,38 @@ app.registerExtension({
settings: [
{
id: 'Comfy.MaskEditor.UseNewEditor',
category: ['Comfy', 'Masking'],
category: ['Mask Editor', 'NewEditor'],
name: 'Use new mask editor',
tooltip: 'Switch to the new mask editor interface',
type: 'boolean',
defaultValue: true,
experimental: true
},
{
id: 'Comfy.MaskEditor.BrushAdjustmentSpeed',
category: ['Mask Editor', 'BrushAdjustment', 'Sensitivity'],
name: 'Brush adjustment speed multiplier',
tooltip:
'Controls how quickly the brush size and hardness change when adjusting. Higher values mean faster changes.',
experimental: true,
type: 'slider',
attrs: {
min: 0.1,
max: 2.0,
step: 0.1
},
defaultValue: 1.0,
versionAdded: '1.0.0'
},
{
id: 'Comfy.MaskEditor.UseDominantAxis',
category: ['Mask Editor', 'BrushAdjustment', 'UseDominantAxis'],
name: 'Lock brush adjustment to dominant axis',
tooltip:
'When enabled, brush adjustments will only affect size OR hardness based on which direction you move more',
type: 'boolean',
defaultValue: true,
experimental: true
}
],
init(app) {

View File

@@ -54,6 +54,7 @@ app.registerExtension({
LiteGraph.registered_slot_in_types[lowerType] = { nodes: [] }
}
LiteGraph.registered_slot_in_types[lowerType].nodes.push(
// @ts-expect-error ComfyNode
nodeType.comfyClass
)
}
@@ -71,6 +72,7 @@ app.registerExtension({
if (!(type in LiteGraph.registered_slot_out_types)) {
LiteGraph.registered_slot_out_types[type] = { nodes: [] }
}
// @ts-expect-error ComfyNode
LiteGraph.registered_slot_out_types[type].nodes.push(nodeType.comfyClass)
if (!LiteGraph.slot_types_out.includes(type)) {

View File

@@ -81,6 +81,7 @@ app.registerExtension({
name: 'Comfy.AudioWidget',
async beforeRegisterNodeDef(nodeType, nodeData) {
if (
// @ts-expect-error ComfyNode
['LoadAudio', 'SaveAudio', 'PreviewAudio'].includes(nodeType.comfyClass)
) {
nodeData.input.required.audioUI = ['AUDIO_UI']

View File

@@ -73,17 +73,22 @@ app.registerExtension({
const canvas = document.createElement('canvas')
const capture = () => {
// @ts-expect-error widget value type narrow down
canvas.width = w.value
// @ts-expect-error widget value type narrow down
canvas.height = h.value
const ctx = canvas.getContext('2d')
// @ts-expect-error widget value type narrow down
ctx.drawImage(video, 0, 0, w.value, h.value)
const data = canvas.toDataURL('image/png')
const img = new Image()
img.onload = () => {
// @ts-expect-error adding extra property
node.imgs = [img]
app.graph.setDirtyCanvas(true)
requestAnimationFrame(() => {
// @ts-expect-error accessing extra property
node.setSizeForImage?.()
})
}
@@ -97,11 +102,14 @@ app.registerExtension({
capture
)
btn.disabled = true
// @ts-expect-error hacky override
btn.serializeValue = () => undefined
// @ts-expect-error hacky override
camera.serializeValue = async () => {
if (captureOnQueue.value) {
capture()
// @ts-expect-error accessing extra property
} else if (!node.imgs?.length) {
const err = `No webcam image captured`
useToastStore().addAlert(err)

View File

@@ -724,6 +724,7 @@ app.registerExtension({
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Add menu options to convert to/from widgets
const origGetExtraMenuOptions = nodeType.prototype.getExtraMenuOptions
// @ts-expect-error adding extra property
nodeType.prototype.convertWidgetToInput = function (widget) {
const config = getConfig.call(this, widget.name) ?? [
widget.type,
@@ -746,8 +747,10 @@ app.registerExtension({
if (w.options?.forceInput) {
continue
}
// @ts-expect-error custom widget type
if (w.type === CONVERTED_TYPE) {
toWidget.push({
// @ts-expect-error never
content: `Convert ${w.name} to widget`,
callback: () => convertToWidget(this, w)
})
@@ -860,7 +863,9 @@ app.registerExtension({
for (const input of this.inputs) {
if (input.widget && !input.widget[GET_CONFIG]) {
input.widget[GET_CONFIG] = () =>
// @ts-expect-error input.widget has unknown type
getConfig.call(this, input.widget.name)
// @ts-expect-error input.widget has unknown type
const w = this.widgets.find((w) => w.name === input.widget.name)
if (w) {
hideWidget(this, w)
@@ -935,6 +940,7 @@ app.registerExtension({
originNode,
originSlot
) {
// @ts-expect-error onConnectInput has 5 arguments
const v = onConnectInput?.(this, arguments)
// Not a combo, ignore
if (type !== 'COMBO') return v

View File

@@ -0,0 +1,37 @@
import { useClipboard } from '@vueuse/core'
import { useToast } from 'primevue/usetoast'
export function useCopyToClipboard() {
const { copy, isSupported } = useClipboard()
const toast = useToast()
const copyToClipboard = async (text: string) => {
if (isSupported) {
try {
await copy(text)
toast.add({
severity: 'success',
summary: 'Success',
detail: 'Copied to clipboard',
life: 3000
})
} catch (err) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Failed to copy report'
})
}
} else {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Clipboard API not supported in your browser'
})
}
}
return {
copyToClipboard
}
}

View File

@@ -0,0 +1,18 @@
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import { markRaw } from 'vue'
import { useI18n } from 'vue-i18n'
import DocumentationSidebarTab from '@/components/sidebar/tabs/DocumentationSidebarTab.vue'
import type { SidebarTabExtension } from '@/types/extensionTypes'
export const useDocumentationSidebarTab = (): SidebarTabExtension => {
const { t } = useI18n()
const queuePendingTaskCountStore = useQueuePendingTaskCountStore()
return {
id: 'documentation',
icon: 'mdi mdi-help',
title: t('sideToolbar.documentation'),
tooltip: t('sideToolbar.documentation'),
component: markRaw(DocumentationSidebarTab),
type: 'vue'
}
}

View File

@@ -18,8 +18,8 @@ export const useModelLibrarySidebarTab = (): SidebarTabExtension => {
iconBadge: () => {
if (isElectron()) {
const electronDownloadStore = useElectronDownloadStore()
if (electronDownloadStore.downloads.length > 0) {
return electronDownloadStore.downloads.length.toString()
if (electronDownloadStore.inProgressDownloads.length > 0) {
return electronDownloadStore.inProgressDownloads.length.toString()
}
}

View File

@@ -1,453 +1,16 @@
import { createI18n } from 'vue-i18n'
const messages = {
en: {
welcome: {
title: 'Welcome to ComfyUI',
getStarted: 'Get Started'
},
install: {
installLocation: 'Install Location',
migration: 'Migration',
desktopSettings: 'Desktop Settings',
chooseInstallationLocation: 'Choose Installation Location',
systemLocations: 'System Locations',
failedToSelectDirectory: 'Failed to select directory',
pathValidationFailed: 'Failed to validate path',
installLocationDescription:
"Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~15GB) left.",
installLocationTooltip:
"ComfyUI's user data directory. Stores:\n- Python Environment\n- Models\n- Custom nodes\n",
appDataLocationTooltip:
"ComfyUI's app data directory. Stores:\n- Logs\n- Server configs",
appPathLocationTooltip:
"ComfyUI's app asset directory. Stores the ComfyUI code and assets",
migrateFromExistingInstallation: 'Migrate from Existing Installation',
migrationSourcePathDescription:
'If you have an existing ComfyUI installation, we can copy/link your existing user files and models to the new installation.',
selectItemsToMigrate: 'Select Items to Migrate',
migrationOptional:
"Migration is optional. If you don't have an existing installation, you can skip this step.",
desktopAppSettings: 'Desktop App Settings',
desktopAppSettingsDescription:
'Configure how ComfyUI behaves on your desktop. You can change these settings later.',
settings: {
autoUpdate: 'Automatic Updates',
allowMetrics: 'Crash Reports',
autoUpdateDescription:
"Automatically download and install updates when they become available. You'll always be notified before updates are installed.",
allowMetricsDescription:
'Help improve ComfyUI by sending anonymous crash reports. No personal information or workflow content will be collected. This can be disabled at any time in the settings menu.',
learnMoreAboutData: 'Learn more about data collection',
dataCollectionDialog: {
title: 'About Data Collection',
whatWeCollect: 'What we collect:',
whatWeDoNotCollect: "What we don't collect:",
errorReports: 'Error message and stack trace',
systemInfo: 'Hardware, OS type, and app version',
personalInformation: 'Personal information',
workflowContent: 'Workflow content',
fileSystemInformation: 'File system information',
workflowContents: 'Workflow contents',
customNodeConfigurations: 'Custom node configurations'
}
}
},
serverStart: {
reinstall: 'Reinstall',
reportIssue: 'Report Issue',
openLogs: 'Open Logs',
process: {
'initial-state': 'Loading...',
'python-setup': 'Setting up Python Environment...',
'starting-server': 'Starting ComfyUI server...',
ready: 'Finishing...',
error: 'Unable to start ComfyUI'
}
},
firstTimeUIMessage:
'This is the first time you use the new UI. Choose "Menu > Use New Menu > Disabled" to restore the old UI.',
download: 'Download',
loadAllFolders: 'Load All Folders',
refresh: 'Refresh',
terminal: 'Terminal',
logs: 'Logs',
videoFailedToLoad: 'Video failed to load',
extensionName: 'Extension Name',
reloadToApplyChanges: 'Reload to apply changes',
insert: 'Insert',
systemInfo: 'System Info',
devices: 'Devices',
about: 'About',
add: 'Add',
confirm: 'Confirm',
reset: 'Reset',
resetKeybindingsTooltip: 'Reset keybindings to default',
customizeFolder: 'Customize Folder',
icon: 'Icon',
color: 'Color',
bookmark: 'Bookmark',
folder: 'Folder',
star: 'Star',
heart: 'Heart',
file: 'File',
inbox: 'Inbox',
box: 'Box',
briefcase: 'Briefcase',
error: 'Error',
loading: 'Loading',
findIssues: 'Find Issues',
reportIssue: 'Send Report',
reportIssueTooltip: 'Submit the error report to Comfy Org',
reportSent: 'Report Submitted',
copyToClipboard: 'Copy to Clipboard',
openNewIssue: 'Open New Issue',
showReport: 'Show Report',
imageFailedToLoad: 'Image failed to load',
reconnecting: 'Reconnecting',
reconnected: 'Reconnected',
delete: 'Delete',
rename: 'Rename',
customize: 'Customize',
experimental: 'BETA',
deprecated: 'DEPR',
loadWorkflow: 'Load Workflow',
goToNode: 'Go to Node',
settings: 'Settings',
searchWorkflows: 'Search Workflows',
searchSettings: 'Search Settings',
searchNodes: 'Search Nodes',
searchModels: 'Search Models',
searchKeybindings: 'Search Keybindings',
noResultsFound: 'No Results Found',
searchFailedMessage:
"We couldn't find any settings matching your search. Try adjusting your search terms.",
noTasksFound: 'No Tasks Found',
noTasksFoundMessage: 'There are no tasks in the queue.',
newFolder: 'New Folder',
sideToolbar: {
themeToggle: 'Toggle Theme',
queue: 'Queue',
nodeLibrary: 'Node Library',
workflows: 'Workflows',
browseTemplates: 'Browse example templates',
openWorkflow: 'Open workflow in local file system',
newBlankWorkflow: 'Create a new blank workflow',
nodeLibraryTab: {
sortOrder: 'Sort Order'
},
modelLibrary: 'Model Library',
downloads: 'Downloads',
queueTab: {
showFlatList: 'Show Flat List',
backToAllTasks: 'Back to All Tasks',
containImagePreview: 'Fill Image Preview',
coverImagePreview: 'Fit Image Preview',
clearPendingTasks: 'Clear Pending Tasks',
filter: 'Filter Outputs',
filters: {
hideCached: 'Hide Cached',
hideCanceled: 'Hide Canceled'
}
}
},
menu: {
hideMenu: 'Hide Menu',
showMenu: 'Show Menu',
batchCount: 'Batch Count',
batchCountTooltip:
'The number of times the workflow generation should be queued',
autoQueue: 'Auto Queue',
disabled: 'Disabled',
disabledTooltip: 'The workflow will not be automatically queued',
instant: 'Instant',
instantTooltip:
'The workflow will be queued instantly after a generation finishes',
change: 'On Change',
changeTooltip: 'The workflow will be queued once a change is made',
queueWorkflow: 'Queue workflow (Shift to queue at front)',
queueWorkflowFront: 'Queue workflow at front',
queue: 'Queue',
interrupt: 'Cancel current run',
refresh: 'Refresh node definitions',
clipspace: 'Open Clipspace',
resetView: 'Reset canvas view',
clear: 'Clear workflow',
toggleBottomPanel: 'Toggle Bottom Panel'
},
templateWorkflows: {
title: 'Get Started with a Template',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: 'Zoom In',
zoomOut: 'Zoom Out',
resetView: 'Reset View',
fitView: 'Fit View',
selectMode: 'Select Mode',
panMode: 'Pan Mode',
toggleLinkVisibility: 'Toggle Link Visibility'
},
electronFileDownload: {
inProgress: 'In Progress',
pause: 'Pause Download',
paused: 'Paused',
resume: 'Resume Download',
cancel: 'Cancel Download',
cancelled: 'Cancelled'
}
},
zh: {
firstTimeUIMessage:
'这是您第一次使用新界面。选择“Menu > Use New Menu > Disabled”以恢复旧界面。',
download: '下载',
loadAllFolders: '加载所有文件夹',
refresh: '刷新',
terminal: '终端',
videoFailedToLoad: '视频加载失败',
extensionName: '扩展名称',
reloadToApplyChanges: '重新加载以应用更改',
insert: '插入',
systemInfo: '系统信息',
devices: '设备',
about: '关于',
add: '添加',
confirm: '确认',
reset: '重置',
resetKeybindingsTooltip: '重置键位',
customizeFolder: '定制文件夹',
icon: '图标',
color: '颜色',
bookmark: '书签',
folder: '文件夹',
star: '星星',
heart: '心',
file: '文件',
inbox: '收件箱',
box: '盒子',
briefcase: '公文包',
error: '错误',
loading: '加载中',
findIssues: '查找 Issue',
copyToClipboard: '复制到剪贴板',
openNewIssue: '开启新 Issue',
showReport: '显示报告',
imageFailedToLoad: '图像加载失败',
reconnecting: '重新连接中',
reconnected: '已重新连接',
delete: '删除',
rename: '重命名',
customize: '定制',
experimental: 'BETA',
deprecated: '弃用',
loadWorkflow: '加载工作流',
goToNode: '前往节点',
settings: '设置',
searchWorkflows: '搜索工作流',
searchSettings: '搜索设置',
searchNodes: '搜索节点',
searchModels: '搜索模型',
searchKeybindings: '搜索键位',
noResultsFound: '未找到结果',
searchFailedMessage:
'我们找不到与您的搜索匹配的任何设置。请尝试调整搜索条件。',
noContent: '(无内容)',
noTasksFound: '未找到任务',
noTasksFoundMessage: '队列中没有任务。',
newFolder: '新建文件夹',
sideToolbar: {
themeToggle: '主题切换',
queue: '队列',
nodeLibrary: '节点库',
workflows: '工作流',
browseTemplates: '浏览示例模板',
openWorkflow: '在本地文件系统中打开工作流',
newBlankWorkflow: '创建一个新空白工作流',
nodeLibraryTab: {
sortOrder: '排序顺序'
},
modelLibrary: '模型库',
queueTab: {
showFlatList: '平铺结果',
backToAllTasks: '返回',
containImagePreview: '填充图像预览',
coverImagePreview: '适应图像预览',
clearPendingTasks: '清除待处理任务'
}
},
menu: {
hideMenu: '隐藏菜单',
showMenu: '显示菜单',
batchCount: '批次数量',
batchCountTooltip: '工作流生成次数',
autoQueue: '自动执行',
disabled: '禁用',
disabledTooltip: '工作流将不会自动执行',
instant: '实时',
instantTooltip: '工作流将会在生成完成后立即执行',
change: '变动',
changeTooltip: '工作流将会在改变后执行',
queueWorkflow: '执行 (Shift 执行到队列首)',
queueWorkflowFront: '执行到队列首',
queue: '队列',
interrupt: '取消当前任务',
refresh: '刷新节点',
clipspace: '打开剪贴板',
resetView: '重置画布视图',
clear: '清空工作流',
toggleBottomPanel: '底部面板'
},
templateWorkflows: {
title: '从模板开始',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: '放大',
zoomOut: '缩小',
resetView: '重置视图',
fitView: '适应视图',
selectMode: '选择模式',
panMode: '平移模式',
toggleLinkVisibility: '切换链接可见性'
}
},
ru: {
download: 'Скачать',
refresh: 'Обновить',
loadAllFolders: 'Загрузить все папки',
terminal: 'Терминал',
videoFailedToLoad: 'Видео не удалось загрузить',
extensionName: 'Название расширения',
reloadToApplyChanges: 'Перезагрузите, чтобы применить изменения',
insert: 'Вставить',
systemInfo: 'Информация о системе',
devices: 'Устройства',
about: 'О',
add: 'Добавить',
confirm: 'Подтвердить',
reset: 'Сбросить',
resetKeybindingsTooltip: 'Сбросить сочетания клавиш по умолчанию',
customizeFolder: 'Настроить папку',
icon: 'Иконка',
color: 'Цвет',
bookmark: 'Закладка',
folder: 'Папка',
star: 'Звёздочка',
heart: 'Сердце',
file: 'Файл',
inbox: 'Входящие',
box: 'Ящик',
briefcase: 'Чемодан',
error: 'Ошибка',
loading: 'Загрузка',
findIssues: 'Найти Issue',
copyToClipboard: 'Копировать в буфер обмена',
openNewIssue: 'Открыть новый Issue',
showReport: 'Показать отчёт',
imageFailedToLoad: 'Изображение не удалось загрузить',
reconnecting: 'Переподключение',
reconnected: 'Переподключено',
delete: 'Удалить',
rename: 'Переименовать',
customize: 'Настроить',
experimental: 'БЕТА',
deprecated: 'УСТАР',
loadWorkflow: 'Загрузить рабочий процесс',
goToNode: 'Перейти к узлу',
settings: 'Настройки',
searchWorkflows: 'Поиск рабочих процессов',
searchSettings: 'Поиск настроек',
searchNodes: 'Поиск узлов',
searchModels: 'Поиск моделей',
searchKeybindings: 'Поиск сочетаний клавиш',
noResultsFound: 'Ничего не найдено',
searchFailedMessage:
'Не удалось найти ни одной настройки, соответствующей вашему запросу. Попробуйте скорректировать поисковый запрос.',
noContent: '(Нет контента)',
noTasksFound: 'Задачи не найдены',
noTasksFoundMessage: 'В очереди нет задач.',
newFolder: 'Новая папка',
sideToolbar: {
themeToggle: 'Переключить тему',
queue: 'Очередь',
nodeLibrary: 'Библиотека узлов',
workflows: 'Рабочие процессы',
browseTemplates: 'Просмотреть примеры шаблонов',
openWorkflow: 'Открыть рабочий процесс в локальной файловой системе',
newBlankWorkflow: 'Создайте новый пустой рабочий процесс',
nodeLibraryTab: {
sortOrder: 'Порядок сортировки'
},
modelLibrary: 'Библиотека моделей',
queueTab: {
showFlatList: 'Показать плоский список',
backToAllTasks: 'Вернуться ко всем задачам',
containImagePreview: 'Предпросмотр заливающего изображения',
coverImagePreview: 'Предпросмотр подходящего изображения',
clearPendingTasks: 'Очистить отложенные задачи'
}
},
menu: {
hideMenu: 'Скрыть меню',
showMenu: 'Показать меню',
batchCount: 'Количество пакетов',
batchCountTooltip:
'Количество раз, когда генерация рабочего процесса должна быть помещена в очередь',
autoQueue: 'Автоочередь',
disabled: 'Отключено',
disabledTooltip:
'Рабочий процесс не будет автоматически помещён в очередь',
instant: 'Мгновенно',
instantTooltip:
'Рабочий процесс будет помещён в очередь сразу же после завершения генерации',
change: 'При изменении',
changeTooltip:
'Рабочий процесс будет поставлен в очередь после внесения изменений',
queueWorkflow: 'Очередь рабочего процесса (Shift для вставки спереди)',
queueWorkflowFront: 'Очередь рабочего процесса (Вставка спереди)',
queue: 'Очередь',
interrupt: 'Отменить текущее выполнение',
refresh: 'Обновить определения узлов',
clipspace: 'Открыть Clipspace',
resetView: 'Сбросить вид холста',
clear: 'Очистить рабочий процесс'
},
templateWorkflows: {
title: 'Начните работу с шаблона',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
resetView: 'Сбросить вид',
fitView: 'Подгонять под выделенные',
selectMode: 'Выбрать режим',
panMode: 'Режим панорамирования',
toggleLinkVisibility: 'Переключить видимость ссылок'
}
}
// TODO: Add more languages
}
import en from './locales/en'
import zh from './locales/zh'
import ru from './locales/ru'
export const i18n = createI18n({
// Must set `false`, as Vue I18n Legacy API is for Vue 2
legacy: false,
locale: navigator.language.split('-')[0] || 'en',
fallbackLocale: 'en',
messages
messages: {
en,
zh,
ru
}
})

225
src/locales/en.ts Normal file
View File

@@ -0,0 +1,225 @@
export default {
welcome: {
title: 'Welcome to ComfyUI',
getStarted: 'Get Started'
},
notSupported: {
title: 'Your device is not supported',
message: 'Only following devices are supported:',
learnMore: 'Learn More',
reportIssue: 'Report Issue',
supportedDevices: {
macos: 'MacOS (M1 or later)',
windows: 'Windows (Nvidia GPU with CUDA support)'
}
},
install: {
installLocation: 'Install Location',
migration: 'Migration',
desktopSettings: 'Desktop Settings',
chooseInstallationLocation: 'Choose Installation Location',
systemLocations: 'System Locations',
failedToSelectDirectory: 'Failed to select directory',
pathValidationFailed: 'Failed to validate path',
installLocationDescription:
"Select the directory for ComfyUI's user data. A python environment will be installed to the selected location. Please make sure the selected disk has enough space (~15GB) left.",
installLocationTooltip:
"ComfyUI's user data directory. Stores:\n- Python Environment\n- Models\n- Custom nodes\n",
appDataLocationTooltip:
"ComfyUI's app data directory. Stores:\n- Logs\n- Server configs",
appPathLocationTooltip:
"ComfyUI's app asset directory. Stores the ComfyUI code and assets",
migrateFromExistingInstallation: 'Migrate from Existing Installation',
migrationSourcePathDescription:
'If you have an existing ComfyUI installation, we can copy/link your existing user files and models to the new installation.',
selectItemsToMigrate: 'Select Items to Migrate',
migrationOptional:
"Migration is optional. If you don't have an existing installation, you can skip this step.",
desktopAppSettings: 'Desktop App Settings',
desktopAppSettingsDescription:
'Configure how ComfyUI behaves on your desktop. You can change these settings later.',
settings: {
autoUpdate: 'Automatic Updates',
allowMetrics: 'Crash Reports',
autoUpdateDescription:
"Automatically download and install updates when they become available. You'll always be notified before updates are installed.",
allowMetricsDescription:
'Help improve ComfyUI by sending anonymous crash reports. No personal information or workflow content will be collected. This can be disabled at any time in the settings menu.',
learnMoreAboutData: 'Learn more about data collection',
dataCollectionDialog: {
title: 'About Data Collection',
whatWeCollect: 'What we collect:',
whatWeDoNotCollect: "What we don't collect:",
errorReports: 'Error message and stack trace',
systemInfo: 'Hardware, OS type, and app version',
personalInformation: 'Personal information',
workflowContent: 'Workflow content',
fileSystemInformation: 'File system information',
workflowContents: 'Workflow contents',
customNodeConfigurations: 'Custom node configurations'
}
},
customNodes: 'Custom Nodes',
customNodesDescription:
'Reference custom node files from existing ComfyUI installations and install their dependencies.'
},
serverStart: {
reinstall: 'Reinstall',
reportIssue: 'Report Issue',
openLogs: 'Open Logs',
process: {
'initial-state': 'Loading...',
'python-setup': 'Setting up Python Environment...',
'starting-server': 'Starting ComfyUI server...',
ready: 'Finishing...',
error: 'Unable to start ComfyUI Desktop'
}
},
serverConfig: {
modifiedConfigs:
'You have modified the following server configurations. Restart to apply changes.',
revertChanges: 'Revert Changes',
restart: 'Restart'
},
empty: 'Empty',
noWorkflowsFound: 'No workflows found.',
comingSoon: 'Coming Soon',
firstTimeUIMessage:
'This is the first time you use the new UI. Choose "Menu > Use New Menu > Disabled" to restore the old UI.',
download: 'Download',
loadAllFolders: 'Load All Folders',
refresh: 'Refresh',
terminal: 'Terminal',
logs: 'Logs',
videoFailedToLoad: 'Video failed to load',
extensionName: 'Extension Name',
reloadToApplyChanges: 'Reload to apply changes',
insert: 'Insert',
systemInfo: 'System Info',
devices: 'Devices',
about: 'About',
add: 'Add',
confirm: 'Confirm',
reset: 'Reset',
resetKeybindingsTooltip: 'Reset keybindings to default',
customizeFolder: 'Customize Folder',
icon: 'Icon',
color: 'Color',
bookmark: 'Bookmark',
folder: 'Folder',
star: 'Star',
heart: 'Heart',
file: 'File',
inbox: 'Inbox',
box: 'Box',
briefcase: 'Briefcase',
error: 'Error',
loading: 'Loading',
findIssues: 'Find Issues',
reportIssue: 'Send Report',
reportIssueTooltip: 'Submit the error report to Comfy Org',
reportSent: 'Report Submitted',
copyToClipboard: 'Copy to Clipboard',
openNewIssue: 'Open New Issue',
showReport: 'Show Report',
imageFailedToLoad: 'Image failed to load',
reconnecting: 'Reconnecting',
reconnected: 'Reconnected',
delete: 'Delete',
rename: 'Rename',
customize: 'Customize',
experimental: 'BETA',
deprecated: 'DEPR',
loadWorkflow: 'Load Workflow',
goToNode: 'Go to Node',
settings: 'Settings',
searchWorkflows: 'Search Workflows',
searchSettings: 'Search Settings',
searchNodes: 'Search Nodes',
searchModels: 'Search Models',
searchKeybindings: 'Search Keybindings',
searchExtensions: 'Search Extensions',
noResultsFound: 'No Results Found',
searchFailedMessage:
"We couldn't find any settings matching your search. Try adjusting your search terms.",
noTasksFound: 'No Tasks Found',
noTasksFoundMessage: 'There are no tasks in the queue.',
newFolder: 'New Folder',
sideToolbar: {
themeToggle: 'Toggle Theme',
queue: 'Queue',
nodeLibrary: 'Node Library',
workflows: 'Workflows',
browseTemplates: 'Browse example templates',
openWorkflow: 'Open workflow in local file system',
newBlankWorkflow: 'Create a new blank workflow',
nodeLibraryTab: {
sortOrder: 'Sort Order'
},
modelLibrary: 'Model Library',
downloads: 'Downloads',
documentation: 'Display documentation for nodes',
queueTab: {
showFlatList: 'Show Flat List',
backToAllTasks: 'Back to All Tasks',
containImagePreview: 'Fill Image Preview',
coverImagePreview: 'Fit Image Preview',
clearPendingTasks: 'Clear Pending Tasks',
filter: 'Filter Outputs',
filters: {
hideCached: 'Hide Cached',
hideCanceled: 'Hide Canceled'
}
}
},
menu: {
hideMenu: 'Hide Menu',
showMenu: 'Show Menu',
batchCount: 'Batch Count',
batchCountTooltip:
'The number of times the workflow generation should be queued',
autoQueue: 'Auto Queue',
disabled: 'Disabled',
disabledTooltip: 'The workflow will not be automatically queued',
instant: 'Instant',
instantTooltip:
'The workflow will be queued instantly after a generation finishes',
change: 'On Change',
changeTooltip: 'The workflow will be queued once a change is made',
queueWorkflow: 'Queue workflow (Shift to queue at front)',
queueWorkflowFront: 'Queue workflow at front',
queue: 'Queue',
interrupt: 'Cancel current run',
refresh: 'Refresh node definitions',
clipspace: 'Open Clipspace',
resetView: 'Reset canvas view',
clear: 'Clear workflow',
toggleBottomPanel: 'Toggle Bottom Panel'
},
templateWorkflows: {
title: 'Get Started with a Template',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: 'Zoom In',
zoomOut: 'Zoom Out',
resetView: 'Reset View',
fitView: 'Fit View',
selectMode: 'Select Mode',
panMode: 'Pan Mode',
toggleLinkVisibility: 'Toggle Link Visibility'
},
electronFileDownload: {
inProgress: 'In Progress',
pause: 'Pause Download',
paused: 'Paused',
resume: 'Resume Download',
cancel: 'Cancel Download',
cancelled: 'Cancelled'
}
}

122
src/locales/ru.ts Normal file
View File

@@ -0,0 +1,122 @@
export default {
empty: 'Пусто',
noWorkflowsFound: 'Рабочие процессы не найдены.',
download: 'Скачать',
refresh: 'Обновить',
loadAllFolders: 'Загрузить все папки',
terminal: 'Терминал',
videoFailedToLoad: 'Видео не удалось загрузить',
extensionName: 'Название расширения',
reloadToApplyChanges: 'Перезагрузите, чтобы применить изменения',
insert: 'Вставить',
systemInfo: 'Информация о системе',
devices: 'Устройства',
about: 'О',
add: 'Добавить',
confirm: 'Подтвердить',
reset: 'Сбросить',
resetKeybindingsTooltip: 'Сбросить сочетания клавиш по умолчанию',
customizeFolder: 'Настроить папку',
icon: 'Иконка',
color: 'Цвет',
bookmark: 'Закладка',
folder: 'Папка',
star: 'Звёздочка',
heart: 'Сердце',
file: 'Файл',
inbox: 'Входящие',
box: 'Ящик',
briefcase: 'Чемодан',
error: 'Ошибка',
loading: 'Загрузка',
findIssues: 'Найти Issue',
copyToClipboard: 'Копировать в буфер обмена',
openNewIssue: 'Открыть новый Issue',
showReport: 'Показать отчёт',
imageFailedToLoad: 'Изображение не удалось загрузить',
reconnecting: 'Переподключение',
reconnected: 'Переподключено',
delete: 'Удалить',
rename: 'Переименовать',
customize: 'Настроить',
experimental: 'БЕТА',
deprecated: 'УСТАР',
loadWorkflow: 'Загрузить рабочий процесс',
goToNode: 'Перейти к узлу',
settings: 'Настройки',
searchWorkflows: 'Поиск рабочих процессов',
searchSettings: 'Поиск настроек',
searchNodes: 'Поиск узлов',
searchModels: 'Поиск моделей',
searchKeybindings: 'Поиск сочетаний клавиш',
searchExtensions: 'Поиск расширений',
noResultsFound: 'Ничего не найдено',
searchFailedMessage:
'Не удалось найти ни одной настройки, соответствующей вашему запросу. Попробуйте скорректировать поисковый запрос.',
noContent: '(Нет контента)',
noTasksFound: 'Задачи не найдены',
noTasksFoundMessage: 'В очереди нет задач.',
newFolder: 'Новая папка',
sideToolbar: {
themeToggle: 'Переключить тему',
queue: 'Очередь',
nodeLibrary: 'Библиотека узлов',
workflows: 'Рабочие процессы',
browseTemplates: 'Просмотреть примеры шаблонов',
openWorkflow: 'Открыть рабочий процесс в локальной файловой системе',
newBlankWorkflow: 'Создайте новый пустой рабочий процесс',
nodeLibraryTab: {
sortOrder: 'Порядок сортировки'
},
modelLibrary: 'Библиотека моделей',
queueTab: {
showFlatList: 'Показать плоский список',
backToAllTasks: 'Вернуться ко всем задачам',
containImagePreview: 'Предпросмотр заливающего изображения',
coverImagePreview: 'Предпросмотр подходящего изображения',
clearPendingTasks: 'Очистить отложенные задачи'
}
},
menu: {
hideMenu: 'Скрыть меню',
showMenu: 'Показать меню',
batchCount: 'Количество пакетов',
batchCountTooltip:
'Количество раз, когда генерация рабочего процесса должна быть помещена в очередь',
autoQueue: 'Автоочередь',
disabled: 'Отключено',
disabledTooltip: 'Рабочий процесс не будет автоматически помещён в очередь',
instant: 'Мгновенно',
instantTooltip:
'Рабочий процесс будет помещён в очередь сразу же после завершения генерации',
change: 'При изменении',
changeTooltip:
'Рабочий процесс будет поставлен в очередь после внесения изменений',
queueWorkflow: 'Очередь рабочего процесса (Shift для вставки спереди)',
queueWorkflowFront: 'Очередь рабочего процесса (Вставка спереди)',
queue: 'Очередь',
interrupt: 'Отменить текущее выполнение',
refresh: 'Обновить определения узлов',
clipspace: 'Открыть Clipspace',
resetView: 'Сбросить вид холста',
clear: 'Очистить рабочий процесс'
},
templateWorkflows: {
title: 'Начните работу с шаблона',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: 'Увеличить',
zoomOut: 'Уменьшить',
resetView: 'Сбросить вид',
fitView: 'Подгонять под выделенные',
selectMode: 'Выбрать режим',
panMode: 'Режим панорамирования',
toggleLinkVisibility: 'Переключить видимость ссылок'
}
}

122
src/locales/zh.ts Normal file
View File

@@ -0,0 +1,122 @@
export default {
empty: '空',
noWorkflowsFound: '未找到工作流',
firstTimeUIMessage:
'这是您第一次使用新界面。选择“Menu > Use New Menu > Disabled”以恢复旧界面。',
download: '下载',
loadAllFolders: '加载所有文件夹',
refresh: '刷新',
terminal: '终端',
videoFailedToLoad: '视频加载失败',
extensionName: '扩展名称',
reloadToApplyChanges: '重新加载以应用更改',
insert: '插入',
systemInfo: '系统信息',
devices: '设备',
about: '关于',
add: '添加',
confirm: '确认',
reset: '重置',
resetKeybindingsTooltip: '重置键位',
customizeFolder: '定制文件夹',
icon: '图标',
color: '颜色',
bookmark: '书签',
folder: '文件夹',
star: '星星',
heart: '心',
file: '文件',
inbox: '收件箱',
box: '盒子',
briefcase: '公文包',
error: '错误',
loading: '加载中',
findIssues: '查找 Issue',
copyToClipboard: '复制到剪贴板',
openNewIssue: '开启新 Issue',
showReport: '显示报告',
imageFailedToLoad: '图像加载失败',
reconnecting: '重新连接中',
reconnected: '已重新连接',
delete: '删除',
rename: '重命名',
customize: '定制',
experimental: 'BETA',
deprecated: '弃用',
loadWorkflow: '加载工作流',
goToNode: '前往节点',
settings: '设置',
searchWorkflows: '搜索工作流',
searchSettings: '搜索设置',
searchNodes: '搜索节点',
searchModels: '搜索模型',
searchKeybindings: '搜索键位',
searchExtensions: '搜索插件',
noResultsFound: '未找到结果',
searchFailedMessage:
'我们找不到与您的搜索匹配的任何设置。请尝试调整搜索条件。',
noContent: '(无内容)',
noTasksFound: '未找到任务',
noTasksFoundMessage: '队列中没有任务。',
newFolder: '新建文件夹',
sideToolbar: {
themeToggle: '主题切换',
queue: '队列',
nodeLibrary: '节点库',
workflows: '工作流',
browseTemplates: '浏览示例模板',
openWorkflow: '在本地文件系统中打开工作流',
newBlankWorkflow: '创建一个新空白工作流',
nodeLibraryTab: {
sortOrder: '排序顺序'
},
modelLibrary: '模型库',
queueTab: {
showFlatList: '平铺结果',
backToAllTasks: '返回',
containImagePreview: '填充图像预览',
coverImagePreview: '适应图像预览',
clearPendingTasks: '清除待处理任务'
}
},
menu: {
hideMenu: '隐藏菜单',
showMenu: '显示菜单',
batchCount: '批次数量',
batchCountTooltip: '工作流生成次数',
autoQueue: '自动执行',
disabled: '禁用',
disabledTooltip: '工作流将不会自动执行',
instant: '实时',
instantTooltip: '工作流将会在生成完成后立即执行',
change: '变动',
changeTooltip: '工作流将会在改变后执行',
queueWorkflow: '执行 (Shift 执行到队列首)',
queueWorkflowFront: '执行到队列首',
queue: '队列',
interrupt: '取消当前任务',
refresh: '刷新节点',
clipspace: '打开剪贴板',
resetView: '重置画布视图',
clear: '清空工作流',
toggleBottomPanel: '底部面板'
},
templateWorkflows: {
title: '从模板开始',
template: {
default: 'Image Generation',
image2image: 'Image to Image',
upscale: '2 Pass Upscale',
flux_schnell: 'Flux Schnell'
}
},
graphCanvasMenu: {
zoomIn: '放大',
zoomOut: '缩小',
resetView: '重置视图',
fitView: '适应视图',
selectMode: '选择模式',
panMode: '平移模式',
toggleLinkVisibility: '切换链接可见性'
}
}

View File

@@ -57,6 +57,12 @@ const router = createRouter({
name: 'WelcomeView',
component: () => import('@/views/WelcomeView.vue'),
beforeEnter: guardElectronAccess
},
{
path: 'not-supported',
name: 'NotSupportedView',
component: () => import('@/views/NotSupportedView.vue'),
beforeEnter: guardElectronAccess
}
]
}

View File

@@ -137,6 +137,7 @@ export class ComfyApp {
canvas: LGraphCanvas
dragOverNode: LGraphNode | null
canvasEl: HTMLCanvasElement
tooltipCallback?: (node: LGraphNode, name: string, value?: string) => boolean
// x, y, scale
zoom_drag_start: [number, number, number] | null
lastNodeErrors: any[] | null
@@ -145,7 +146,6 @@ export class ComfyApp {
configuringGraph: boolean
isNewUserSession: boolean
storageLocation: StorageLocation
multiUserServer: boolean
ctx: CanvasRenderingContext2D
bodyTop: HTMLElement
bodyLeft: HTMLElement
@@ -1728,7 +1728,6 @@ export class ComfyApp {
return
}
this.multiUserServer = true
let user = localStorage['Comfy.userId']
const users = userConfig.users ?? {}
if (!user || !users[user]) {

View File

@@ -388,8 +388,11 @@ export class ChangeTracker {
return false
}
// Reroutes (schema 0.4)
if (!_.isEqual(a.extra?.reroutes, b.extra?.reroutes)) return false
// Compare extra properties ignoring ds
if (
!_.isEqual(_.omit(a.extra ?? {}, ['ds']), _.omit(b.extra ?? {}, ['ds']))
)
return false
// Compare other properties normally
for (const key of ['links', 'reroutes', 'groups']) {

View File

@@ -66,7 +66,7 @@ export const workflowService = {
const workflowStore = useWorkflowStore()
const existingWorkflow = workflowStore.getWorkflowByPath(newPath)
if (existingWorkflow) {
if (existingWorkflow && !existingWorkflow.isTemporary) {
const res = (await ComfyAsyncDialog.prompt({
title: 'Overwrite existing file?',
message: `"${newPath}" already exists. Do you want to overwrite it?`,

View File

@@ -2,52 +2,126 @@
// Currently we need to bridge between legacy app code and Vue app with a Pinia store.
import { defineStore } from 'pinia'
import { ref, shallowRef, type Component, markRaw } from 'vue'
import { ref, type Component, markRaw } from 'vue'
interface DialogComponentProps {
maximizable?: boolean
onClose?: () => void
}
export const useDialogStore = defineStore('dialog', () => {
const isVisible = ref(false)
const title = ref('')
const headerComponent = shallowRef<Component | null>(null)
const component = shallowRef<Component | null>(null)
const props = ref<Record<string, any>>({})
const dialogComponentProps = ref<DialogComponentProps>({})
interface DialogInstance {
key: string
visible: boolean
title?: string
headerComponent?: Component
component: Component
contentProps: Record<string, any>
dialogComponentProps: Record<string, any>
}
function showDialog(options: {
export const useDialogStore = defineStore('dialog', () => {
const dialogStack = ref<DialogInstance[]>([])
const genDialogKey = () => `dialog-${Math.random().toString(36).slice(2, 9)}`
function riseDialog(options: { key: string }) {
const dialogKey = options.key
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
if (index !== -1) {
const dialogs = dialogStack.value.splice(index, 1)
dialogStack.value.push(...dialogs)
}
}
function closeDialog(options?: { key: string }) {
if (!options) {
dialogStack.value.pop()
return
}
const dialogKey = options.key
const index = dialogStack.value.findIndex((d) => d.key === dialogKey)
if (index === -1) {
return
}
dialogStack.value.splice(index, 1)
}
function createDialog(options: {
key: string
title?: string
headerComponent?: Component
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
}) {
isVisible.value = true
title.value = options.title ?? ''
headerComponent.value = options.headerComponent
? markRaw(options.headerComponent)
: null
component.value = markRaw(options.component)
props.value = options.props || {}
dialogComponentProps.value = options.dialogComponentProps || {}
const dialog = {
key: options.key,
visible: true,
title: options.title,
headerComponent: options.headerComponent
? markRaw(options.headerComponent)
: undefined,
component: markRaw(options.component),
contentProps: { ...options.props },
dialogComponentProps: {
maximizable: false,
modal: true,
closable: true,
closeOnEscape: true,
dismissableMask: true,
...options.dialogComponentProps,
maximized: false,
onMaximize: () => {
dialog.dialogComponentProps.maximized = true
},
onUnmaximize: () => {
dialog.dialogComponentProps.maximized = false
},
onAfterHide: () => {
options.dialogComponentProps?.onClose?.()
closeDialog(dialog)
},
pt: {
root: {
onMousedown: () => {
riseDialog(dialog)
}
}
}
}
}
dialogStack.value.push(dialog)
return dialog
}
function closeDialog() {
if (dialogComponentProps.value.onClose) {
dialogComponentProps.value.onClose()
function showDialog(options: {
key?: string
title?: string
headerComponent?: Component
component: Component
props?: Record<string, any>
dialogComponentProps?: DialogComponentProps
}) {
const dialogKey = options.key || genDialogKey()
let dialog = dialogStack.value.find((d) => d.key === dialogKey)
if (dialog) {
dialog.visible = true
riseDialog(dialog)
} else {
dialog = createDialog({ ...options, key: dialogKey })
}
isVisible.value = false
return dialog
}
return {
isVisible,
title,
headerComponent,
component,
props,
dialogComponentProps,
dialogStack,
riseDialog,
showDialog,
closeDialog
}

View File

@@ -1,8 +1,8 @@
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { defineStore } from 'pinia'
import { isElectron, electronAPI } from '@/utils/envUtil'
import type {
DownloadState,
import {
type DownloadState,
DownloadStatus
} from '@comfyorg/comfyui-electron-types'
@@ -69,6 +69,11 @@ export const useElectronDownloadStore = defineStore('downloads', () => {
resume,
cancel,
findByUrl,
initialize
initialize,
inProgressDownloads: computed(() =>
downloads.value.filter(
({ status }) => status !== DownloadStatus.COMPLETED
)
)
}
})

View File

@@ -1,4 +1,5 @@
import { LGraphNode, LGraphGroup, LGraphCanvas } from '@comfyorg/litegraph'
import type { ComfyNodeItem } from '@/types/comfy'
import { defineStore } from 'pinia'
import { shallowRef } from 'vue'
@@ -10,6 +11,14 @@ export const useTitleEditorStore = defineStore('titleEditor', () => {
}
})
export const useHoveredItemStore = defineStore('hoveredItem', () => {
const hoveredItem = shallowRef<ComfyNodeItem | null>(null)
return {
hoveredItem
}
})
export const useCanvasStore = defineStore('canvas', () => {
/**
* The LGraphCanvas instance.

View File

@@ -5,7 +5,8 @@ import {
import {
type ComfyNodeDef,
type ComfyInputsSpec as ComfyInputsSpecSchema,
type InputSpec
type InputSpec,
type DescriptionSpec
} from '@/types/apiTypes'
import { defineStore } from 'pinia'
import { ComfyWidgetConstructor } from '@/scripts/widgets'
@@ -158,7 +159,7 @@ export class ComfyNodeDefImpl {
display_name: string
category: string
python_module: string
description: string
description: DescriptionSpec
deprecated: boolean
experimental: boolean
input: ComfyInputsSpec

View File

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

View File

@@ -1,18 +1,39 @@
import { ServerConfig } from '@/constants/serverConfig'
import { ServerConfig, ServerConfigValue } from '@/constants/serverConfig'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export type ServerConfigWithValue<T> = ServerConfig<T> & {
/**
* Current value.
*/
value: T
/**
* Initial value loaded from settings.
*/
initialValue: T
}
export const useServerConfigStore = defineStore('serverConfig', () => {
const serverConfigById = ref<Record<string, ServerConfigWithValue<any>>>({})
const serverConfigById = ref<
Record<string, ServerConfigWithValue<ServerConfigValue>>
>({})
const serverConfigs = computed(() => {
return Object.values(serverConfigById.value)
})
const modifiedConfigs = computed<ServerConfigWithValue<ServerConfigValue>[]>(
() => {
return serverConfigs.value.filter((config) => {
return config.initialValue !== config.value
})
}
)
const revertChanges = () => {
for (const config of modifiedConfigs.value) {
config.value = config.initialValue
}
}
const serverConfigsByCategory = computed<
Record<string, ServerConfigWithValue<any>[]>
Record<string, ServerConfigWithValue<ServerConfigValue>[]>
>(() => {
return serverConfigs.value.reduce(
(acc, config) => {
@@ -21,15 +42,17 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
acc[category].push(config)
return acc
},
{} as Record<string, ServerConfigWithValue<any>[]>
{} as Record<string, ServerConfigWithValue<ServerConfigValue>[]>
)
})
const serverConfigValues = computed<Record<string, any>>(() => {
const serverConfigValues = computed<Record<string, ServerConfigValue>>(() => {
return Object.fromEntries(
serverConfigs.value.map((config) => {
return [
config.id,
config.value === config.defaultValue || !config.value
config.value === config.defaultValue ||
config.value === null ||
config.value === undefined
? undefined
: config.value
]
@@ -37,10 +60,18 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
)
})
const launchArgs = computed<Record<string, string>>(() => {
return Object.assign(
const args: Record<
string,
Omit<ServerConfigValue, 'undefined' | 'null'>
> = Object.assign(
{},
...serverConfigs.value.map((config) => {
if (config.value === config.defaultValue || !config.value) {
// Filter out configs that have the default value or undefined | null value
if (
config.value === config.defaultValue ||
config.value === null ||
config.value === undefined
) {
return {}
}
return config.getValue
@@ -48,16 +79,36 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
: { [config.id]: config.value }
})
)
// Convert true to empty string
// Convert number to string
return Object.fromEntries(
Object.entries(args).map(([key, value]) => {
if (value === true) {
return [key, '']
}
return [key, value.toString()]
})
) as Record<string, string>
})
const commandLineArgs = computed<string>(() => {
return Object.entries(launchArgs.value)
.map(([key, value]) => [`--${key}`, value])
.flat()
.filter((arg: string) => arg !== '')
.join(' ')
})
function loadServerConfig(
configs: ServerConfig<any>[],
values: Record<string, any>
configs: ServerConfig<ServerConfigValue>[],
values: Record<string, ServerConfigValue>
) {
for (const config of configs) {
const value = values[config.id] ?? config.defaultValue
serverConfigById.value[config.id] = {
...config,
value: values[config.id] ?? config.defaultValue
value,
initialValue: value
}
}
}
@@ -65,9 +116,12 @@ export const useServerConfigStore = defineStore('serverConfig', () => {
return {
serverConfigById,
serverConfigs,
modifiedConfigs,
serverConfigsByCategory,
serverConfigValues,
launchArgs,
commandLineArgs,
revertChanges,
loadServerConfig
}
})

View File

@@ -2,6 +2,7 @@ import { useModelLibrarySidebarTab } from '@/hooks/sidebarTabs/modelLibrarySideb
import { useNodeLibrarySidebarTab } from '@/hooks/sidebarTabs/nodeLibrarySidebarTab'
import { useQueueSidebarTab } from '@/hooks/sidebarTabs/queueSidebarTab'
import { useWorkflowsSidebarTab } from '@/hooks/sidebarTabs/workflowsSidebarTab'
import { useDocumentationSidebarTab } from '@/hooks/sidebarTabs/documentationSidebarTab'
import { SidebarTabExtension } from '@/types/extensionTypes'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
@@ -57,6 +58,7 @@ export const useSidebarTabStore = defineStore('sidebarTab', () => {
registerSidebarTab(useNodeLibrarySidebarTab())
registerSidebarTab(useModelLibrarySidebarTab())
registerSidebarTab(useWorkflowsSidebarTab())
registerSidebarTab(useDocumentationSidebarTab())
}
return {

View File

@@ -356,6 +356,11 @@ const zComfyComboOutput = z.array(z.any())
const zComfyOutputTypesSpec = z.array(
z.union([zComfyNodeDataType, zComfyComboOutput])
)
const zDescriptionSpec = z.union([
z.string(),
z.tuple([z.string(), z.string()]),
z.tuple([z.string(), z.string(), z.record(z.string(), z.any())])
])
const zComfyNodeDef = z.object({
input: zComfyInputsSpec.optional(),
@@ -365,7 +370,7 @@ const zComfyNodeDef = z.object({
output_tooltips: z.array(z.string()).optional(),
name: z.string(),
display_name: z.string(),
description: z.string(),
description: zDescriptionSpec,
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
@@ -378,6 +383,7 @@ export type InputSpec = z.infer<typeof zInputSpec>
export type ComfyInputsSpec = z.infer<typeof zComfyInputsSpec>
export type ComfyOutputTypesSpec = z.infer<typeof zComfyOutputTypesSpec>
export type ComfyNodeDef = z.infer<typeof zComfyNodeDef>
export type DescriptionSpec = z.infer<typeof zDescriptionSpec>
export function validateComfyNodeDef(
data: any,
@@ -454,11 +460,6 @@ const zNodeBadgeMode = z.enum(
Object.values(NodeBadgeMode) as [string, ...string[]]
)
const zQueueFilter = z.object({
hideCached: z.boolean(),
hideCanceled: z.boolean()
})
const zSettings = z.record(z.any()).and(
z
.object({
@@ -511,8 +512,6 @@ const zSettings = z.record(z.any()).and(
'Comfy.Validation.Workflows': z.boolean(),
'Comfy.Workflow.SortNodeIdOnSave': z.boolean(),
'Comfy.Queue.ImageFit': z.enum(['contain', 'cover']),
'Comfy.Queue.ShowFlatList': z.boolean(),
'Comfy.Queue.Filter': zQueueFilter.passthrough(),
'Comfy.Workflow.WorkflowTabsPosition': z.enum(['Sidebar', 'Topbar']),
'Comfy.Node.DoubleClickTitleToEdit': z.boolean(),
'Comfy.Window.UnloadConfirmation': z.boolean(),

View File

@@ -1,11 +1,12 @@
import type { LGraphNode } from './litegraph'
import type { ComfyApp } from '../scripts/app'
import type { ComfyNodeDef } from '@/types/apiTypes'
import type { LGraphNode, IWidget } from '@comfyorg/litegraph'
import type { ComfyApp } from '@/scripts/app'
import type { ComfyNodeDef, DescriptionSpec } from '@/types/apiTypes'
import type { Keybinding } from '@/types/keyBindingTypes'
import type { ComfyCommand } from '@/stores/commandStore'
import type { SettingParams } from './settingTypes'
import type { BottomPanelExtension } from './extensionTypes'
import type { SettingParams } from '@/types/settingTypes'
import type { BottomPanelExtension } from '@/types/extensionTypes'
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
import type { ComfyWorkflowJSON } from '@/types/comfyWorkflow'
export type Widgets = Record<string, ComfyWidgetConstructor>
@@ -159,20 +160,8 @@ export interface ComfyExtension {
[key: string]: any
}
/**
* @deprecated Use ComfyNodeDef instead
*/
export type ComfyObjectInfo = {
name: string
display_name?: string
description?: string
category: string
input?: {
required?: Record<string, ComfyObjectInfoConfig>
optional?: Record<string, ComfyObjectInfoConfig>
}
output?: string[]
output_name: string[]
}
export type ComfyObjectInfoConfig = [string | any[]] | [string | any[], any]
export type ComfyNodeItem =
| { node: LGraphNode; type: 'Title' }
| { node: LGraphNode; type: 'Output'; outputSlot: number }
| { node: LGraphNode; type: 'Input'; inputName: string }
| { node: LGraphNode; type: 'Widget'; widget: IWidget }

View File

@@ -40,6 +40,7 @@ export enum CudaMalloc {
export enum FloatingPointPrecision {
AUTO = 'auto',
FP64 = 'fp64',
FP32 = 'fp32',
FP16 = 'fp16',
BF16 = 'bf16',

View File

@@ -58,3 +58,8 @@ export interface FormItem {
attrs?: Record<string, any>
options?: Array<string | SettingOption> | ((value: any) => SettingOption[])
}
export interface ISettingGroup {
label: string
settings: SettingParams[]
}

View File

@@ -19,7 +19,10 @@ import { useI18n } from 'vue-i18n'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { api } from '@/scripts/api'
import { StatusWsMessageStatus } from '@/types/apiTypes'
import { useQueuePendingTaskCountStore } from '@/stores/queueStore'
import {
useQueuePendingTaskCountStore,
useQueueStore
} from '@/stores/queueStore'
import type { ToastMessageOptions } from 'primevue/toast'
import { useToast } from 'primevue/usetoast'
import { i18n } from '@/i18n'
@@ -35,6 +38,8 @@ import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { useNodeDefStore, useNodeFrequencyStore } from '@/stores/nodeDefStore'
import { useBottomPanelStore } from '@/stores/workspace/bottomPanelStore'
import { useModelStore } from '@/stores/modelStore'
import { useServerConfigStore } from '@/stores/serverConfigStore'
import { SERVER_CONFIG_ITEMS } from '@/constants/serverConfig'
setupAutoQueueHandler()
@@ -92,6 +97,12 @@ watchEffect(() => {
}
})
watchEffect(() => {
useQueueStore().maxHistoryItems = settingStore.get(
'Comfy.Queue.MaxHistoryItems'
)
})
const init = () => {
settingStore.addSettings(app.ui.settings)
useKeybindingStore().loadCoreKeybindings()
@@ -151,6 +162,12 @@ const onGraphReady = () => {
// Load keybindings.
useKeybindingStore().loadUserKeybindings()
// Load server config
useServerConfigStore().loadServerConfig(
SERVER_CONFIG_ITEMS,
settingStore.get('Comfy.Server.ServerConfigValues')
)
// Load model folders
useModelStore().loadModelFolders()

View File

@@ -0,0 +1,62 @@
<template>
<div
class="font-sans w-screen h-screen flex items-center m-0 text-neutral-900 bg-neutral-300 pointer-events-auto"
>
<div class="flex-grow flex items-center justify-center">
<div class="flex flex-col gap-8 p-8">
<!-- Header -->
<h1 class="text-4xl font-bold text-red-500">
{{ $t('notSupported.title') }}
</h1>
<!-- Message -->
<div class="space-y-4">
<p class="text-xl">
{{ $t('notSupported.message') }}
</p>
<ul class="list-disc list-inside space-y-1 text-neutral-800">
<li>{{ $t('notSupported.supportedDevices.macos') }}</li>
<li>{{ $t('notSupported.supportedDevices.windows') }}</li>
</ul>
</div>
<!-- Actions -->
<div class="flex gap-4">
<Button
:label="$t('notSupported.learnMore')"
icon="pi pi-github"
@click="openDocs"
severity="secondary"
/>
<Button
:label="$t('notSupported.reportIssue')"
icon="pi pi-flag"
@click="reportIssue"
severity="danger"
/>
</div>
</div>
</div>
<!-- Right side image -->
<div class="h-screen flex-grow-0">
<img
src="/assets/images/sad_girl.png"
alt="Sad girl illustration"
class="h-full object-cover"
/>
</div>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
const openDocs = () => {
window.open('https://github.com/Comfy-Org/desktop', '_blank')
}
const reportIssue = () => {
window.open('https://forum.comfy.org/c/v1-feedback/', '_blank')
}
</script>

View File

@@ -2,9 +2,14 @@
<div
class="font-sans flex flex-col justify-center items-center h-screen m-0 text-neutral-300 bg-neutral-900 dark-theme pointer-events-auto"
>
<h2 class="text-2xl font-bold">{{ t(`serverStart.process.${status}`) }}</h2>
<h2 class="text-2xl font-bold">
{{ t(`serverStart.process.${status}`) }}
<span v-if="status === ProgressStatus.ERROR">
v{{ electronVersion }}
</span>
</h2>
<div
v-if="status == ProgressStatus.ERROR"
v-if="status === ProgressStatus.ERROR"
class="flex items-center my-4 gap-2"
>
<Button
@@ -43,6 +48,7 @@ const electron = electronAPI()
const { t } = useI18n()
const status = ref<ProgressStatus>(ProgressStatus.INITIAL_STATE)
const electronVersion = ref<string>('')
let xterm: Terminal | undefined
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
@@ -72,9 +78,10 @@ const reportIssue = () => {
}
const openLogs = () => electron.openLogsFolder()
onMounted(() => {
onMounted(async () => {
electron.sendReady()
electron.onProgressUpdate(updateProgress)
electronVersion.value = await electron.getElectronVersion()
})
</script>

View File

@@ -177,7 +177,8 @@ export default {
},
textColor: {
muted: 'var(--p-text-muted-color)'
muted: 'var(--p-text-muted-color)',
highlight: 'var(--p-primary-color)'
}
}
},

View File

@@ -170,20 +170,116 @@ describe('useServerConfigStore', () => {
const configs: ServerConfig<any>[] = [
{ ...dummyFormItem, id: 'test.config1', defaultValue: 'default1' },
{ ...dummyFormItem, id: 'test.config2', defaultValue: 'default2' },
{ ...dummyFormItem, id: 'test.config3', defaultValue: 'default3' }
{ ...dummyFormItem, id: 'test.config3', defaultValue: 'default3' },
{ ...dummyFormItem, id: 'test.config4', defaultValue: null }
]
store.loadServerConfig(configs, {
'test.config1': undefined,
'test.config2': null,
'test.config3': ''
'test.config3': '',
'test.config4': 0
})
expect(Object.keys(store.launchArgs)).toHaveLength(0)
expect(Object.keys(store.serverConfigValues)).toEqual([
'test.config1',
'test.config2',
'test.config3'
expect(Object.keys(store.launchArgs)).toEqual([
'test.config3',
'test.config4'
])
expect(Object.values(store.launchArgs)).toEqual(['', '0'])
expect(store.serverConfigById['test.config3'].value).toBe('')
expect(store.serverConfigById['test.config4'].value).toBe(0)
expect(Object.values(store.serverConfigValues)).toEqual([
undefined,
undefined,
'',
0
])
})
it('should convert true to empty string in launch arguments', () => {
store.loadServerConfig(
[
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 0
}
],
{
'test.config1': true
}
)
expect(store.launchArgs['test.config1']).toBe('')
expect(store.commandLineArgs).toBe('--test.config1')
})
it('should convert number to string in launch arguments', () => {
store.loadServerConfig(
[
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 1
}
],
{
'test.config1': 123
}
)
expect(store.launchArgs['test.config1']).toBe('123')
expect(store.commandLineArgs).toBe('--test.config1 123')
})
it('should drop nullish values in launch arguments', () => {
store.loadServerConfig(
[
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 1
}
],
{
'test.config1': null
}
)
expect(Object.keys(store.launchArgs)).toHaveLength(0)
})
it('should track modified configs', () => {
const configs = [
{
...dummyFormItem,
id: 'test.config1',
defaultValue: 'default1'
},
{
...dummyFormItem,
id: 'test.config2',
defaultValue: 'default2'
}
]
store.loadServerConfig(configs, {
'test.config1': 'initial1'
})
// Initially no modified configs
expect(store.modifiedConfigs).toHaveLength(0)
// Modify config1's value after loading
store.serverConfigById['test.config1'].value = 'custom1'
// Now config1 should be in modified configs
expect(store.modifiedConfigs).toHaveLength(1)
expect(store.modifiedConfigs[0].id).toBe('test.config1')
expect(store.modifiedConfigs[0].value).toBe('custom1')
expect(store.modifiedConfigs[0].initialValue).toBe('initial1')
// Change config1 back to default
store.serverConfigById['test.config1'].value = 'initial1'
// Should go back to no modified configs
expect(store.modifiedConfigs).toHaveLength(0)
})
})

View File

@@ -73,7 +73,6 @@ export async function checkBeforeAndAfterReload(graph, cb) {
* @param { string } name
* @param { Record<string, string | [string | string[], any]> } input
* @param { (string | string[])[] | Record<string, string | string[]> } output
* @returns { Record<string, import("../../src/types/comfy").ComfyObjectInfo> }
*/
export function makeNodeDef(name, input, output = {}) {
const nodeDef = {

View File

@@ -29,14 +29,10 @@ export interface APIConfig {
userData?: Record<string, any>
}
/**
* @typedef { import("../../src/types/comfy").ComfyObjectInfo } ComfyObjectInfo
*/
/**
* @param {{
* mockExtensions?: string[],
* mockNodeDefs?: Record<string, ComfyObjectInfo>,
* mockNodeDefs?: Record<string, any>,
* settings?: Record<string, string>
* userConfig?: {storage: "server" | "browser", users?: Record<string, any>, migrated?: boolean },
* userData?: Record<string, any>

View File

@@ -10,6 +10,9 @@ const mockElectronAPI: Plugin = {
{
tag: 'script',
children: `window.electronAPI = {
restartApp: () => {
alert('restartApp')
},
sendReady: () => {},
onShowSelectDirectory: () => {},
onFirstTimeSetupComplete: () => {},