mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-15 19:54:32 +00:00
Compare commits
1 Commits
update-fix
...
branding_m
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
47e3330baa |
443
README.md
443
README.md
@@ -67,449 +67,6 @@ The development of successive minor versions overlaps. For example, while versio
|
||||
| 3 | Mar 15-21 | Released | Feature Freeze | Development | 1.1.7 through 1.1.13 (daily)<br>1.2.0 through 1.2.6 (daily) |
|
||||
| 4 | Mar 22-28 | - | Released | Feature Freeze | 1.2.7 through 1.2.13 (daily)<br>1.3.0 through 1.3.6 (daily) |
|
||||
|
||||
## Release Summary
|
||||
|
||||
### Major features
|
||||
|
||||
<details id='feature-native-translation'>
|
||||
<summary>v1.5: Native translation (i18n)</summary>
|
||||
|
||||
ComfyUI now includes built-in translation support, replacing the need for third-party translation extensions. Select your language
|
||||
in `Comfy > Locale > Language` to translate the interface into English, Chinese (Simplified), Russian, Japanese, or Korean. This native
|
||||
implementation offers better performance, reliability, and maintainability compared to previous solutions.<br>
|
||||
|
||||
More details available here: https://blog.comfy.org/p/native-localization-support-i18n
|
||||
</details>
|
||||
|
||||
<details id='feature-mask-editor'>
|
||||
<summary>v1.4: New mask editor</summary>
|
||||
|
||||
https://github.com/Comfy-Org/ComfyUI_frontend/pull/1284 implements a new mask editor.
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details id='feature-integrated-server-terminal'>
|
||||
<summary>v1.3.22: Integrated server terminal</summary>
|
||||
|
||||
Press Ctrl + ` to toggle integrated terminal.
|
||||
|
||||
https://github.com/user-attachments/assets/eddedc6a-07a3-4a83-9475-63b3977f6d94
|
||||
</details>
|
||||
|
||||
<details id='feature-keybinding-customization'>
|
||||
<summary>v1.3.7: Keybinding customization</summary>
|
||||
|
||||
## Basic UI
|
||||

|
||||
|
||||
## Reset button
|
||||

|
||||
|
||||
## Edit Keybinding
|
||||

|
||||

|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/a3984ed9-eb28-4d47-86c0-7fc3efc2b5d0)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-node-library-sidebar'>
|
||||
<summary>v1.2.4: Node library sidebar tab</summary>
|
||||
|
||||
#### Drag & Drop
|
||||
https://github.com/user-attachments/assets/853e20b7-bc0e-49c9-bbce-a2ba7566f92f
|
||||
|
||||
#### Filter
|
||||
https://github.com/user-attachments/assets/4bbca3ee-318f-4cf0-be32-a5a5541066cf
|
||||
</details>
|
||||
|
||||
<details id='feature-queue-sidebar'>
|
||||
<summary>v1.2.0: Queue/History sidebar tab</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/86e264fe-4d26-4f07-aa9a-83bdd2d02b8f
|
||||
</details>
|
||||
|
||||
<details id='feature-node-search'>
|
||||
<summary>v1.1.0: Node search box</summary>
|
||||
|
||||
#### Fuzzy search & Node preview
|
||||

|
||||
|
||||
#### Release link with shift
|
||||
https://github.com/user-attachments/assets/a1b2b5c3-10d1-4256-b620-345de6858f25
|
||||
</details>
|
||||
|
||||
### QoL changes
|
||||
|
||||
<details id='feature-nested-group'>
|
||||
<summary>v1.3.32: **Litegraph** Nested group</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/f51adeb1-028e-40af-81e4-0ac13075198a
|
||||
</details>
|
||||
|
||||
<details id='feature-group-selection'>
|
||||
<summary>v1.3.24: **Litegraph** Group selection</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/e6230a94-411e-4fba-90cb-6c694200adaa
|
||||
</details>
|
||||
|
||||
<details id='feature-toggle-link-visibility'>
|
||||
<summary>v1.3.6: **Litegraph** Toggle link visibility</summary>
|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/34e460ac-fbbc-44ef-bfbb-99a84c2ae2be)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-auto-widget-conversion'>
|
||||
<summary>v1.3.4: **Litegraph** Auto widget to input conversion</summary>
|
||||
|
||||
Dropping a link of correct type on node widget will automatically convert the widget to input.
|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/15cea0b0-b225-4bec-af50-2cdb16dc46bf)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-pan-mode'>
|
||||
<summary>v1.3.4: **Litegraph** Canvas pan mode</summary>
|
||||
|
||||
The canvas becomes readonly in pan mode. Pan mode is activated by clicking the pan mode button on the canvas menu
|
||||
or by holding the space key.
|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/c7872532-a2ac-44c1-9e7d-9e03b5d1a80b)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-shift-drag-link-creation'>
|
||||
<summary>v1.3.1: **Litegraph** Shift drag link to create a new link</summary>
|
||||
|
||||
[rec.webm](https://github.com/user-attachments/assets/7e73aaf9-79e2-4c3c-a26a-911cba3b85e4)
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-optional-input-donuts'>
|
||||
<summary>v1.2.62: **Litegraph** Show optional input slots as donuts</summary>
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-group-title-edit'>
|
||||
<summary>v1.2.44: **Litegraph** Double click group title to edit</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/5bf0e2b6-8b3a-40a7-b44f-f0879e9ad26f
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-group-selection-shortcut'>
|
||||
<summary>v1.2.39: **Litegraph** Group selected nodes with Ctrl + G</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/7805dc54-0854-4a28-8bcd-4b007fa01151
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-node-title-edit'>
|
||||
<summary>v1.2.38: **Litegraph** Double click node title to edit</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/d61d5d0e-f200-4153-b293-3e3f6a212b30
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-drag-multi-link'>
|
||||
<summary>v1.2.7: **Litegraph** drags multiple links with shift pressed</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/68826715-bb55-4b2a-be6e-675cfc424afe
|
||||
|
||||
https://github.com/user-attachments/assets/c142c43f-2fe9-4030-8196-b3bfd4c6977d
|
||||
|
||||
</details>
|
||||
|
||||
<details id='feature-auto-connect-link'>
|
||||
<summary>v1.2.2: **Litegraph** auto connects to correct slot</summary>
|
||||
|
||||
#### Before
|
||||
https://github.com/user-attachments/assets/c253f778-82d5-4e6f-aec0-ea2ccf421651
|
||||
|
||||
#### After
|
||||
https://github.com/user-attachments/assets/b6360ac0-f0d2-447c-9daa-8a2e20c0dc1d
|
||||
</details>
|
||||
|
||||
<details id='feature-hide-text-overflow'>
|
||||
<summary>v1.1.8: **Litegraph** hides text overflow on widget value</summary>
|
||||
|
||||
https://github.com/user-attachments/assets/5696a89d-4a47-4fcc-9e8c-71e1264943f2
|
||||
</details>
|
||||
|
||||
### Developer APIs
|
||||
|
||||
<details>
|
||||
<summary>v1.6.13: prompt/confirm/alert replacements for ComfyUI desktop</summary>
|
||||
|
||||
Several browser-only APIs are not available in ComfyUI desktop's electron environment.
|
||||
|
||||
- `window.prompt`
|
||||
- `window.confirm`
|
||||
- `window.alert`
|
||||
|
||||
Please use the following APIs as replacements.
|
||||
|
||||
```js
|
||||
// window.prompt
|
||||
window['app'].extensionManager.dialog
|
||||
.prompt({
|
||||
title: 'Test Prompt',
|
||||
message: 'Test Prompt Message'
|
||||
})
|
||||
.then((value: string) => {
|
||||
// Do something with the value user entered
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
|
||||
```js
|
||||
// window.confirm
|
||||
window['app'].extensionManager.dialog
|
||||
.confirm({
|
||||
title: 'Test Confirm',
|
||||
message: 'Test Confirm Message'
|
||||
})
|
||||
.then((value: boolean) => {
|
||||
// Do something with the value user entered
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
|
||||
```js
|
||||
// window.alert
|
||||
window['app'].extensionManager.toast
|
||||
.addAlert("Test Alert")
|
||||
```
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>v1.3.34: Register about panel badges</summary>
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
aboutPageBadges: [
|
||||
{
|
||||
label: 'Test Badge',
|
||||
url: 'https://example.com',
|
||||
icon: 'pi pi-box'
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-bottom-panel-tabs'>
|
||||
<summary>v1.3.22: Register bottom panel tabs</summary>
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension',
|
||||
bottomPanelTabs: [
|
||||
{
|
||||
id: 'TestTab',
|
||||
title: 'Test Tab',
|
||||
type: 'custom',
|
||||
render: (el) => {
|
||||
el.innerHTML = '<div>Custom tab</div>'
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-settings'>
|
||||
<summary>v1.3.22: New settings API</summary>
|
||||
|
||||
Legacy settings API.
|
||||
|
||||
```js
|
||||
// Register a new setting
|
||||
app.ui.settings.addSetting({
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'Hello, world!'
|
||||
})
|
||||
|
||||
// Get the value of a setting
|
||||
const value = app.ui.settings.getSettingValue('TestSetting')
|
||||
|
||||
// Set the value of a setting
|
||||
app.ui.settings.setSettingValue('TestSetting', 'Hello, universe!')
|
||||
```
|
||||
|
||||
New settings API.
|
||||
|
||||
```js
|
||||
// Register a new setting
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
settings: [
|
||||
{
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'text',
|
||||
defaultValue: 'Hello, world!'
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
// Get the value of a setting
|
||||
const value = app.extensionManager.setting.get('TestSetting')
|
||||
|
||||
// Set the value of a setting
|
||||
app.extensionManager.setting.set('TestSetting', 'Hello, universe!')
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-commands-keybindings'>
|
||||
<summary>v1.3.7: Register commands and keybindings</summary>
|
||||
|
||||
Extensions can call the following API to register commands and keybindings. Do
|
||||
note that keybindings defined in core cannot be overwritten, and some keybindings
|
||||
are reserved by the browser.
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
commands: [
|
||||
{
|
||||
id: 'TestCommand',
|
||||
function: () => {
|
||||
alert('TestCommand')
|
||||
}
|
||||
}
|
||||
],
|
||||
keybindings: [
|
||||
{
|
||||
combo: { key: 'k' },
|
||||
commandId: 'TestCommand'
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
<details id='extension-api-topbar-menu'>
|
||||
<summary>v1.3.1: Extension API to register custom topbar menu items</summary>
|
||||
|
||||
Extensions can call the following API to register custom topbar menu items.
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
commands: [
|
||||
{
|
||||
id: 'foo-id',
|
||||
label: 'foo',
|
||||
function: () => {
|
||||
alert(1)
|
||||
}
|
||||
}
|
||||
],
|
||||
menuCommands: [
|
||||
{
|
||||
path: ['ext', 'ext2'],
|
||||
commands: ['foo-id']
|
||||
}
|
||||
]
|
||||
})
|
||||
```
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details id='extension-api-toast'>
|
||||
<summary>v1.2.27: Extension API to add toast message</summary>i
|
||||
|
||||
Extensions can call the following API to add toast messages.
|
||||
|
||||
```js
|
||||
app.extensionManager.toast.add({
|
||||
severity: 'info',
|
||||
summary: 'Loaded!',
|
||||
detail: 'Extension loaded!',
|
||||
life: 3000
|
||||
})
|
||||
```
|
||||
Documentation of all supported options can be found here: <https://primevue.org/toast/#api.toast.interfaces.ToastMessageOptions>
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details id='extension-api-sidebar-tab'>
|
||||
<summary>v1.2.4: Extension API to register custom sidebar tab</summary>
|
||||
|
||||
Extensions now can call the following API to register a sidebar tab.
|
||||
|
||||
```js
|
||||
app.extensionManager.registerSidebarTab({
|
||||
id: "search",
|
||||
icon: "pi pi-search",
|
||||
title: "search",
|
||||
tooltip: "search",
|
||||
type: "custom",
|
||||
render: (el) => {
|
||||
el.innerHTML = "<div>Custom search tab</div>";
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
The list of supported icons can be found here: <https://primevue.org/icons/#list>
|
||||
|
||||
We will support custom icons later.
|
||||
|
||||

|
||||
</details>
|
||||
|
||||
<details id='extension-api-selection-toolbox'>
|
||||
<summary>v1.10.9: Selection Toolbox API</summary>
|
||||
|
||||
Extensions can register commands that appear in the selection toolbox when specific items are selected on the canvas.
|
||||
|
||||
```js
|
||||
app.registerExtension({
|
||||
name: 'TestExtension1',
|
||||
commands: [
|
||||
{
|
||||
id: 'test.selection.command',
|
||||
label: 'Test Command',
|
||||
icon: 'pi pi-star',
|
||||
function: () => {
|
||||
// Command logic here
|
||||
}
|
||||
}
|
||||
],
|
||||
// Return an array of command IDs to show in the selection toolbox
|
||||
// when an item is selected
|
||||
getSelectionToolboxCommands: (selectedItem) => ['test.selection.command']
|
||||
})
|
||||
```
|
||||
|
||||
The selection toolbox will display the command button when items are selected:
|
||||

|
||||
|
||||
</details>
|
||||
|
||||
## Contributing
|
||||
|
||||
We're building this frontend together and would love your help — no matter how you'd like to pitch in! You don't need to write code to make a difference.
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg width="520" height="520" viewBox="0 0 520 520" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_227_285" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="520" height="520">
|
||||
<path d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z" fill="#FFFFFF"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<rect y="0.751831" width="520" height="520" fill="#000000"/>
|
||||
<path d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z" fill="#FFFFFF"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 2.0 KiB |
@@ -27,6 +27,9 @@
|
||||
--content-fg: #000;
|
||||
--content-hover-bg: #adadad;
|
||||
--content-hover-fg: #000;
|
||||
/* Colors for Comfy branding. */
|
||||
--comfy-yellow: #f0ff41;
|
||||
--comfy-blue: #172dd7;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
|
||||
49
src/components/topbar/ComfyLogo.vue
Normal file
49
src/components/topbar/ComfyLogo.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<svg
|
||||
width="520"
|
||||
height="520"
|
||||
viewBox="0 0 520 520"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<mask
|
||||
id="mask0_227_285"
|
||||
style="mask-type: alpha"
|
||||
maskUnits="userSpaceOnUse"
|
||||
x="0"
|
||||
y="0"
|
||||
width="520"
|
||||
height="520"
|
||||
>
|
||||
<path
|
||||
d="M0 184.335C0 119.812 0 87.5502 12.5571 62.9055C23.6026 41.2274 41.2274 23.6026 62.9055 12.5571C87.5502 0 119.812 0 184.335 0H335.665C400.188 0 432.45 0 457.094 12.5571C478.773 23.6026 496.397 41.2274 507.443 62.9055C520 87.5502 520 119.812 520 184.335V335.665C520 400.188 520 432.45 507.443 457.094C496.397 478.773 478.773 496.397 457.094 507.443C432.45 520 400.188 520 335.665 520H184.335C119.812 520 87.5502 520 62.9055 507.443C41.2274 496.397 23.6026 478.773 12.5571 457.094C0 432.45 0 400.188 0 335.665V184.335Z"
|
||||
fill="#EEFF30"
|
||||
/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_227_285)">
|
||||
<rect y="0.751831" width="520" height="520" fill="#172DD7" />
|
||||
<path
|
||||
d="M176.484 428.831C168.649 428.831 162.327 425.919 158.204 420.412C153.966 414.755 152.861 406.857 155.171 398.749L164.447 366.178C165.187 363.585 164.672 360.794 163.059 358.636C161.446 356.483 158.921 355.216 156.241 355.216H129.571C121.731 355.216 115.409 352.308 111.289 346.802C107.051 341.14 105.946 333.242 108.258 325.134L140.124 213.748L143.642 201.51C148.371 184.904 165.62 171.407 182.097 171.407H214.009C217.817 171.407 221.167 168.868 222.215 165.183L232.769 128.135C237.494 111.545 254.742 98.048 271.219 98.048L339.468 97.9264L389.431 97.9221C397.268 97.9221 403.59 100.831 407.711 106.337C411.949 111.994 413.054 119.892 410.744 128L396.457 178.164C391.734 194.75 374.485 208.242 358.009 208.242L289.607 208.372H257.706C253.902 208.372 250.557 210.907 249.502 214.588L222.903 307.495C222.159 310.093 222.673 312.892 224.291 315.049C225.904 317.202 228.428 318.469 231.107 318.469C231.113 318.469 276.307 318.381 276.307 318.381H326.122C333.959 318.381 340.281 321.29 344.402 326.796C348.639 332.457 349.744 340.355 347.433 348.463L333.146 398.619C328.423 415.209 311.174 428.701 294.698 428.701L226.299 428.831H176.484Z"
|
||||
fill="#F0FF41"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
svg > g > rect {
|
||||
fill: black;
|
||||
}
|
||||
|
||||
.dark-theme svg > g > rect {
|
||||
fill: white;
|
||||
}
|
||||
|
||||
svg > g > path {
|
||||
fill: var(--comfy-yellow);
|
||||
}
|
||||
|
||||
.dark-theme svg > g > path {
|
||||
fill: var(--comfy-blue);
|
||||
}
|
||||
</style>
|
||||
@@ -5,11 +5,7 @@
|
||||
class="comfyui-menu flex items-center"
|
||||
:class="{ dropzone: isDropZone, 'dropzone-active': isDroppable }"
|
||||
>
|
||||
<img
|
||||
src="/assets/images/comfy-logo-mono.svg"
|
||||
alt="ComfyUI Logo"
|
||||
class="comfyui-logo ml-2 app-drag h-6"
|
||||
/>
|
||||
<ComfyLogo class="user-select-none cursor-default ml-2 app-drag h-6 w-6" />
|
||||
<CommandMenubar />
|
||||
<div class="flex-grow min-w-0 app-drag h-full">
|
||||
<WorkflowTabs v-if="workflowTabsPosition === 'Topbar'" />
|
||||
@@ -48,6 +44,7 @@ import { computed, onMounted, provide, ref } from 'vue'
|
||||
|
||||
import Actionbar from '@/components/actionbar/ComfyActionbar.vue'
|
||||
import BottomPanelToggleButton from '@/components/topbar/BottomPanelToggleButton.vue'
|
||||
import ComfyLogo from '@/components/topbar/ComfyLogo.vue'
|
||||
import CommandMenubar from '@/components/topbar/CommandMenubar.vue'
|
||||
import CurrentUserButton from '@/components/topbar/CurrentUserButton.vue'
|
||||
import WorkflowTabs from '@/components/topbar/WorkflowTabs.vue'
|
||||
@@ -128,14 +125,4 @@ onMounted(() => {
|
||||
:deep(.p-menubar-item-label) {
|
||||
line-height: revert;
|
||||
}
|
||||
|
||||
.comfyui-logo {
|
||||
user-select: none;
|
||||
cursor: default;
|
||||
filter: invert(0);
|
||||
}
|
||||
|
||||
.dark-theme .comfyui-logo {
|
||||
filter: invert(1);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { ISerialisedGraph } from '@comfyorg/litegraph/dist/types/serialisat
|
||||
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
|
||||
import { validateComfyWorkflow } from '@/schemas/comfyWorkflowSchema'
|
||||
import { useToastStore } from '@/stores/toastStore'
|
||||
import { WorkflowLinkFixer } from '@/utils/linkFixer'
|
||||
import { fixBadLinks } from '@/utils/linkFixer'
|
||||
|
||||
export interface ValidationResult {
|
||||
graphData: ComfyWorkflowJSON | null
|
||||
@@ -16,49 +16,6 @@ export interface ValidationResult {
|
||||
export function useWorkflowValidation() {
|
||||
const toastStore = useToastStore()
|
||||
|
||||
function tryFixLinks(
|
||||
graphData: ComfyWorkflowJSON,
|
||||
options: { silent?: boolean } = {}
|
||||
): ComfyWorkflowJSON | null {
|
||||
const { silent = false } = options
|
||||
// Collect all logs in an array
|
||||
const logs: string[] = []
|
||||
// Then validate and fix links if schema validation passed
|
||||
const fixer = WorkflowLinkFixer.create(
|
||||
graphData as unknown as ISerialisedGraph
|
||||
)
|
||||
fixer.logger = {
|
||||
log: (...args: any[]) => {
|
||||
logs.push(...args)
|
||||
}
|
||||
}
|
||||
const checkBadLinksResult = fixer.check()
|
||||
// Graph has no bad links, so we can return it as is
|
||||
if (!checkBadLinksResult.hasBadLinks) {
|
||||
return graphData
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: 'Workflow Validation',
|
||||
detail: logs.join('\n')
|
||||
})
|
||||
}
|
||||
|
||||
const fixBadLinksResult = fixer.fix()
|
||||
if (!fixBadLinksResult.hasBadLinks) {
|
||||
if (!silent) {
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: 'Workflow Links Fixed'
|
||||
})
|
||||
}
|
||||
return fixBadLinksResult.graph as unknown as ComfyWorkflowJSON
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates a workflow, including link validation and schema validation
|
||||
*/
|
||||
@@ -70,6 +27,7 @@ export function useWorkflowValidation() {
|
||||
): Promise<ValidationResult> {
|
||||
const { silent = false } = options
|
||||
|
||||
let linksFixes
|
||||
let validatedData: ComfyWorkflowJSON | null = null
|
||||
|
||||
// First do schema validation
|
||||
@@ -83,16 +41,51 @@ export function useWorkflowValidation() {
|
||||
)
|
||||
|
||||
if (validatedGraphData) {
|
||||
try {
|
||||
validatedData = tryFixLinks(validatedGraphData, { silent })
|
||||
} catch (err) {
|
||||
// Link fixer itself is throwing an error. Log it and return the original graph
|
||||
console.error(err)
|
||||
// Collect all logs in an array
|
||||
const logs: string[] = []
|
||||
// Then validate and fix links if schema validation passed
|
||||
const linkValidation = fixBadLinks(
|
||||
validatedGraphData as unknown as ISerialisedGraph,
|
||||
{
|
||||
fix: true,
|
||||
silent,
|
||||
logger: {
|
||||
log: (message: string) => {
|
||||
logs.push(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
if (!silent && logs.length > 0) {
|
||||
toastStore.add({
|
||||
severity: 'warn',
|
||||
summary: 'Workflow Validation',
|
||||
detail: logs.join('\n')
|
||||
})
|
||||
}
|
||||
|
||||
// If links were fixed, notify the user
|
||||
if (linkValidation.fixed) {
|
||||
if (!silent) {
|
||||
toastStore.add({
|
||||
severity: 'success',
|
||||
summary: 'Workflow Links Fixed',
|
||||
detail: `Fixed ${linkValidation.patched} node connections and removed ${linkValidation.deleted} invalid links.`
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
validatedData = linkValidation.graph as unknown as ComfyWorkflowJSON
|
||||
linksFixes = {
|
||||
patched: linkValidation.patched,
|
||||
deleted: linkValidation.deleted
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
graphData: validatedData
|
||||
graphData: validatedData,
|
||||
linksFixes
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* This code is adapted from rgthree-comfy's link_fixer.ts
|
||||
* @see https://github.com/rgthree/rgthree-comfy/blob/main/src_web/common/link_fixer.ts
|
||||
* @see https://github.com/rgthree/rgthree-comfy/blob/b84f39c7c224de765de0b54c55b967329011819d/src_web/common/link_fixer.ts
|
||||
*
|
||||
* MIT License
|
||||
*
|
||||
@@ -24,31 +24,20 @@
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import type {
|
||||
INodeOutputSlot,
|
||||
ISlotType,
|
||||
LGraph,
|
||||
LGraphNode,
|
||||
LLink
|
||||
} from '@comfyorg/litegraph'
|
||||
import type { LGraph, LGraphNode, LLink } from '@comfyorg/litegraph'
|
||||
import type { NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
|
||||
import type {
|
||||
LinkId,
|
||||
SerialisedLLinkArray
|
||||
} from '@comfyorg/litegraph/dist/LLink'
|
||||
import type { SerialisedLLinkArray } from '@comfyorg/litegraph/dist/LLink'
|
||||
import type {
|
||||
ISerialisedGraph,
|
||||
ISerialisedNode
|
||||
} from '@comfyorg/litegraph/dist/types/serialisation'
|
||||
|
||||
/**
|
||||
* The bad links data returned from either a fixer `check()`, or the results of a `fix()` call.
|
||||
*/
|
||||
export interface BadLinksData<T = ISerialisedGraph | LGraph> {
|
||||
hasBadLinks: boolean
|
||||
fixed: boolean
|
||||
graph: T
|
||||
patches: number
|
||||
deletes: number
|
||||
patched: number
|
||||
deleted: number
|
||||
}
|
||||
|
||||
enum IoDirection {
|
||||
@@ -56,468 +45,183 @@ enum IoDirection {
|
||||
OUTPUT
|
||||
}
|
||||
|
||||
/**
|
||||
* Data interface that mimics a nodes `inputs` and `outputs` holding the _to be_ mutated node data
|
||||
* during a check.
|
||||
*/
|
||||
interface PatchedNodeSlots {
|
||||
[nodeId: string]: {
|
||||
inputs?: { [slot: number]: number | null }
|
||||
outputs?: {
|
||||
[slots: number]: {
|
||||
links: number[]
|
||||
changes: { [linkId: number]: 'ADD' | 'REMOVE' }
|
||||
}
|
||||
}
|
||||
function getNodeById(graph: ISerialisedGraph | LGraph, id: NodeId) {
|
||||
if ((graph as LGraph).getNodeById) {
|
||||
return (graph as LGraph).getNodeById(id)
|
||||
}
|
||||
graph = graph as ISerialisedGraph
|
||||
return graph.nodes.find((node: ISerialisedNode) => node.id == id)!
|
||||
}
|
||||
|
||||
/**
|
||||
* Link data derived from either a ISerialisedGraph or LGraph `links` property.
|
||||
*/
|
||||
interface LinkData {
|
||||
id: LinkId
|
||||
origin_id: NodeId
|
||||
origin_slot: number
|
||||
target_id: NodeId
|
||||
target_slot: number
|
||||
type: ISlotType
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of links data for the given links type; either from an LGraph or SerializedGraph.
|
||||
*/
|
||||
function getLinksData(
|
||||
links: ISerialisedGraph['links'] | LGraph['links'] | { [key: string]: LLink }
|
||||
): LinkData[] {
|
||||
if (links instanceof Map) {
|
||||
const data: LinkData[] = []
|
||||
for (const llink of links.values()) {
|
||||
if (!llink) continue
|
||||
data.push(llink)
|
||||
}
|
||||
return data
|
||||
}
|
||||
// This is apparently marked deprecated in ComfyUI but who knows if we would get stale data in
|
||||
// here that's not a map (handled above). Go ahead and handle it anyway.
|
||||
if (!Array.isArray(links)) {
|
||||
const data: LinkData[] = []
|
||||
for (const key in links) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
const llink = (links.hasOwnProperty(key) && links[key]) || null
|
||||
if (!llink) continue
|
||||
data.push(llink)
|
||||
}
|
||||
return data
|
||||
}
|
||||
return links.map((link: SerialisedLLinkArray) => ({
|
||||
function extendLink(link: SerialisedLLinkArray) {
|
||||
return {
|
||||
link: link,
|
||||
id: link[0],
|
||||
origin_id: link[1],
|
||||
origin_slot: link[2],
|
||||
target_id: link[3],
|
||||
target_slot: link[4],
|
||||
type: link[5]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
/** The instruction data for fixing a node's inputs or outputs. */
|
||||
interface WorkflowLinkFixerNodeInstruction {
|
||||
node: ISerialisedNode | LGraphNode
|
||||
op: 'REMOVE' | 'ADD'
|
||||
dir: IoDirection
|
||||
slot: number
|
||||
linkId: number
|
||||
linkIdToUse: number | null
|
||||
}
|
||||
|
||||
/** The instruction data for fixing a link from a workflow links. */
|
||||
interface WorkflowLinkFixerLinksInstruction {
|
||||
op: 'DELETE'
|
||||
linkId: number
|
||||
reason: string
|
||||
}
|
||||
|
||||
type WorkflowLinkFixerInstruction =
|
||||
| WorkflowLinkFixerNodeInstruction
|
||||
| WorkflowLinkFixerLinksInstruction
|
||||
|
||||
/**
|
||||
* The WorkflowLinkFixer for either ISerialisedGraph or a live LGraph.
|
||||
* Takes a ISerialisedGraph or live LGraph and inspects the links and nodes to ensure the linking
|
||||
* makes logical sense. Can apply fixes when passed the `fix` argument as true.
|
||||
*
|
||||
* Use `WorkflowLinkFixer.create(graph: ISerialisedGraph | LGraph)` to create a new instance.
|
||||
* Note that fixes are a best-effort attempt. Seems to get it correct in most cases, but there is a
|
||||
* chance it correct an anomoly that results in placing an incorrect link (say, if there were two
|
||||
* links in the data). Users should take care to not overwrite work until manually checking the
|
||||
* result.
|
||||
*/
|
||||
export abstract class WorkflowLinkFixer<
|
||||
G extends ISerialisedGraph | LGraph,
|
||||
N extends ISerialisedNode | LGraphNode
|
||||
> {
|
||||
silent: boolean = false
|
||||
checkedData: BadLinksData<G> | null = null
|
||||
logger: { log: (...args: any[]) => void } = console
|
||||
|
||||
protected graph: G
|
||||
protected patchedNodeSlots: PatchedNodeSlots = {}
|
||||
protected instructions: WorkflowLinkFixerInstruction[] = []
|
||||
|
||||
/**
|
||||
* Creates the WorkflowLinkFixer for the given graph type.
|
||||
*/
|
||||
static create(graph: ISerialisedGraph): WorkflowLinkFixerSerialized
|
||||
static create(graph: LGraph): WorkflowLinkFixerGraph
|
||||
static create(
|
||||
graph: ISerialisedGraph | LGraph
|
||||
): WorkflowLinkFixerSerialized | WorkflowLinkFixerGraph {
|
||||
if (typeof (graph as LGraph).getNodeById === 'function') {
|
||||
return new WorkflowLinkFixerGraph(graph as LGraph)
|
||||
}
|
||||
return new WorkflowLinkFixerSerialized(graph as ISerialisedGraph)
|
||||
}
|
||||
|
||||
protected constructor(graph: G) {
|
||||
this.graph = graph
|
||||
}
|
||||
|
||||
abstract getNodeById(id: NodeId): N | null
|
||||
abstract deleteGraphLink(id: LinkId): true | string
|
||||
|
||||
/**
|
||||
* Checks the current graph data for any bad links.
|
||||
*/
|
||||
check(force: boolean = false): BadLinksData<G> {
|
||||
if (this.checkedData && !force) {
|
||||
return { ...this.checkedData }
|
||||
}
|
||||
this.instructions = []
|
||||
this.patchedNodeSlots = {}
|
||||
|
||||
const instructions: (WorkflowLinkFixerInstruction | null)[] = []
|
||||
|
||||
const links: LinkData[] = getLinksData(this.graph.links)
|
||||
links.reverse()
|
||||
for (const link of links) {
|
||||
if (!link) continue
|
||||
|
||||
const originNode = this.getNodeById(link.origin_id)
|
||||
const originHasLink = () =>
|
||||
this.nodeHasLinkId(
|
||||
originNode!,
|
||||
IoDirection.OUTPUT,
|
||||
link.origin_slot,
|
||||
link.id
|
||||
)
|
||||
const patchOrigin = (op: 'ADD' | 'REMOVE', id = link.id) =>
|
||||
this.getNodePatchInstruction(
|
||||
originNode!,
|
||||
IoDirection.OUTPUT,
|
||||
link.origin_slot,
|
||||
id,
|
||||
op
|
||||
)
|
||||
|
||||
const targetNode = this.getNodeById(link.target_id)
|
||||
const targetHasLink = () =>
|
||||
this.nodeHasLinkId(
|
||||
targetNode!,
|
||||
IoDirection.INPUT,
|
||||
link.target_slot,
|
||||
link.id
|
||||
)
|
||||
const targetHasAnyLink = () =>
|
||||
this.nodeHasAnyLink(targetNode!, IoDirection.INPUT, link.target_slot)
|
||||
const patchTarget = (op: 'ADD' | 'REMOVE', id = link.id) =>
|
||||
this.getNodePatchInstruction(
|
||||
targetNode!,
|
||||
IoDirection.INPUT,
|
||||
link.target_slot,
|
||||
id,
|
||||
op
|
||||
)
|
||||
|
||||
const originLog = `origin(${link.origin_id}).outputs[${link.origin_slot}].links`
|
||||
const targetLog = `target(${link.target_id}).inputs[${link.target_slot}].link`
|
||||
|
||||
if (!originNode || !targetNode) {
|
||||
if (!originNode && !targetNode) {
|
||||
// This can fall through and continue; we remove it after this loop.
|
||||
} else if (!originNode && targetNode) {
|
||||
this.log(
|
||||
`Link ${link.id} is funky... ` +
|
||||
`origin ${link.origin_id} does not exist, but target ${link.target_id} does.`
|
||||
)
|
||||
if (targetHasLink()) {
|
||||
this.log(
|
||||
` > [PATCH] ${targetLog} does have link, will remove the inputs' link first.`
|
||||
)
|
||||
instructions.push(patchTarget('REMOVE', -1))
|
||||
}
|
||||
} else if (!targetNode && originNode) {
|
||||
this.log(
|
||||
`Link ${link.id} is funky... ` +
|
||||
`target ${link.target_id} does not exist, but origin ${link.origin_id} does.`
|
||||
)
|
||||
if (originHasLink()) {
|
||||
this.log(
|
||||
` > [PATCH] Origin's links' has ${link.id}; will remove the link first.`
|
||||
)
|
||||
instructions.push(patchOrigin('REMOVE'))
|
||||
}
|
||||
}
|
||||
continue
|
||||
export function fixBadLinks(
|
||||
graph: ISerialisedGraph | LGraph,
|
||||
options: {
|
||||
fix?: boolean
|
||||
silent?: boolean
|
||||
logger?: { log: (...args: any[]) => void }
|
||||
} = {}
|
||||
): BadLinksData {
|
||||
const { fix = false, silent = false, logger: _logger = console } = options
|
||||
const logger = {
|
||||
log: (...args: any[]) => {
|
||||
if (!silent) {
|
||||
_logger.log(...args)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (targetHasLink() || originHasLink()) {
|
||||
if (!originHasLink()) {
|
||||
this.log(
|
||||
`${link.id} is funky... ${originLog} does NOT contain it, but ${targetLog} does.`
|
||||
)
|
||||
this.log(
|
||||
` > [PATCH] Attempt a fix by adding this ${link.id} to ${originLog}.`
|
||||
)
|
||||
instructions.push(patchOrigin('ADD'))
|
||||
} else if (!targetHasLink()) {
|
||||
this.log(
|
||||
`${link.id} is funky... ${targetLog} is NOT correct (is ${
|
||||
targetNode.inputs?.[link.target_slot]?.link
|
||||
}), but ${originLog} contains it`
|
||||
)
|
||||
if (!targetHasAnyLink()) {
|
||||
this.log(
|
||||
` > [PATCH] ${targetLog} is not defined, will set to ${link.id}.`
|
||||
)
|
||||
let instruction = patchTarget('ADD')
|
||||
if (!instruction) {
|
||||
this.log(
|
||||
` > [PATCH] Nvm, ${targetLog} already patched. Removing ${link.id} from ${originLog}.`
|
||||
)
|
||||
instruction = patchOrigin('REMOVE')
|
||||
}
|
||||
instructions.push(instruction)
|
||||
} else {
|
||||
this.log(
|
||||
` > [PATCH] ${targetLog} is defined, removing ${link.id} from ${originLog}.`
|
||||
)
|
||||
instructions.push(patchOrigin('REMOVE'))
|
||||
}
|
||||
const patchedNodeSlots: {
|
||||
[nodeId: string]: {
|
||||
inputs?: { [slot: number]: number | null }
|
||||
outputs?: {
|
||||
[slots: number]: {
|
||||
links: number[]
|
||||
changes: { [linkId: number]: 'ADD' | 'REMOVE' }
|
||||
}
|
||||
}
|
||||
}
|
||||
} = {}
|
||||
|
||||
// Now that we've cleaned up the inputs, outputs, run through it looking for dangling links.,
|
||||
for (const link of links) {
|
||||
if (!link) continue
|
||||
const originNode = this.getNodeById(link.origin_id)
|
||||
const targetNode = this.getNodeById(link.target_id)
|
||||
if (!originNode && !targetNode) {
|
||||
instructions.push({
|
||||
op: 'DELETE',
|
||||
linkId: link.id,
|
||||
reason: `Both nodes #${link.origin_id} & #${link.target_id} are removed`
|
||||
})
|
||||
}
|
||||
// Now that we've manipulated the linking, check again if they both exist.
|
||||
if (
|
||||
(!originNode ||
|
||||
!this.nodeHasLinkId(
|
||||
originNode,
|
||||
IoDirection.OUTPUT,
|
||||
link.origin_slot,
|
||||
link.id
|
||||
)) &&
|
||||
(!targetNode ||
|
||||
!this.nodeHasLinkId(
|
||||
targetNode,
|
||||
IoDirection.INPUT,
|
||||
link.target_slot,
|
||||
link.id
|
||||
))
|
||||
) {
|
||||
instructions.push({
|
||||
op: 'DELETE',
|
||||
linkId: link.id,
|
||||
reason:
|
||||
`both origin node #${link.origin_id} ` +
|
||||
`${!originNode ? 'is removed' : `is missing link id output slot ${link.origin_slot}`}` +
|
||||
`and target node #${link.target_id} ` +
|
||||
`${!targetNode ? 'is removed' : `is missing link id input slot ${link.target_slot}`}.`
|
||||
})
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
this.instructions = instructions.filter((i) => !!i)
|
||||
this.checkedData = {
|
||||
hasBadLinks: !!this.instructions.length,
|
||||
graph: this.graph,
|
||||
patches: this.instructions.filter(
|
||||
(i) => !!(i as WorkflowLinkFixerNodeInstruction).node
|
||||
).length,
|
||||
deletes: this.instructions.filter((i) => i.op === 'DELETE').length
|
||||
}
|
||||
return { ...this.checkedData }
|
||||
const data: {
|
||||
patchedNodes: Array<ISerialisedNode | LGraphNode>
|
||||
deletedLinks: number[]
|
||||
} = {
|
||||
patchedNodes: [],
|
||||
deletedLinks: []
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixes a checked graph by running through the instructions generated during the check run. Also
|
||||
* double-checks for inconsistencies after the fix, recursively calling itself up to five times
|
||||
* before giving up.
|
||||
* Internal patch node. We keep track of changes in patchedNodeSlots in case we're in a dry run.
|
||||
*/
|
||||
fix(force: boolean = false, times?: number): BadLinksData<G> {
|
||||
if (!this.checkedData || force) {
|
||||
this.check(force)
|
||||
}
|
||||
let patches = 0
|
||||
let deletes = 0
|
||||
for (const instruction of this.instructions) {
|
||||
if ((instruction as WorkflowLinkFixerNodeInstruction).node) {
|
||||
const { node, slot, linkIdToUse, dir, op } =
|
||||
instruction as WorkflowLinkFixerNodeInstruction
|
||||
if (dir == IoDirection.INPUT) {
|
||||
node.inputs = node.inputs || []
|
||||
const oldValue = node.inputs[slot]?.link
|
||||
node.inputs[slot]!.link = linkIdToUse
|
||||
this.log(
|
||||
`Node #${node.id}: Set link ${linkIdToUse} to input slot ${slot} (was ${oldValue})`
|
||||
)
|
||||
} else if (op === 'ADD' && linkIdToUse != null) {
|
||||
node.outputs = node.outputs || []
|
||||
node.outputs[slot] = node.outputs[slot] || ({} as INodeOutputSlot)
|
||||
node.outputs[slot].links = node.outputs[slot].links || []
|
||||
node.outputs[slot].links.push(linkIdToUse)
|
||||
this.log(
|
||||
`Node #${node.id}: Add link ${linkIdToUse} to output slot #${slot}`
|
||||
)
|
||||
} else if (op === 'REMOVE' && linkIdToUse != null) {
|
||||
const linkIdIndex = node.outputs![slot]!.links!.indexOf(linkIdToUse)
|
||||
node.outputs![slot]!.links!.splice(linkIdIndex, 1)
|
||||
this.log(
|
||||
`Node #${node.id}: Remove link ${linkIdToUse} from output slot #${slot}`
|
||||
)
|
||||
} else {
|
||||
throw new Error('Unhandled Node Instruction')
|
||||
}
|
||||
patches++
|
||||
} else if (instruction.op === 'DELETE') {
|
||||
const wasDeleted = this.deleteGraphLink(instruction.linkId)
|
||||
if (wasDeleted === true) {
|
||||
this.log(
|
||||
`Link #${instruction.linkId}: Removed workflow link b/c ${instruction.reason}`
|
||||
)
|
||||
} else {
|
||||
this.log(`Error Link #${instruction.linkId}: ${wasDeleted}`)
|
||||
}
|
||||
deletes++
|
||||
} else {
|
||||
throw new Error('Unhandled Instruction')
|
||||
}
|
||||
}
|
||||
|
||||
const newCheck = this.check(force)
|
||||
times = times == null ? 5 : times
|
||||
let newFix = null
|
||||
// If we still have bad links, then recurse (up to five times).
|
||||
if (newCheck.hasBadLinks && times > 0) {
|
||||
newFix = this.fix(true, times - 1)
|
||||
}
|
||||
|
||||
return {
|
||||
hasBadLinks: newFix?.hasBadLinks ?? newCheck.hasBadLinks,
|
||||
graph: this.graph,
|
||||
patches: patches + (newFix?.patches ?? 0),
|
||||
deletes: deletes + (newFix?.deletes ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
/** Logs if not silent. */
|
||||
protected log(...args: any[]) {
|
||||
if (this.silent) return
|
||||
this.logger.log(...args)
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches a node for a check run, returning the instruction that would be made.
|
||||
*/
|
||||
private getNodePatchInstruction(
|
||||
node: N,
|
||||
function patchNodeSlot(
|
||||
node: ISerialisedNode | LGraphNode,
|
||||
ioDir: IoDirection,
|
||||
slot: number,
|
||||
linkId: number,
|
||||
op: 'ADD' | 'REMOVE'
|
||||
): WorkflowLinkFixerNodeInstruction | null {
|
||||
this.patchedNodeSlots[node.id] = this.patchedNodeSlots[node.id] || {}
|
||||
const patchedNode = this.patchedNodeSlots[node.id]!
|
||||
) {
|
||||
patchedNodeSlots[node.id] = patchedNodeSlots[node.id] || {}
|
||||
const patchedNode = patchedNodeSlots[node.id]!
|
||||
if (ioDir == IoDirection.INPUT) {
|
||||
patchedNode['inputs'] = patchedNode['inputs'] || {}
|
||||
// We can set to null (delete), so undefined means we haven't set it at all.
|
||||
if (patchedNode['inputs']![slot] !== undefined) {
|
||||
this.log(
|
||||
` > Already set ${node.id}.inputs[${slot}] to ${patchedNode['inputs']![slot]!} Skipping.`
|
||||
logger.log(
|
||||
` > Already set ${node.id}.inputs[${slot}] to ${patchedNode[
|
||||
'inputs'
|
||||
]![slot]!} Skipping.`
|
||||
)
|
||||
return null
|
||||
return false
|
||||
}
|
||||
const linkIdToUse = op === 'REMOVE' ? null : linkId
|
||||
patchedNode['inputs']![slot] = linkIdToUse
|
||||
return { node, dir: ioDir, op, slot, linkId, linkIdToUse }
|
||||
}
|
||||
|
||||
patchedNode['outputs'] = patchedNode['outputs'] || {}
|
||||
patchedNode['outputs']![slot] = patchedNode['outputs']![slot] || {
|
||||
links: [...(node.outputs?.[slot]?.links || [])],
|
||||
changes: {}
|
||||
}
|
||||
if (patchedNode['outputs']![slot]!['changes']![linkId] !== undefined) {
|
||||
this.log(
|
||||
` > Already set ${node.id}.outputs[${slot}] to ${patchedNode['inputs']![slot]}! Skipping.`
|
||||
)
|
||||
return null
|
||||
}
|
||||
patchedNode['outputs']![slot]!['changes']![linkId] = op
|
||||
if (op === 'ADD') {
|
||||
const linkIdIndex =
|
||||
patchedNode['outputs']![slot]!['links'].indexOf(linkId)
|
||||
if (linkIdIndex !== -1) {
|
||||
this.log(
|
||||
` > Hmmm.. asked to add ${linkId} but it is already in list...`
|
||||
const linkIdToSet = op === 'REMOVE' ? null : linkId
|
||||
patchedNode['inputs']![slot] = linkIdToSet
|
||||
if (fix) {
|
||||
// node.inputs[slot]!.link = linkIdToSet;
|
||||
}
|
||||
} else {
|
||||
patchedNode['outputs'] = patchedNode['outputs'] || {}
|
||||
patchedNode['outputs']![slot] = patchedNode['outputs']![slot] || {
|
||||
links: [...(node.outputs?.[slot]?.links || [])],
|
||||
changes: {}
|
||||
}
|
||||
if (patchedNode['outputs']![slot]!['changes']![linkId] !== undefined) {
|
||||
logger.log(
|
||||
` > Already set ${node.id}.outputs[${slot}] to ${
|
||||
patchedNode['inputs']![slot]
|
||||
}! Skipping.`
|
||||
)
|
||||
return null
|
||||
return false
|
||||
}
|
||||
patchedNode['outputs']![slot]!['changes']![linkId] = op
|
||||
if (op === 'ADD') {
|
||||
const linkIdIndex =
|
||||
patchedNode['outputs']![slot]!['links'].indexOf(linkId)
|
||||
if (linkIdIndex !== -1) {
|
||||
logger.log(
|
||||
` > Hmmm.. asked to add ${linkId} but it is already in list...`
|
||||
)
|
||||
return false
|
||||
}
|
||||
patchedNode['outputs']![slot]!['links'].push(linkId)
|
||||
if (fix) {
|
||||
node.outputs = node.outputs || []
|
||||
node.outputs[slot] = node.outputs[slot] || ({} as any)
|
||||
node.outputs[slot]!.links = node.outputs[slot]!.links || []
|
||||
node.outputs[slot]!.links!.push(linkId)
|
||||
}
|
||||
} else {
|
||||
const linkIdIndex =
|
||||
patchedNode['outputs']![slot]!['links'].indexOf(linkId)
|
||||
if (linkIdIndex === -1) {
|
||||
logger.log(
|
||||
` > Hmmm.. asked to remove ${linkId} but it doesn't exist...`
|
||||
)
|
||||
return false
|
||||
}
|
||||
patchedNode['outputs']![slot]!['links'].splice(linkIdIndex, 1)
|
||||
if (fix) {
|
||||
node.outputs?.[slot]!.links!.splice(linkIdIndex, 1)
|
||||
}
|
||||
}
|
||||
patchedNode['outputs']![slot]!['links'].push(linkId)
|
||||
return { node, dir: ioDir, op, slot, linkId, linkIdToUse: linkId }
|
||||
}
|
||||
|
||||
const linkIdIndex = patchedNode['outputs']![slot]!['links'].indexOf(linkId)
|
||||
if (linkIdIndex === -1) {
|
||||
this.log(` > Hmmm.. asked to remove ${linkId} but it doesn't exist...`)
|
||||
return null
|
||||
}
|
||||
patchedNode['outputs']![slot]!['links'].splice(linkIdIndex, 1)
|
||||
return { node, dir: ioDir, op, slot, linkId, linkIdToUse: linkId }
|
||||
data.patchedNodes.push(node)
|
||||
return true
|
||||
}
|
||||
|
||||
/** Checks if a node (or patched data) has a linkId. */
|
||||
private nodeHasLinkId(
|
||||
node: N,
|
||||
/**
|
||||
* Internal to check if a node (or patched data) has a linkId.
|
||||
*/
|
||||
function nodeHasLinkId(
|
||||
node: ISerialisedNode | LGraphNode,
|
||||
ioDir: IoDirection,
|
||||
slot: number,
|
||||
linkId: number
|
||||
) {
|
||||
// Patched data should be canonical. We can double check if fixing too.
|
||||
let has = false
|
||||
if (ioDir === IoDirection.INPUT) {
|
||||
const nodeHasIt = node.inputs?.[slot]?.link === linkId
|
||||
if (this.patchedNodeSlots[node.id]?.['inputs']) {
|
||||
if (patchedNodeSlots[node.id]?.['inputs']) {
|
||||
const patchedHasIt =
|
||||
this.patchedNodeSlots[node.id]!['inputs']![slot] === linkId
|
||||
patchedNodeSlots[node.id]!['inputs']![slot] === linkId
|
||||
// If we're fixing, double check that node matches.
|
||||
if (fix && nodeHasIt !== patchedHasIt) {
|
||||
throw Error('Error. Expected node to match patched data.')
|
||||
}
|
||||
has = patchedHasIt
|
||||
} else {
|
||||
has = nodeHasIt
|
||||
has = !!nodeHasIt
|
||||
}
|
||||
} else {
|
||||
const nodeHasIt = node.outputs?.[slot]?.links?.includes(linkId)
|
||||
if (
|
||||
this.patchedNodeSlots[node.id]?.['outputs']?.[slot]?.['changes'][linkId]
|
||||
) {
|
||||
if (patchedNodeSlots[node.id]?.['outputs']?.[slot]?.['changes'][linkId]) {
|
||||
const patchedHasIt =
|
||||
this.patchedNodeSlots[node.id]!['outputs']![slot]?.links.includes(
|
||||
linkId
|
||||
)
|
||||
patchedNodeSlots[node.id]!['outputs']![slot]?.links.includes(linkId)
|
||||
// If we're fixing, double check that node matches.
|
||||
if (fix && nodeHasIt !== patchedHasIt) {
|
||||
throw Error('Error. Expected node to match patched data.')
|
||||
}
|
||||
has = !!patchedHasIt
|
||||
} else {
|
||||
has = !!nodeHasIt
|
||||
@@ -526,24 +230,38 @@ export abstract class WorkflowLinkFixer<
|
||||
return has
|
||||
}
|
||||
|
||||
/** Checks if a node (or patched data) has a linkId. */
|
||||
private nodeHasAnyLink(node: N, ioDir: IoDirection, slot: number) {
|
||||
/**
|
||||
* Internal to check if a node (or patched data) has a linkId.
|
||||
*/
|
||||
function nodeHasAnyLink(
|
||||
node: ISerialisedNode | LGraphNode,
|
||||
ioDir: IoDirection,
|
||||
slot: number
|
||||
) {
|
||||
// Patched data should be canonical. We can double check if fixing too.
|
||||
let hasAny = false
|
||||
if (ioDir === IoDirection.INPUT) {
|
||||
const nodeHasAny = node.inputs?.[slot]?.link != null
|
||||
if (this.patchedNodeSlots[node.id]?.['inputs']) {
|
||||
if (patchedNodeSlots[node.id]?.['inputs']) {
|
||||
const patchedHasAny =
|
||||
this.patchedNodeSlots[node.id]!['inputs']![slot] != null
|
||||
patchedNodeSlots[node.id]!['inputs']![slot] != null
|
||||
// If we're fixing, double check that node matches.
|
||||
if (fix && nodeHasAny !== patchedHasAny) {
|
||||
throw Error('Error. Expected node to match patched data.')
|
||||
}
|
||||
hasAny = patchedHasAny
|
||||
} else {
|
||||
hasAny = !!nodeHasAny
|
||||
}
|
||||
} else {
|
||||
const nodeHasAny = node.outputs?.[slot]?.links?.length
|
||||
if (this.patchedNodeSlots[node.id]?.['outputs']?.[slot]?.['changes']) {
|
||||
if (patchedNodeSlots[node.id]?.['outputs']?.[slot]?.['changes']) {
|
||||
const patchedHasAny =
|
||||
this.patchedNodeSlots[node.id]!['outputs']![slot]?.links.length
|
||||
patchedNodeSlots[node.id]!['outputs']![slot]?.links.length
|
||||
// If we're fixing, double check that node matches.
|
||||
if (fix && nodeHasAny !== patchedHasAny) {
|
||||
throw Error('Error. Expected node to match patched data.')
|
||||
}
|
||||
hasAny = !!patchedHasAny
|
||||
} else {
|
||||
hasAny = !!nodeHasAny
|
||||
@@ -551,69 +269,209 @@ export abstract class WorkflowLinkFixer<
|
||||
}
|
||||
return hasAny
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A WorkflowLinkFixer for serialized data.
|
||||
*/
|
||||
class WorkflowLinkFixerSerialized extends WorkflowLinkFixer<
|
||||
ISerialisedGraph,
|
||||
ISerialisedNode
|
||||
> {
|
||||
constructor(graph: ISerialisedGraph) {
|
||||
super(graph)
|
||||
let links: Array<SerialisedLLinkArray | LLink> = []
|
||||
if (!Array.isArray(graph.links)) {
|
||||
links = Object.values(graph.links).reduce((acc, v) => {
|
||||
acc[v.id] = v
|
||||
return acc
|
||||
}, links)
|
||||
} else {
|
||||
links = graph.links
|
||||
}
|
||||
|
||||
getNodeById(id: NodeId) {
|
||||
return this.graph.nodes.find((node) => Number(node.id) === id) ?? null
|
||||
}
|
||||
const linksReverse = [...links]
|
||||
linksReverse.reverse()
|
||||
for (const l of linksReverse) {
|
||||
if (!l) continue
|
||||
const link =
|
||||
(l as LLink).origin_slot != null
|
||||
? (l as LLink)
|
||||
: extendLink(l as SerialisedLLinkArray)
|
||||
|
||||
override fix(force: boolean = false, times?: number) {
|
||||
const ret = super.fix(force, times)
|
||||
// If we're a serialized graph, we can filter out the links because it's just an array.
|
||||
this.graph.links = this.graph.links.filter((l) => !!l)
|
||||
return ret
|
||||
}
|
||||
const originNode = getNodeById(graph, link.origin_id)
|
||||
const originHasLink = () =>
|
||||
nodeHasLinkId(originNode!, IoDirection.OUTPUT, link.origin_slot, link.id)
|
||||
const patchOrigin = (op: 'ADD' | 'REMOVE', id = link.id) =>
|
||||
patchNodeSlot(originNode!, IoDirection.OUTPUT, link.origin_slot, id, op)
|
||||
|
||||
deleteGraphLink(id: LinkId) {
|
||||
// Sometimes we got objects instead of serializzed array for links if passed after ComfyUI's
|
||||
// loadGraphData modifies the data. Let's find the id handling the bastardized objects just in
|
||||
// case.
|
||||
const idx = this.graph.links.findIndex(
|
||||
(l) => l && (l[0] === id || (l as any).id === id)
|
||||
)
|
||||
if (idx === -1) {
|
||||
return `Link #${id} not found in workflow links.`
|
||||
}
|
||||
this.graph.links.splice(idx, 1)
|
||||
return true
|
||||
}
|
||||
}
|
||||
const targetNode = getNodeById(graph, link.target_id)
|
||||
const targetHasLink = () =>
|
||||
nodeHasLinkId(targetNode!, IoDirection.INPUT, link.target_slot, link.id)
|
||||
const targetHasAnyLink = () =>
|
||||
nodeHasAnyLink(targetNode!, IoDirection.INPUT, link.target_slot)
|
||||
const patchTarget = (op: 'ADD' | 'REMOVE', id = link.id) =>
|
||||
patchNodeSlot(targetNode!, IoDirection.INPUT, link.target_slot, id, op)
|
||||
|
||||
/**
|
||||
* A WorkflowLinkFixer for live LGraph data.
|
||||
*/
|
||||
class WorkflowLinkFixerGraph extends WorkflowLinkFixer<LGraph, LGraphNode> {
|
||||
constructor(graph: LGraph) {
|
||||
super(graph)
|
||||
}
|
||||
const originLog = `origin(${link.origin_id}).outputs[${link.origin_slot}].links`
|
||||
const targetLog = `target(${link.target_id}).inputs[${link.target_slot}].link`
|
||||
|
||||
getNodeById(id: NodeId) {
|
||||
return this.graph.getNodeById(id) ?? null
|
||||
}
|
||||
|
||||
deleteGraphLink(id: LinkId) {
|
||||
if (this.graph.links instanceof Map) {
|
||||
if (!this.graph.links.has(id)) {
|
||||
return `Link #${id} not found in workflow links.`
|
||||
if (!originNode || !targetNode) {
|
||||
if (!originNode && !targetNode) {
|
||||
logger.log(
|
||||
`Link ${link.id} is invalid, ` +
|
||||
`both origin ${link.origin_id} and target ${link.target_id} do not exist`
|
||||
)
|
||||
} else if (!originNode) {
|
||||
logger.log(
|
||||
`Link ${link.id} is funky... ` +
|
||||
`origin ${link.origin_id} does not exist, but target ${link.target_id} does.`
|
||||
)
|
||||
if (targetHasLink()) {
|
||||
logger.log(
|
||||
` > [PATCH] ${targetLog} does have link, will remove the inputs' link first.`
|
||||
)
|
||||
patchTarget('REMOVE', -1)
|
||||
}
|
||||
} else if (!targetNode) {
|
||||
logger.log(
|
||||
`Link ${link.id} is funky... ` +
|
||||
`target ${link.target_id} does not exist, but origin ${link.origin_id} does.`
|
||||
)
|
||||
if (originHasLink()) {
|
||||
logger.log(
|
||||
` > [PATCH] Origin's links' has ${link.id}; will remove the link first.`
|
||||
)
|
||||
patchOrigin('REMOVE')
|
||||
}
|
||||
}
|
||||
this.graph.links.delete(id)
|
||||
return true
|
||||
continue
|
||||
}
|
||||
if (this.graph.links[id] == null) {
|
||||
return `Link #${id} not found in workflow links.`
|
||||
|
||||
if (targetHasLink() || originHasLink()) {
|
||||
if (!originHasLink()) {
|
||||
logger.log(
|
||||
`${link.id} is funky... ${originLog} does NOT contain it, but ${targetLog} does.`
|
||||
)
|
||||
|
||||
logger.log(
|
||||
` > [PATCH] Attempt a fix by adding this ${link.id} to ${originLog}.`
|
||||
)
|
||||
patchOrigin('ADD')
|
||||
} else if (!targetHasLink()) {
|
||||
logger.log(
|
||||
`${link.id} is funky... ${targetLog} is NOT correct (is ${
|
||||
targetNode.inputs?.[link.target_slot]?.link
|
||||
}), but ${originLog} contains it`
|
||||
)
|
||||
if (!targetHasAnyLink()) {
|
||||
logger.log(
|
||||
` > [PATCH] ${targetLog} is not defined, will set to ${link.id}.`
|
||||
)
|
||||
let patched = patchTarget('ADD')
|
||||
if (!patched) {
|
||||
logger.log(
|
||||
` > [PATCH] Nvm, ${targetLog} already patched. Removing ${link.id} from ${originLog}.`
|
||||
)
|
||||
patched = patchOrigin('REMOVE')
|
||||
}
|
||||
} else {
|
||||
logger.log(
|
||||
` > [PATCH] ${targetLog} is defined, removing ${link.id} from ${originLog}.`
|
||||
)
|
||||
patchOrigin('REMOVE')
|
||||
}
|
||||
}
|
||||
}
|
||||
delete this.graph.links[id]
|
||||
return true
|
||||
}
|
||||
|
||||
// Now that we've cleaned up the inputs, outputs, run through it looking for dangling links.,
|
||||
for (const l of linksReverse) {
|
||||
if (!l) continue
|
||||
const link =
|
||||
(l as LLink).origin_slot != null
|
||||
? (l as LLink)
|
||||
: extendLink(l as SerialisedLLinkArray)
|
||||
const originNode = getNodeById(graph, link.origin_id)
|
||||
const targetNode = getNodeById(graph, link.target_id)
|
||||
// Now that we've manipulated the linking, check again if they both exist.
|
||||
if (
|
||||
(!originNode ||
|
||||
!nodeHasLinkId(
|
||||
originNode,
|
||||
IoDirection.OUTPUT,
|
||||
link.origin_slot,
|
||||
link.id
|
||||
)) &&
|
||||
(!targetNode ||
|
||||
!nodeHasLinkId(
|
||||
targetNode,
|
||||
IoDirection.INPUT,
|
||||
link.target_slot,
|
||||
link.id
|
||||
))
|
||||
) {
|
||||
logger.log(
|
||||
`${link.id} is def invalid; BOTH origin node ${link.origin_id} ${
|
||||
!originNode ? 'is removed' : `doesn't have ${link.id}`
|
||||
} and ${link.origin_id} target node ${
|
||||
!targetNode ? 'is removed' : `doesn't have ${link.id}`
|
||||
}.`
|
||||
)
|
||||
data.deletedLinks.push(link.id)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// If we're fixing, then we've been patching along the way. Now go through and actually delete
|
||||
// the zombie links from `app.graph.links`
|
||||
if (fix) {
|
||||
for (let i = data.deletedLinks.length - 1; i >= 0; i--) {
|
||||
logger.log(`Deleting link #${data.deletedLinks[i]}.`)
|
||||
if ((graph as LGraph).getNodeById) {
|
||||
delete graph.links[data.deletedLinks[i]!]
|
||||
} else {
|
||||
graph = graph as ISerialisedGraph
|
||||
// Sometimes we got objects for links if passed after ComfyUI's loadGraphData modifies the
|
||||
// data. We make a copy now, but can handle the bastardized objects just in case.
|
||||
const idx = graph.links.findIndex(
|
||||
(l) =>
|
||||
l &&
|
||||
(l[0] === data.deletedLinks[i] ||
|
||||
(l as any).id === data.deletedLinks[i])
|
||||
)
|
||||
if (idx === -1) {
|
||||
logger.log(`INDEX NOT FOUND for #${data.deletedLinks[i]}`)
|
||||
}
|
||||
logger.log(`splicing ${idx} from links`)
|
||||
graph.links.splice(idx, 1)
|
||||
}
|
||||
}
|
||||
// If we're a serialized graph, we can filter out the links because it's just an array.
|
||||
if (!(graph as LGraph).getNodeById) {
|
||||
graph.links = (graph as ISerialisedGraph).links.filter((l) => !!l)
|
||||
}
|
||||
}
|
||||
if (!data.patchedNodes.length && !data.deletedLinks.length) {
|
||||
return {
|
||||
hasBadLinks: false,
|
||||
fixed: false,
|
||||
graph,
|
||||
patched: data.patchedNodes.length,
|
||||
deleted: data.deletedLinks.length
|
||||
}
|
||||
}
|
||||
|
||||
logger.log(
|
||||
`${fix ? 'Made' : 'Would make'} ${data.patchedNodes.length || 'no'} node link patches, and ${
|
||||
data.deletedLinks.length || 'no'
|
||||
} stale link removals.`
|
||||
)
|
||||
|
||||
let hasBadLinks: boolean = !!(
|
||||
data.patchedNodes.length || data.deletedLinks.length
|
||||
)
|
||||
// If we're fixing, then let's run it again to see if there are no more bad links.
|
||||
if (fix && !silent) {
|
||||
const rerun = fixBadLinks(graph, { fix: false, silent: true })
|
||||
hasBadLinks = rerun.hasBadLinks
|
||||
}
|
||||
|
||||
return {
|
||||
hasBadLinks,
|
||||
fixed: !!hasBadLinks && fix,
|
||||
graph,
|
||||
patched: data.patchedNodes.length,
|
||||
deleted: data.deletedLinks.length
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user