Compare commits

...

83 Commits

Author SHA1 Message Date
filtered
28f2788cd5 Subgraph testing release v1.22.0-sub.0 (#4086) 2025-06-05 13:56:31 -07:00
Blake
a1ed6ddbf8 [chore] Update litegraph 0.16.0-sub.7 2025-06-05 12:23:20 -07:00
Blake
3d0a3264fe [Test] Remove failing test (auto-generated) 2025-06-05 05:30:12 -07:00
Blake
ceaf47c40e Fix convert to subgraph shown on IO node 2025-06-05 03:21:15 -07:00
filtered
085ed03e46 Fix Vue unwrap using markRaw
Involves minor runtime change.
2025-06-04 03:09:59 -07:00
filtered
465903a962 [Test] Revert invalid test generation 2025-06-04 03:08:24 -07:00
filtered
f3f7eea87c [chore] Update litegraph 0.16.0-sub.6 2025-06-04 02:33:43 -07:00
filtered
38a118da2e Reimpl. escape key handling in frontend 2025-06-04 00:55:24 -07:00
filtered
f76eae3383 Add subgraph nodes to before/after exec 2025-06-04 00:55:24 -07:00
filtered
97765136ed nit 2025-06-04 00:55:24 -07:00
filtered
ced55b1cfc Remove deprecated group node conversion menu 2025-06-04 00:55:24 -07:00
filtered
bdae544c4a Add convert to subgraph toolbox button 2025-06-04 00:55:24 -07:00
filtered
88bb9eea39 [chore] Update litegraph 0.16.0-sub.5 2025-06-04 00:55:24 -07:00
filtered
1bb18b837e Fix delete button shown for io nodes 2025-06-04 00:55:24 -07:00
filtered
ab3a1fff63 Fix edit mask button shown when non-nodes selected 2025-06-04 00:55:24 -07:00
filtered
0de0d1645d Fix breadcrumbs overlap 2nd row tabs 2025-06-04 00:55:24 -07:00
filtered
907f1e27cc Fix active subgraph requires breadcrumb component 2025-06-04 00:55:24 -07:00
filtered
ed80211ae6 Fix unwarranted workflow validation warning 2025-06-04 00:55:24 -07:00
filtered
afd7741927 [chore] Update litegraph 0.16.0-sub.4 2025-06-04 00:55:24 -07:00
filtered
083ef7d779 [Cleanup] Remove redundant LinkConnector call 2025-06-04 00:55:24 -07:00
filtered
9cc9e20955 Update node create to use active subgraph 2025-06-04 00:55:24 -07:00
filtered
b2760a345f [chore] Update litegraph 0.16.0-sub.3 2025-06-04 00:55:24 -07:00
filtered
a3cda65a7b Fix invalid links in nested subgraph conversion 2025-06-04 00:55:24 -07:00
filtered
c646ba8c53 Update litegraph 0.16.0-sub.2 2025-06-04 00:55:24 -07:00
filtered
2918eb2213 Fix crash on graph load - reactive proxy leak 2025-06-04 00:55:24 -07:00
filtered
38fed2f2c9 Update litegraph 0.16.0-sub.1 2025-06-04 00:55:24 -07:00
filtered
53a1598459 Fix breadcrumb reactivity 2025-06-04 00:55:24 -07:00
github-actions
d06728dad3 Update locales [skip ci] 2025-06-04 00:55:24 -07:00
filtered
2405fb16bc Use subgraph npm 2025-06-04 00:55:24 -07:00
filtered
a56b616571 Add simpler interface for active DOM widgets 2025-06-04 00:54:51 -07:00
filtered
5888f673e4 [Test] Update expectations 2025-06-04 00:54:51 -07:00
filtered
9fdcc02d0f Keep subgraph nav state when swapping workflows 2025-06-04 00:54:51 -07:00
filtered
f862afafb4 Clear DOM widgets instead of trying to manage refs 2025-06-04 00:54:51 -07:00
filtered
7f6d0b3c47 [Debug] Include more items in graph diff output 2025-06-04 00:54:27 -07:00
filtered
cae1845d6c Add convert to subgraph command 2025-06-04 00:54:27 -07:00
filtered
9541735bef Fix DOM widgets disappear 2025-06-04 00:54:27 -07:00
filtered
3f5113f49d nit 2025-06-04 00:54:27 -07:00
filtered
a0059b4ba2 Track subgraph open history for breadcrumbs 2025-06-04 00:54:27 -07:00
filtered
5d78cec293 [TS] Replace ts-ignore w/error 2025-06-04 00:54:27 -07:00
filtered
2a41b90538 nit 2025-06-04 00:54:27 -07:00
filtered
ecdf89d8d8 Fix desync of litegraph type and nodedefs 2025-06-04 00:54:27 -07:00
filtered
6d5cbcc8b6 [TS] Remove unnecessary assertion 2025-06-04 00:54:27 -07:00
filtered
024212c735 Prune DOM widgets outside of current subgraph 2025-06-04 00:54:27 -07:00
filtered
4342fc46ab Remove unnecessary copy of litegraph objects 2025-06-04 00:54:27 -07:00
filtered
aebc35f4fb Fix nested subgraph breadcrumbs 2025-06-04 00:54:27 -07:00
filtered
afb441654f nit - prevent duplicate subgraph registrations 2025-06-04 00:54:27 -07:00
filtered
03af7516a4 Fix DOM widgets after rebase 2025-06-04 00:54:27 -07:00
filtered
27779ce7c5 [PARTIAL] Add Subgraph 2025-06-04 00:54:27 -07:00
filtered
f2bc74f0b7 Fix corruption when app.graph is changed 2025-06-04 00:54:27 -07:00
filtered
1589f2cce6 [TS] Remove unintended type assertion 2025-06-04 00:54:27 -07:00
filtered
c686c9c144 Add subgraph workflow fields to change tracker 2025-06-04 00:54:27 -07:00
filtered
d7bca4aa28 Add i18n for nothing selected notification 2025-06-04 00:54:27 -07:00
filtered
7d4050de05 [Refactor] Generic TS type dedupe 2025-06-04 00:54:27 -07:00
filtered
5077dee42b [Nope] Zod schema recursive II 2025-06-04 00:54:26 -07:00
filtered
03bfa0ae71 [Nope] Add Zod recursive schema requirements 2025-06-04 00:54:26 -07:00
Comfy Org PR Bot
d1f4341319 1.22.0 (#4060)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-03 06:16:28 -07:00
Comfy Org PR Bot
8c8bb1a3b7 [chore] Update litegraph to 0.15.15 (#4062)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-03 06:16:12 -07:00
ComfyUI Wiki
05ef25a7a3 Update the Compare slider start position to the middle (#4052) 2025-06-02 21:40:13 -07:00
Benjamin Lu
86aeeb87bb Change hosts accept from readWrite to read (#4058) 2025-06-03 03:16:43 +00:00
Christian Byrne
f7093f6ce0 [dev] Add claude command to provide feedback and spot issues with local changes using Playwright MCP (#4039) 2025-06-02 02:19:51 -07:00
Benjamin Lu
88817e5bc0 Use new Algolia proxy (#4030) 2025-06-02 00:20:37 -07:00
filtered
3ac8aa248c Revert "Export vue new (#3966)" (#4050) 2025-06-02 09:57:47 +10:00
filtered
75ab54ee04 Revert "[Dev] Add Playwright MCP for Local Development (#4028)" (#4048) 2025-06-02 06:21:35 +10:00
filtered
a5729c9e06 Revert "[fix] Automatically fix malformed node def translations" (#4045) 2025-06-02 05:37:30 +10:00
filtered
d1da3476da Revert "Update locales for node definitions" (#4047) 2025-06-02 05:36:41 +10:00
Comfy Org PR Bot
ac01bff67e Update locales for node definitions (#4019)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
Co-authored-by: bymyself <cbyrne@comfy.org>
Co-authored-by: github-actions <github-actions@github.com>
2025-06-01 06:46:50 -07:00
Christian Byrne
ec4ced26e7 [fix] Automatically fix malformed node def translations (#4042) 2025-06-01 06:45:40 -07:00
Benjamin Lu
40cfc43c54 Add Help Menu in NodeLibrarySidebarTab (#3922)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-01 23:24:31 +10:00
filtered
35a811c5cf Remove duplication from bug report form (#4043) 2025-06-01 22:42:50 +10:00
Christian Byrne
3d4ac07957 [DevTask] Add custom node testing checkbox to issue template (#4041) 2025-06-01 02:55:59 -07:00
Christian Byrne
54055e7707 [docs] Centralize troubleshooting documentation (#4040) 2025-06-01 01:32:21 -07:00
Christian Byrne
69f33f322f [fix] Clear CSS background variable when canvas background image is removed (#4034) 2025-06-01 13:41:17 +10:00
Christian Byrne
b81c2f7cd2 [bugfix] Filter model metadata by current widget selection (#4021)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-06-01 12:43:00 +10:00
Comfy Org PR Bot
6289ac9182 1.21.3 (#4035)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-01 00:31:35 +00:00
Christian Byrne
86a7dd05a3 [Dev] Add Playwright MCP for Local Development (#4028)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-31 13:51:37 -07:00
Christian Byrne
dee00edc5f [feat] Add node library sorting and grouping controls (#4024)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-31 17:39:39 +10:00
Christian Byrne
afac449f41 [fix] Remove dynamic import timing issue causing Playwright test flakiness (#4031) 2025-05-31 14:01:13 +10:00
filtered
aca1a2a194 Revert "Allow extensions to define pinia stores" (#4027) 2025-05-31 04:12:59 +10:00
filtered
4dfe75d68b Add GH types to issue templates (#3991) 2025-05-30 02:57:10 -07:00
Christian Byrne
2c37dba143 [docs] Add Claude command for adding missing i18n strings (#4023) 2025-05-30 02:22:40 -07:00
Christian Byrne
3936454ffd [feat] Add logout button to user popover (#4022) 2025-05-30 02:17:00 -07:00
Christian Byrne
30ee669f5c [refactor] Refactor file handling (#3955) 2025-05-30 02:05:41 -07:00
Terry Jia
811ddd6165 Allow extensions to define pinia stores (#4018) 2025-05-30 12:05:03 +10:00
81 changed files with 4565 additions and 640 deletions

View File

@@ -0,0 +1,43 @@
# Add Missing i18n Translations
## Task: Add English translations for all new localized strings
### Step 1: Identify new translation keys
Find all translation keys that were added in the current branch's changes. These keys appear as arguments to translation functions: `t()`, `st()`, `$t()`, or similar i18n functions.
### Step 2: Add translations to English locale file
For each new translation key found, add the corresponding English text to the file `src/locales/en/main.json`.
### Key-to-JSON mapping rules:
- Translation keys use dot notation to represent nested JSON structure
- Convert dot notation to nested JSON objects when adding to the locale file
- Example: The key `g.user.name` maps to:
```json
{
"g": {
"user": {
"name": "User Name"
}
}
}
```
### Important notes:
1. **Only modify the English locale file** (`src/locales/en/main.json`)
2. **Do not modify other locale files** - translations for other languages are automatically generated by the `i18n.yaml` workflow
3. **Exception for manual translations**: Only add translations to non-English locale files if:
- You have specific domain knowledge that would produce a more accurate translation than the automated system
- The automated translation would likely be incorrect due to technical terminology or context-specific meaning
### Example workflow:
1. If you added `t('settings.advanced.enable')` in a Vue component
2. Add to `src/locales/en/main.json`:
```json
{
"settings": {
"advanced": {
"enable": "Enable advanced settings"
}
}
}
```

View File

@@ -0,0 +1,57 @@
Your task is to perform visual verification of our recent changes to ensure they display correctly in the browser. This verification is critical for catching visual regressions, layout issues, and ensuring our UI changes render properly for end users.
<instructions>
Follow these steps systematically to verify our changes:
1. **Server Setup**
- Check if the dev server is running on port 5173 using browser navigation or port checking
- If not running, start it with `npm run dev` from the root directory
- If the server fails to start, provide detailed troubleshooting steps by reading package.json and README.md for accurate instructions
- Wait for the server to be fully ready before proceeding
2. **Visual Testing Process**
- Navigate to http://localhost:5173/
- For each target page (specified in arguments or recently changed files):
* Navigate to the page using direct URL or site navigation
* Take a high-quality screenshot
* Analyze the screenshot for the specific changes we implemented
* Document any visual issues or improvements needed
3. **Quality Verification**
Check each page for:
- Content accuracy and completeness
- Proper styling and layout alignment
- Responsive design elements
- Navigation functionality
- Image loading and display
- Typography and readability
- Color scheme consistency
- Interactive elements (buttons, links, forms)
</instructions>
<examples>
Common issues to watch for:
- Broken layouts or overlapping elements
- Missing images or broken image links
- Inconsistent styling or spacing
- Navigation menu problems
- Mobile responsiveness issues
- Text overflow or truncation
- Color contrast problems
</examples>
<reporting>
For each page tested, provide:
1. Page URL and screenshot
2. Confirmation that changes display correctly OR detailed description of issues found
3. Any design improvement suggestions
4. Overall assessment of visual quality
If you find issues, be specific about:
- Exact location of the problem
- Expected vs actual behavior
- Severity level (critical, important, minor)
- Suggested fix if obvious
</reporting>
Remember: Take your time with each screenshot and analysis. Visual quality directly impacts user experience and our project's professional appearance.

View File

@@ -1,7 +1,9 @@
name: Bug Report
description: "Something is not behaving as expected."
title: "[Bug]: "
description: 'Something is not behaving as expected.'
title: '[Bug]: '
labels: ['Potential Bug']
type: Bug
body:
- type: markdown
attributes:
@@ -10,8 +12,15 @@ body:
- **1:** You are running the latest version of ComfyUI.
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
- **3:** You confirmed that the bug is not caused by a custom node. You can disable all custom nodes by passing
`--disable-all-custom-nodes` command line argument.
- type: checkboxes
id: custom-nodes-test
attributes:
label: Custom Node Testing
description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
options:
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
required: true
- type: textarea
attributes:

View File

@@ -1,7 +1,8 @@
name: Feature Request
description: Suggest an idea for this project
title: "[Feature Request]: "
labels: ["enhancement"]
title: '[Feature Request]: '
labels: ['enhancement']
type: Feature
body:
- type: checkboxes

100
README.md
View File

@@ -641,100 +641,8 @@ See [locales/README.md](src/locales/README.md) for details.
## Troubleshooting
> **Note**: For comprehensive troubleshooting and how-to guides, please refer to our [official documentation](https://docs.comfy.org/). This section covers only the most common issues related to frontend development.
For comprehensive troubleshooting and technical support, please refer to our official documentation:
> **Desktop Users**: For issues specific to the desktop application, please refer to the [ComfyUI desktop repository](https://github.com/Comfy-Org/desktop).
### Debugging Custom Node (Extension) Issues
If you're experiencing crashes, errors, or unexpected behavior with ComfyUI, it's often caused by custom nodes (extensions). Follow these steps to identify and resolve the issues:
#### Step 1: Verify if custom nodes are causing the problem
Run ComfyUI with the `--disable-all-custom-nodes` flag:
```bash
python main.py --disable-all-custom-nodes
```
If the issue disappears, a custom node is the culprit. Proceed to the next step.
#### Step 2: Identify the problematic custom node using binary search
Rather than disabling nodes one by one, use this more efficient approach:
1. Temporarily move half of your custom nodes out of the `custom_nodes` directory
```bash
# Create a temporary directory
# Linux/Mac
mkdir ~/custom_nodes_disabled
# Windows
mkdir %USERPROFILE%\custom_nodes_disabled
# Move half of your custom nodes (assuming you have node1 through node8)
# Linux/Mac
mv custom_nodes/node1 custom_nodes/node2 custom_nodes/node3 custom_nodes/node4 ~/custom_nodes_disabled/
# Windows
move custom_nodes\node1 custom_nodes\node2 custom_nodes\node3 custom_nodes\node4 %USERPROFILE%\custom_nodes_disabled\
```
2. Run ComfyUI again
- If the issue persists: The problem is in nodes 5-8 (the remaining half)
- If the issue disappears: The problem is in nodes 1-4 (the moved half)
3. Let's assume the issue disappeared, so the problem is in nodes 1-4. Move half of these for the next test:
```bash
# Move nodes 3-4 back to custom_nodes
# Linux/Mac
mv ~/custom_nodes_disabled/node3 ~/custom_nodes_disabled/node4 custom_nodes/
# Windows
move %USERPROFILE%\custom_nodes_disabled\node3 %USERPROFILE%\custom_nodes_disabled\node4 custom_nodes\
```
4. Run ComfyUI again
- If the issue reappears: The problem is in nodes 3-4
- If issue still gone: The problem is in nodes 1-2
5. Let's assume the issue reappeared, so the problem is in nodes 3-4. Test each one:
```bash
# Move node 3 back to disabled
# Linux/Mac
mv custom_nodes/node3 ~/custom_nodes_disabled/
# Windows
move custom_nodes\node3 %USERPROFILE%\custom_nodes_disabled\
```
6. Run ComfyUI again
- If the issue disappears: node3 is the problem
- If issue persists: node4 is the problem
7. Repeat until you identify the specific problematic node
#### Step 3: Update or replace the problematic node
Once identified:
1. Check for updates to the problematic custom node
2. Consider alternatives with similar functionality
3. Report the issue to the custom node developer with specific details
### Common Issues and Solutions
- **"Module not found" errors**: Usually indicates missing Python dependencies. Check the custom node's `requirements.txt` file for required packages and install them:
```bash
pip install -r custom_nodes/problematic_node/requirements.txt
```
- **Frontend or Templates Package Not Updated**: After updating ComfyUI via Git, ensure you update the frontend dependencies:
```bash
pip install -r requirements.txt
```
- **Can't Find Custom Node**: Make sure to disable node validation in ComfyUI settings.
- **Error Toast About Workflow Failing Validation**: Report the issue to the ComfyUI team. As a temporary workaround, disable workflow validation in settings.
- **Login Issues When Not on Localhost**: Normal login is only available when accessing from localhost. If you're running ComfyUI via LAN, another domain, or headless, you can use our API key feature to authenticate. The API key lets you log in normally through the UI. Generate an API key at [platform.comfy.org/login](https://platform.comfy.org/login) and use it in the API Key field in the login dialog or with the `--api-key` command line argument. Refer to our [API Key Integration Guide](https://docs.comfy.org/essentials/comfyui-server/api-key-integration#integration-of-api-key-to-use-comfyui-api-nodes) for complete setup instructions.
- **[General Troubleshooting Guide](https://docs.comfy.org/troubleshooting/overview)** - Common issues, performance optimization, and reporting bugs
- **[Custom Node Issues](https://docs.comfy.org/troubleshooting/custom-node-issues)** - Debugging custom node problems and conflicts
- **[Desktop Installation Guide](https://docs.comfy.org/installation/desktop/windows)** - Desktop-specific installation and troubleshooting

View File

@@ -0,0 +1,59 @@
{
"last_node_id": 1,
"last_link_id": 0,
"nodes": [
{
"id": 1,
"type": "CheckpointLoaderSimple",
"pos": [256, 256],
"size": [315, 98],
"flags": {},
"order": 0,
"mode": 0,
"inputs": [],
"outputs": [
{
"name": "MODEL",
"type": "MODEL",
"links": null
},
{
"name": "CLIP",
"type": "CLIP",
"links": null
},
{
"name": "VAE",
"type": "VAE",
"links": null
}
],
"properties": {
"Node name for S&R": "CheckpointLoaderSimple",
"models": [
{
"name": "outdated_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
},
{
"name": "another_outdated_model.safetensors",
"url": "http://localhost:8188/api/devtools/fake_model.safetensors",
"directory": "text_encoders"
}
]
},
"widgets_values": ["current_selected_model.safetensors"]
}
],
"links": [],
"groups": [],
"config": {},
"extra": {
"ds": {
"offset": [0, 0],
"scale": 1
}
},
"version": 0.4
}

View File

@@ -103,7 +103,7 @@ test.describe('Missing models warning', () => {
}
])
}
comfyPage.page.route(
await comfyPage.page.route(
'**/api/experiment/models',
(route) => route.fulfill(modelFoldersRes),
{ times: 1 }
@@ -121,7 +121,7 @@ test.describe('Missing models warning', () => {
}
])
}
comfyPage.page.route(
await comfyPage.page.route(
'**/api/experiment/models/text_encoders',
(route) => route.fulfill(clipModelsRes),
{ times: 1 }
@@ -133,6 +133,18 @@ test.describe('Missing models warning', () => {
await expect(missingModelsWarning).not.toBeVisible()
})
test('Should not display warning when model metadata exists but widget values have changed', async ({
comfyPage
}) => {
// This tests the scenario where outdated model metadata exists in the workflow
// but the actual selected models (widget values) have changed
await comfyPage.loadWorkflow('model_metadata_widget_mismatch')
// The missing models warning should NOT appear
const missingModelsWarning = comfyPage.page.locator('.comfy-missing-models')
await expect(missingModelsWarning).not.toBeVisible()
})
// Flaky test after parallelization
// https://github.com/Comfy-Org/ComfyUI_frontend/pull/1400
test.skip('Should download missing model when clicking download button', async ({

View File

@@ -0,0 +1,556 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../fixtures/ComfyPage'
// TODO: there might be a better solution for this
// Helper function to pan canvas and select node
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
const nodePos = await nodeRef.getPosition()
await comfyPage.page.evaluate((pos) => {
const app = window['app']
const canvas = app.canvas
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
canvas.setDirty(true, true)
}, nodePos)
await comfyPage.nextFrame()
await nodeRef.click('title')
}
test.describe('Node Help', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setup()
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
})
test.describe('Selection Toolbox', () => {
test('Should open help menu for selected node', async ({ comfyPage }) => {
// Load a workflow with a node
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.loadWorkflow('default')
// Select a single node (KSampler) using node references
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
if (ksamplerNodes.length === 0) {
throw new Error('No KSampler nodes found in the workflow')
}
// Select the node with panning to ensure toolbox is visible
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Wait for selection overlay container and toolbox to appear
await expect(
comfyPage.page.locator('.selection-overlay-container')
).toBeVisible()
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
// Click the help button in the selection toolbox
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await expect(helpButton).toBeVisible()
await helpButton.click()
// Verify that the node library sidebar is opened
await expect(
comfyPage.menu.nodeLibraryTab.selectedTabButton
).toBeVisible()
// Verify that the help page is shown for the correct node
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler')
await expect(helpPage.locator('.node-help-content')).toBeVisible()
})
})
test.describe('Node Library Sidebar', () => {
test('Should open help menu from node library', async ({ comfyPage }) => {
// Open the node library sidebar
await comfyPage.menu.nodeLibraryTab.open()
// Wait for node library to load
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
// Search for KSampler to make it easier to find
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
'KSampler'
)
// Find the KSampler node in search results
const ksamplerNode = comfyPage.page
.locator('.tree-explorer-node-label')
.filter({ hasText: 'KSampler' })
.first()
await expect(ksamplerNode).toBeVisible()
// Hover over the node to show action buttons
await ksamplerNode.hover()
// Click the help button
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
await expect(helpButton).toBeVisible()
await helpButton.click()
// Verify that the help page is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler')
await expect(helpPage.locator('.node-help-content')).toBeVisible()
})
test('Should show node library tab when clicking back from help page', async ({
comfyPage
}) => {
// Open the node library sidebar
await comfyPage.menu.nodeLibraryTab.open()
// Wait for node library to load
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
// Search for KSampler
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
'KSampler'
)
// Find and interact with the node
const ksamplerNode = comfyPage.page
.locator('.tree-explorer-node-label')
.filter({ hasText: 'KSampler' })
.first()
await ksamplerNode.hover()
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
await helpButton.click()
// Verify help page is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler')
// Click the back button - use a more specific selector
const backButton = comfyPage.page.locator('button:has(.pi-arrow-left)')
await expect(backButton).toBeVisible()
await backButton.click()
// Verify that we're back to the node library view
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
await expect(
comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput
).toBeVisible()
// Verify help page is no longer visible
await expect(helpPage.locator('.node-help-content')).not.toBeVisible()
})
})
test.describe('Help Content', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
})
test('Should display loading state while fetching help', async ({
comfyPage
}) => {
// Mock slow network response
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 1000))
await route.fulfill({
status: 200,
body: '# Test Help Content\nThis is test help content.'
})
})
// Load workflow and select a node
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
// Verify loading spinner is shown
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage.locator('.p-progressspinner')).toBeVisible()
// Wait for content to load
await expect(helpPage).toContainText('Test Help Content')
})
test('Should display fallback content when help file not found', async ({
comfyPage
}) => {
// Mock 404 response for help files
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
await route.fulfill({
status: 404,
body: 'Not Found'
})
})
// Load workflow and select a node
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
// Click help button
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
// Verify fallback content is shown (description, inputs, outputs)
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('Description')
await expect(helpPage).toContainText('Inputs')
await expect(helpPage).toContainText('Outputs')
})
test('Should render markdown with images correctly', async ({
comfyPage
}) => {
// Mock response with markdown containing images
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Documentation
![Example Image](example.jpg)
![External Image](https://example.com/image.png)
## Parameters
- **steps**: Number of steps
`
})
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler Documentation')
// Check that relative image paths are prefixed correctly
const relativeImage = helpPage.locator('img[alt="Example Image"]')
await expect(relativeImage).toBeVisible()
await expect(relativeImage).toHaveAttribute(
'src',
/.*\/docs\/KSampler\/example\.jpg/
)
// Check that absolute URLs are not modified
const externalImage = helpPage.locator('img[alt="External Image"]')
await expect(externalImage).toHaveAttribute(
'src',
'https://example.com/image.png'
)
})
test('Should render video elements with source tags in markdown', async ({
comfyPage
}) => {
// Mock response with video elements
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Demo
<video src="demo.mp4" controls autoplay></video>
<video src="/absolute/video.mp4" controls></video>
<video controls>
<source src="video.mp4" type="video/mp4">
<source src="https://example.com/video.webm" type="video/webm">
</video>
`
})
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
// Check relative video paths are prefixed
const relativeVideo = helpPage.locator('video[src*="demo.mp4"]')
await expect(relativeVideo).toBeVisible()
await expect(relativeVideo).toHaveAttribute(
'src',
/.*\/docs\/KSampler\/demo\.mp4/
)
await expect(relativeVideo).toHaveAttribute('controls', '')
await expect(relativeVideo).toHaveAttribute('autoplay', '')
// Check absolute paths are not modified
const absoluteVideo = helpPage.locator('video[src="/absolute/video.mp4"]')
await expect(absoluteVideo).toHaveAttribute('src', '/absolute/video.mp4')
// Check video source elements
const relativeVideoSource = helpPage.locator('source[src*="video.mp4"]')
await expect(relativeVideoSource).toHaveAttribute(
'src',
/.*\/docs\/KSampler\/video\.mp4/
)
const externalVideoSource = helpPage.locator(
'source[src="https://example.com/video.webm"]'
)
await expect(externalVideoSource).toHaveAttribute(
'src',
'https://example.com/video.webm'
)
})
test('Should handle custom node documentation paths', async ({
comfyPage
}) => {
// First load workflow with custom node
await comfyPage.loadWorkflow('group_node_v1.3.3')
// Mock custom node documentation with fallback
await comfyPage.page.route(
'**/extensions/*/docs/*/en.md',
async (route) => {
await route.fulfill({ status: 404 })
}
)
await comfyPage.page.route('**/extensions/*/docs/*.md', async (route) => {
await route.fulfill({
status: 200,
body: `# Custom Node Documentation
This is documentation for a custom node.
![Custom Image](assets/custom.png)
`
})
})
// Find and select a custom/group node
const nodeRefs = await comfyPage.page.evaluate(() => {
return window['app'].graph.nodes.map((n: any) => n.id)
})
if (nodeRefs.length > 0) {
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
await selectNodeWithPan(comfyPage, firstNode)
}
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
if (await helpButton.isVisible()) {
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('Custom Node Documentation')
// Check image path for custom nodes
const image = helpPage.locator('img[alt="Custom Image"]')
await expect(image).toHaveAttribute(
'src',
/.*\/extensions\/.*\/docs\/assets\/custom\.png/
)
}
})
test('Should sanitize dangerous HTML content', async ({ comfyPage }) => {
// Mock response with potentially dangerous content
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# Safe Content
<script>alert('XSS')</script>
<img src="x" onerror="alert('XSS')">
<a href="javascript:alert('XSS')">Dangerous Link</a>
<iframe src="evil.com"></iframe>
<!-- Safe content -->
<video src="safe.mp4" controls></video>
<img src="safe.jpg" alt="Safe Image">
`
})
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
// Dangerous elements should be removed
await expect(helpPage.locator('script')).toHaveCount(0)
await expect(helpPage.locator('iframe')).toHaveCount(0)
// Check that onerror attribute is removed
const images = helpPage.locator('img')
const imageCount = await images.count()
for (let i = 0; i < imageCount; i++) {
const img = images.nth(i)
const onError = await img.getAttribute('onerror')
expect(onError).toBeNull()
}
// Check that javascript: links are sanitized
const links = helpPage.locator('a')
const linkCount = await links.count()
for (let i = 0; i < linkCount; i++) {
const link = links.nth(i)
const href = await link.getAttribute('href')
if (href !== null) {
expect(href).not.toContain('javascript:')
}
}
// Safe content should remain
await expect(helpPage.locator('video[src*="safe.mp4"]')).toBeVisible()
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
})
test('Should handle locale-specific documentation', async ({
comfyPage
}) => {
// Mock different responses for different locales
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSamplerード
これは日本語のドキュメントです。
`
})
})
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: `# KSampler Node
This is English documentation.
`
})
})
// Set locale to Japanese
await comfyPage.setSetting('Comfy.Locale', 'ja')
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSamplerード')
await expect(helpPage).toContainText('これは日本語のドキュメントです')
// Reset locale
await comfyPage.setSetting('Comfy.Locale', 'en')
})
test('Should handle network errors gracefully', async ({ comfyPage }) => {
// Mock network error
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
await route.abort('failed')
})
await comfyPage.loadWorkflow('default')
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
// Should show fallback content (node description)
await expect(helpPage).toBeVisible()
await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible()
// Should show some content even on error
const content = await helpPage.textContent()
expect(content).toBeTruthy()
})
test('Should update help content when switching between nodes', async ({
comfyPage
}) => {
// Mock different help content for different nodes
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
await route.fulfill({
status: 200,
body: '# KSampler Help\n\nThis is KSampler documentation.'
})
})
await comfyPage.page.route(
'**/docs/CheckpointLoaderSimple/en.md',
async (route) => {
await route.fulfill({
status: 200,
body: '# Checkpoint Loader Help\n\nThis is Checkpoint Loader documentation.'
})
}
)
await comfyPage.loadWorkflow('default')
// Select KSampler first
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
const helpButton = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton.click()
const helpPage = comfyPage.page.locator('.sidebar-content-container')
await expect(helpPage).toContainText('KSampler Help')
await expect(helpPage).toContainText('This is KSampler documentation')
// Now select Checkpoint Loader
const checkpointNodes = await comfyPage.getNodeRefsByType(
'CheckpointLoaderSimple'
)
await selectNodeWithPan(comfyPage, checkpointNodes[0])
// Click help button again
const helpButton2 = comfyPage.page.locator(
'.selection-toolbox button:has(.pi-question-circle)'
)
await helpButton2.click()
// Content should update
await expect(helpPage).toContainText('Checkpoint Loader Help')
await expect(helpPage).toContainText(
'This is Checkpoint Loader documentation'
)
await expect(helpPage).not.toContainText('KSampler documentation')
})
})
})

View File

@@ -0,0 +1,59 @@
import { Plugin } from 'vite'
/**
* Vite plugin that adds an alias export for Vue's createBaseVNode as createElementVNode.
*
* This plugin addresses compatibility issues where some components or libraries
* might be using the older createElementVNode function name instead of createBaseVNode.
* It modifies the Vue vendor chunk during build to add the alias export.
*
* @returns {Plugin} A Vite plugin that modifies the Vue vendor chunk exports
*/
export function addElementVnodeExportPlugin(): Plugin {
return {
name: 'add-element-vnode-export-plugin',
renderChunk(code, chunk, _options) {
if (chunk.name.startsWith('vendor-vue')) {
const exportRegex = /(export\s*\{)([^}]*)(\}\s*;?\s*)$/
const match = code.match(exportRegex)
if (match) {
const existingExports = match[2].trim()
const exportsArray = existingExports
.split(',')
.map((e) => e.trim())
.filter(Boolean)
const hasCreateBaseVNode = exportsArray.some((e) =>
e.startsWith('createBaseVNode')
)
const hasCreateElementVNode = exportsArray.some((e) =>
e.includes('createElementVNode')
)
if (hasCreateBaseVNode && !hasCreateElementVNode) {
const newExportStatement = `${match[1]} ${existingExports ? existingExports + ',' : ''} createBaseVNode as createElementVNode ${match[3]}`
const newCode = code.replace(exportRegex, newExportStatement)
console.log(
`[add-element-vnode-export-plugin] Added 'createBaseVNode as createElementVNode' export to vendor-vue chunk.`
)
return { code: newCode, map: null }
} else if (!hasCreateBaseVNode) {
console.warn(
`[add-element-vnode-export-plugin] Warning: 'createBaseVNode' not found in exports of vendor-vue chunk. Cannot add alias.`
)
}
} else {
console.warn(
`[add-element-vnode-export-plugin] Warning: Could not find expected export block format in vendor-vue chunk.`
)
}
}
return null
}
}
}

View File

@@ -1,24 +1,9 @@
import glob from 'fast-glob'
import fs from 'fs-extra'
import { dirname, join } from 'node:path'
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
import type { OutputOptions } from 'rollup'
import { HtmlTagDescriptor, Plugin } from 'vite'
interface ImportMapSource {
interface VendorLibrary {
name: string
pattern: string | RegExp
entry: string
recursiveDependence?: boolean
override?: Record<string, Partial<ImportMapSource>>
}
const parseDeps = (root: string, pkg: string) => {
const pkgPath = join(root, 'node_modules', pkg, 'package.json')
if (fs.existsSync(pkgPath)) {
const content = fs.readFileSync(pkgPath, 'utf-8')
const pkg = JSON.parse(content)
return Object.keys(pkg.dependencies || {})
}
return []
pattern: RegExp
}
/**
@@ -38,89 +23,53 @@ const parseDeps = (root: string, pkg: string) => {
* @returns {Plugin} A Vite plugin that generates and injects an import map
*/
export function generateImportMapPlugin(
importMapSources: ImportMapSource[]
vendorLibraries: VendorLibrary[]
): Plugin {
const importMapEntries: Record<string, string> = {}
const resolvedImportMapSources: Map<string, ImportMapSource> = new Map()
const assetDir = 'assets/lib'
let root: string
return {
name: 'generate-import-map-plugin',
// Configure manual chunks during the build process
configResolved(config) {
root = config.root
if (config.build) {
// Ensure rollupOptions exists
if (!config.build.rollupOptions) {
config.build.rollupOptions = {}
}
for (const source of importMapSources) {
resolvedImportMapSources.set(source.name, source)
if (source.recursiveDependence) {
const deps = parseDeps(root, source.name)
while (deps.length) {
const dep = deps.shift()!
const depSource = Object.assign({}, source, {
name: dep,
pattern: dep,
...source.override?.[dep]
})
resolvedImportMapSources.set(depSource.name, depSource)
const _deps = parseDeps(root, depSource.name)
deps.unshift(..._deps)
const outputOptions: OutputOptions = {
manualChunks: (id: string) => {
for (const lib of vendorLibraries) {
if (lib.pattern.test(id)) {
return `vendor-${lib.name}`
}
}
}
return null
},
// Disable minification of internal exports to preserve function names
minifyInternalExports: false
}
const external: (string | RegExp)[] = []
for (const [, source] of resolvedImportMapSources) {
external.push(source.pattern)
}
config.build.rollupOptions.external = external
config.build.rollupOptions.output = outputOptions
}
},
generateBundle(_options) {
for (const [, source] of resolvedImportMapSources) {
if (source.entry) {
const moduleFile = join(source.name, source.entry)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
generateBundle(_options, bundle) {
for (const fileName in bundle) {
const chunk = bundle[fileName]
if (chunk.type === 'chunk' && !chunk.isEntry) {
// Find matching vendor library by chunk name
const vendorLib = vendorLibraries.find(
(lib) => chunk.name === `vendor-${lib.name}`
)
importMapEntries[source.name] =
'./' + normalizePath(join(assetDir, moduleFile))
if (vendorLib) {
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
importMapEntries[vendorLib.name] = relativePath
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
}
if (source.recursiveDependence) {
const files = glob.sync(['**/*.{js,mjs}'], {
cwd: join(root, 'node_modules', source.name)
})
for (const file of files) {
const moduleFile = join(source.name, file)
const sourceFile = join(root, 'node_modules', moduleFile)
const targetFile = join(root, 'dist', assetDir, moduleFile)
importMapEntries[normalizePath(join(source.name, dirname(file)))] =
'./' + normalizePath(join(assetDir, moduleFile))
const targetDir = dirname(targetFile)
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true })
}
fs.copyFileSync(sourceFile, targetFile)
console.log(
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
)
}
}
}

View File

@@ -1,2 +1,3 @@
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
export { comfyAPIPlugin } from './comfyAPIPlugin'
export { generateImportMapPlugin } from './generateImportMapPlugin'

53
package-lock.json generated
View File

@@ -1,18 +1,18 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.21.2",
"version": "1.22.0-sub.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.21.2",
"version": "1.22.0-sub.0",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.14",
"@comfyorg/litegraph": "^0.16.0-sub.7",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -29,12 +29,14 @@
"@xterm/xterm": "^5.5.0",
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",
@@ -54,6 +56,7 @@
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.44.1",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
@@ -788,9 +791,9 @@
"license": "GPL-3.0-only"
},
"node_modules/@comfyorg/litegraph": {
"version": "0.15.14",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.15.14.tgz",
"integrity": "sha512-9yERUwRVFPFspXowyg5z97QyF6+UbHG6ZNygvxSOisTCVSPOUeX/E02xcnhB5BHk0bTZCJGg9v2iztXBE5brnA==",
"version": "0.16.0-sub.7",
"resolved": "https://registry.npmjs.org/@comfyorg/litegraph/-/litegraph-0.16.0-sub.7.tgz",
"integrity": "sha512-L1PIuN4JKczGYP7cu5jsgnjw6rWJRfr4753eBv7ufQX9Jd6g0dyrf6u8XaIVBj2Z1EpWy+SNRxT39VwZWu61KQ==",
"license": "MIT"
},
"node_modules/@cspotcode/source-map-support": {
@@ -3988,6 +3991,16 @@
"integrity": "sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==",
"license": "MIT"
},
"node_modules/@types/dompurify": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz",
"integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/trusted-types": "*"
}
},
"node_modules/@types/estree": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.5.tgz",
@@ -4115,6 +4128,13 @@
"meshoptimizer": "~0.18.1"
}
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"devOptional": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "2.0.11",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
@@ -6605,6 +6625,15 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dompurify": {
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.5.tgz",
"integrity": "sha512-mLPd29uoRe9HpvwP2TxClGQBzGXeEC/we/q+bFlmPPmj2p2Ugl3r6ATu/UU1v77DXNcehiBg9zsr1dREyA/dJQ==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/domutils": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/domutils/-/domutils-2.8.0.tgz",
@@ -10085,6 +10114,18 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/marked": {
"version": "15.0.11",
"resolved": "https://registry.npmjs.org/marked/-/marked-15.0.11.tgz",
"integrity": "sha512-1BEXAU2euRCG3xwgLVT1y0xbJEld1XOrmRJpUwRCcy7rxhSCwMrmEu9LXoPhHSCJG41V7YcQ2mjKRr5BA3ITIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/mdast-util-find-and-replace": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-2.2.2.tgz",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.21.2",
"version": "1.22.0-sub.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -34,6 +34,7 @@
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.44.1",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",
"@types/lodash": "^4.17.6",
"@types/node": "^20.14.8",
@@ -74,7 +75,7 @@
"@alloc/quick-lru": "^5.2.0",
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
"@comfyorg/comfyui-electron-types": "^0.4.43",
"@comfyorg/litegraph": "^0.15.14",
"@comfyorg/litegraph": "^0.16.0-sub.7",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -91,12 +92,14 @@
"@xterm/xterm": "^5.5.0",
"algoliasearch": "^5.21.0",
"axios": "^1.8.2",
"dompurify": "^3.2.5",
"dotenv": "^16.4.5",
"firebase": "^11.6.0",
"fuse.js": "^7.0.0",
"jsondiffpatch": "^0.6.0",
"lodash": "^4.17.21",
"loglevel": "^1.9.2",
"marked": "^15.0.11",
"pinia": "^2.1.7",
"primeicons": "^7.0.0",
"primevue": "^4.2.5",

View File

@@ -1,8 +1,5 @@
<template>
<div
v-if="workflowStore.isSubgraphActive"
class="fixed top-[var(--comfy-topbar-height)] left-[var(--sidebar-width)] p-2 subgraph-breadcrumb"
>
<div v-if="workflowStore.isSubgraphActive" class="p-2 subgraph-breadcrumb">
<Breadcrumb
class="bg-transparent"
:home="home"
@@ -14,28 +11,30 @@
</template>
<script setup lang="ts">
import { useEventListener, whenever } from '@vueuse/core'
import { useEventListener } from '@vueuse/core'
import Breadcrumb from 'primevue/breadcrumb'
import type { MenuItem, MenuItemCommandEvent } from 'primevue/menuitem'
import { computed } from 'vue'
import { useWorkflowService } from '@/services/workflowService'
import { useCanvasStore } from '@/stores/graphStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { useWorkflowStore } from '@/stores/workflowStore'
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
const navigationStore = useSubgraphNavigationStore()
const workflowName = computed(() => workflowStore.activeWorkflow?.filename)
const items = computed(() => {
if (!workflowStore.subgraphNamePath.length) return []
if (!navigationStore.navigationStack.length) return []
return workflowStore.subgraphNamePath.map<MenuItem>((name) => ({
label: name,
command: async () => {
const workflow = workflowStore.getWorkflowByPath(name)
if (workflow) await workflowService.openWorkflow(workflow)
return navigationStore.navigationStack.map<MenuItem>((subgraph) => ({
label: subgraph.name,
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(subgraph)
}
}))
})
@@ -43,7 +42,7 @@ const items = computed(() => {
const home = computed(() => ({
label: workflowName.value,
icon: 'pi pi-home',
command: async () => {
command: () => {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
@@ -55,14 +54,17 @@ const handleItemClick = (event: MenuItemCommandEvent) => {
event.item.command?.(event)
}
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
// Escape exits from the current subgraph.
useEventListener(document, 'keydown', (event) => {
if (event.key === 'Escape') {
const canvas = useCanvasStore().getCanvas()
if (!canvas.graph) throw new TypeError('Canvas has no graph')
canvas.setGraph(
navigationStore.navigationStack.at(-2) ?? canvas.graph.rootGraph
)
}
)
})
</script>
<style>

View File

@@ -21,16 +21,14 @@ import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useCanvasStore } from '@/stores/graphStore'
const domWidgetStore = useDomWidgetStore()
const widgetStates = computed(() =>
Array.from(domWidgetStore.widgetStates.values())
)
const widgetStates = computed(() => domWidgetStore.activeWidgetStates)
const updateWidgets = () => {
const lgCanvas = canvasStore.canvas
if (!lgCanvas) return
const lowQuality = lgCanvas.low_quality
for (const widgetState of domWidgetStore.widgetStates.values()) {
for (const widgetState of widgetStates.value) {
const widget = widgetState.widget
const node = widget.node as LGraphNode

View File

@@ -12,10 +12,12 @@
<BottomPanel />
</template>
<template #graph-canvas-panel>
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
class="pointer-events-auto"
/>
<div class="absolute top-0 left-0 w-auto max-w-full pointer-events-auto">
<SecondRowWorkflowTabs
v-if="workflowTabsPosition === 'Topbar (2nd-row)'"
/>
<SubgraphBreadcrumb />
</div>
<GraphCanvasMenu v-if="canvasMenuEnabled" class="pointer-events-auto" />
</template>
</LiteGraphCanvasSplitterOverlay>
@@ -39,12 +41,11 @@
</SelectionOverlay>
<DomWidgets />
</template>
<SubgraphBreadcrumb />
</template>
<script setup lang="ts">
import type { LGraphNode } from '@comfyorg/litegraph'
import { useEventListener } from '@vueuse/core'
import { useEventListener, whenever } from '@vueuse/core'
import { computed, onMounted, ref, watch, watchEffect } from 'vue'
import LiteGraphCanvasSplitterOverlay from '@/components/LiteGraphCanvasSplitterOverlay.vue'
@@ -84,6 +85,7 @@ import { useCanvasStore } from '@/stores/graphStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
@@ -334,6 +336,16 @@ onMounted(async () => {
}
)
whenever(
() => useCanvasStore().canvas,
(canvas) => {
useEventListener(canvas.canvas, 'litegraph:set-graph', () => {
useWorkflowStore().updateActiveGraph()
})
},
{ immediate: true }
)
emit('ready')
})
</script>

View File

@@ -11,6 +11,7 @@
<BypassButton />
<PinButton />
<MaskEditorButton />
<ConvertToSubgraphButton />
<DeleteButton />
<RefreshButton />
<ExtensionCommandButton
@@ -18,6 +19,7 @@
:key="command.id"
:command="command"
/>
<HelpButton />
</Panel>
</template>
@@ -27,9 +29,11 @@ import { computed } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import ConvertToSubgraphButton from '@/components/graph/selectionToolbox/ConvertToSubgraphButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'

View File

@@ -0,0 +1,34 @@
<template>
<Button
v-show="isVisible"
v-tooltip.top="{
value: t('commands.Comfy_Graph_ConvertToSubgraph.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-box"
@click="() => commandStore.execute('Comfy.Graph.ConvertToSubgraph')"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isVisible = computed(() => {
return (
canvasStore.groupSelected ||
canvasStore.rerouteSelected ||
canvasStore.nodeSelected
)
})
</script>

View File

@@ -1,5 +1,6 @@
<template>
<Button
v-show="isDeletable"
v-tooltip.top="{
value: t('commands.Comfy_Canvas_DeleteSelectedItems.label'),
showDelay: 1000
@@ -13,10 +14,17 @@
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
const { t } = useI18n()
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isDeletable = computed(() =>
canvasStore.selectedItems.some((x) => x.removable !== false)
)
</script>

View File

@@ -0,0 +1,49 @@
<template>
<Button
v-show="nodeDef"
v-tooltip.top="{
value: $t('g.help'),
showDelay: 1000
}"
class="help-button"
text
icon="pi pi-question-circle"
severity="secondary"
@click="showHelp"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
import { useCanvasStore } from '@/stores/graphStore'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
import { isLGraphNode } from '@/utils/litegraphUtil'
const canvasStore = useCanvasStore()
const nodeDefStore = useNodeDefStore()
const sidebarTabStore = useSidebarTabStore()
const nodeHelpStore = useNodeHelpStore()
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
const nodeDef = computed<ComfyNodeDefImpl | null>(() => {
if (canvasStore.selectedItems.length !== 1) return null
const item = canvasStore.selectedItems[0]
if (!isLGraphNode(item)) return null
return nodeDefStore.fromLGraphNode(item)
})
const showHelp = () => {
const def = nodeDef.value
if (!def) return
if (sidebarTabStore.activeSidebarTabId !== nodeLibraryTabId) {
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
}
nodeHelpStore.openHelp(def)
}
</script>

View File

@@ -25,8 +25,9 @@ const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isSingleImageNode = computed(() => {
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
return nodes.length === 1 && nodes.some(isImageNode)
const { selectedItems } = canvasStore
const item = selectedItems[0]
return selectedItems.length === 1 && isLGraphNode(item) && isImageNode(item)
})
const openMaskEditor = () => {

View File

@@ -95,12 +95,14 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
return
}
disconnectOnReset = false
const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation()
})
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
if (disconnectOnReset) {
canvasStore.getCanvas().linkConnector.connectToNode(node, triggerEvent)
}
disconnectOnReset = false
// Notify changeTracker - new step should be added
useWorkflowStore().activeWorkflow?.changeTracker?.checkState()

View File

@@ -1,67 +1,124 @@
<template>
<SidebarTabTemplate
:title="$t('sideToolbar.nodeLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.newFolder')"
class="new-folder-button"
icon="pi pi-folder-plus"
text
severity="secondary"
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortOrder')"
class="sort-button"
:icon="alphabeticalSort ? 'pi pi-sort-alpha-down' : 'pi pi-sort-alt'"
text
severity="secondary"
@click="alphabeticalSort = !alphabeticalSort"
/>
</template>
<template #header>
<SearchBox
v-model:modelValue="searchQuery"
class="node-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
@search="handleSearch"
@show-filter="($event) => searchFilter?.toggle($event)"
@remove-filter="onRemoveFilter"
/>
<div class="h-full">
<SidebarTabTemplate
v-if="!isHelpOpen"
:title="$t('sideToolbar.nodeLibrary')"
class="bg-[var(--p-tree-background)]"
>
<template #tool-buttons>
<Button
v-tooltip.bottom="$t('g.newFolder')"
class="new-folder-button"
icon="pi pi-folder-plus"
text
severity="secondary"
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupBy')"
:icon="selectedGroupingIcon"
text
severity="secondary"
@click="groupingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortMode')"
:icon="selectedSortingIcon"
text
severity="secondary"
@click="sortingPopover?.toggle($event)"
/>
<Button
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
icon="pi pi-refresh"
text
severity="secondary"
@click="resetOrganization"
/>
<Popover ref="groupingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in groupingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedGroupingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectGrouping(option.id)"
/>
</div>
</Popover>
<Popover ref="sortingPopover">
<div class="flex flex-col gap-1 p-2">
<Button
v-for="option in sortingOptions"
:key="option.id"
:icon="option.icon"
:label="$t(option.label)"
text
:severity="
selectedSortingId === option.id ? 'primary' : 'secondary'
"
class="justify-start"
@click="selectSorting(option.id)"
/>
</div>
</Popover>
</template>
<template #header>
<div>
<SearchBox
v-model:modelValue="searchQuery"
class="node-lib-search-box p-2 2xl:p-4"
:placeholder="$t('g.searchNodes') + '...'"
filter-icon="pi pi-filter"
:filters
@search="handleSearch"
@show-filter="($event) => searchFilter?.toggle($event)"
@remove-filter="onRemoveFilter"
/>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @add-filter="onAddFilter" />
</Popover>
</template>
<template #body>
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
/>
<Divider
v-show="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
class="m-2"
/>
<TreeExplorer
v-model:expandedKeys="expandedKeys"
class="node-lib-tree-explorer"
:root="renderedRoot"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
</template>
</TreeExplorer>
</template>
</SidebarTabTemplate>
<Popover ref="searchFilter" class="ml-[-13px]">
<NodeSearchFilter @add-filter="onAddFilter" />
</Popover>
</div>
</template>
<template #body>
<div>
<NodeBookmarkTreeExplorer
ref="nodeBookmarkTreeExplorerRef"
:filtered-node-defs="filteredNodeDefs"
:open-node-help="openHelp"
/>
<Divider
v-show="nodeBookmarkStore.bookmarks.length > 0"
type="dashed"
class="m-2"
/>
<TreeExplorer
v-model:expandedKeys="expandedKeys"
class="node-lib-tree-explorer"
:root="renderedRoot"
>
<template #node="{ node }">
<NodeTreeLeaf :node="node" :open-node-help="openHelp" />
</template>
</TreeExplorer>
</div>
</template>
</SidebarTabTemplate>
<NodeHelpPage v-else :node="currentHelpNode!" @close="closeHelp" />
</div>
<div id="node-library-node-preview-container" />
</template>
<script setup lang="ts">
import { useLocalStorage } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import Popover from 'primevue/popover'
@@ -73,24 +130,31 @@ import TreeExplorer from '@/components/common/TreeExplorer.vue'
import NodePreview from '@/components/node/NodePreview.vue'
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useLitegraphService } from '@/services/litegraphService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import {
ComfyNodeDefImpl,
buildNodeDefTree,
useNodeDefStore
} from '@/stores/nodeDefStore'
DEFAULT_GROUPING_ID,
DEFAULT_SORTING_ID,
nodeOrganizationService
} from '@/services/nodeOrganizationService'
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
import type {
GroupingStrategyId,
SortingStrategyId
} from '@/types/nodeOrganizationTypes'
import type { TreeNode } from '@/types/treeExplorerTypes'
import type { TreeExplorerNode } from '@/types/treeExplorerTypes'
import { FuseFilterWithValue } from '@/utils/fuseUtil'
import { sortedTree } from '@/utils/treeUtil'
import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue'
const nodeDefStore = useNodeDefStore()
const nodeBookmarkStore = useNodeBookmarkStore()
const nodeHelpStore = useNodeHelpStore()
const expandedKeys = ref<Record<string, boolean>>({})
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
@@ -98,13 +162,70 @@ const nodeBookmarkTreeExplorerRef = ref<InstanceType<
typeof NodeBookmarkTreeExplorer
> | null>(null)
const searchFilter = ref<InstanceType<typeof Popover> | null>(null)
const alphabeticalSort = ref(false)
const groupingPopover = ref<InstanceType<typeof Popover> | null>(null)
const sortingPopover = ref<InstanceType<typeof Popover> | null>(null)
const selectedGroupingId = useLocalStorage<GroupingStrategyId>(
'Comfy.NodeLibrary.GroupBy',
DEFAULT_GROUPING_ID
)
const selectedSortingId = useLocalStorage<SortingStrategyId>(
'Comfy.NodeLibrary.SortBy',
DEFAULT_SORTING_ID
)
const searchQuery = ref<string>('')
const { currentHelpNode, isHelpOpen } = storeToRefs(nodeHelpStore)
const { openHelp, closeHelp } = nodeHelpStore
const groupingOptions = computed(() =>
nodeOrganizationService.getGroupingStrategies().map((strategy) => ({
id: strategy.id,
label: strategy.label,
icon: strategy.icon
}))
)
const sortingOptions = computed(() =>
nodeOrganizationService.getSortingStrategies().map((strategy) => ({
id: strategy.id,
label: strategy.label,
icon: strategy.icon
}))
)
const selectedGroupingIcon = computed(() =>
nodeOrganizationService.getGroupingIcon(selectedGroupingId.value)
)
const selectedSortingIcon = computed(() =>
nodeOrganizationService.getSortingIcon(selectedSortingId.value)
)
const selectGrouping = (groupingId: string) => {
selectedGroupingId.value = groupingId as GroupingStrategyId
groupingPopover.value?.hide()
}
const selectSorting = (sortingId: string) => {
selectedSortingId.value = sortingId as SortingStrategyId
sortingPopover.value?.hide()
}
const resetOrganization = () => {
selectedGroupingId.value = DEFAULT_GROUPING_ID
selectedSortingId.value = DEFAULT_SORTING_ID
}
const root = computed(() => {
const root = filteredRoot.value || nodeDefStore.nodeTree
return alphabeticalSort.value ? sortedTree(root, { groupLeaf: true }) : root
// Determine which nodes to use
const nodes =
filteredNodeDefs.value.length > 0
? filteredNodeDefs.value
: nodeDefStore.visibleNodeDefs
// Use the service to organize nodes
return nodeOrganizationService.organizeNodes(nodes, {
groupBy: selectedGroupingId.value,
sortBy: selectedSortingId.value
})
})
const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
@@ -144,12 +265,6 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
})
const filteredNodeDefs = ref<ComfyNodeDefImpl[]>([])
const filteredRoot = computed<TreeNode | null>(() => {
if (!filteredNodeDefs.value.length) {
return null
}
return buildNodeDefTree(filteredNodeDefs.value)
})
const filters: Ref<
(SearchFilter & { filter: FuseFilterWithValue<ComfyNodeDefImpl, string> })[]
> = ref([])
@@ -175,8 +290,10 @@ const handleSearch = async (query: string) => {
)
await nextTick()
// @ts-expect-error fixme ts strict error
expandNode(filteredRoot.value)
// Expand the search results tree
if (filteredNodeDefs.value.length > 0) {
expandNode(root.value)
}
}
const onAddFilter = async (

View File

@@ -9,7 +9,7 @@
<NodeTreeFolder :node="node" />
</template>
<template #node="{ node }">
<NodeTreeLeaf :node="node" />
<NodeTreeLeaf :node="node" :open-node-help="props.openNodeHelp" />
</template>
</TreeExplorer>
@@ -43,6 +43,7 @@ import type {
const props = defineProps<{
filteredNodeDefs: ComfyNodeDefImpl[]
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
}>()
const expandedKeys = ref<Record<string, boolean>>({})

View File

@@ -0,0 +1,230 @@
<template>
<div class="flex flex-col h-full bg-[var(--p-tree-background)] overflow-auto">
<div
class="px-3 py-2 flex items-center border-b border-[var(--p-divider-color)]"
>
<Button
v-tooltip.bottom="$t('g.back')"
icon="pi pi-arrow-left"
text
severity="secondary"
@click="$emit('close')"
/>
<span class="ml-2 font-semibold">{{ node.display_name }}</span>
</div>
<div class="p-4 flex-grow node-help-content max-w-[600px] mx-auto">
<ProgressSpinner
v-if="isLoading"
class="m-auto"
aria-label="Loading help"
/>
<!-- Markdown fetched successfully -->
<div
v-else-if="!error"
class="markdown-content"
v-html="renderedHelpHtml"
/>
<!-- Fallback: markdown not found or fetch error -->
<div v-else class="text-sm space-y-6 fallback-content">
<p v-if="node.description">
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
</p>
<div v-if="inputList.length">
<p>
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
</p>
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="input in inputList" :key="input.name">
<td>
<code>{{ input.name }}</code>
</td>
<td>{{ input.type }}</td>
<td>{{ input.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
<div v-if="outputList.length">
<p>
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
</p>
<table>
<thead>
<tr>
<th>{{ $t('g.name') }}</th>
<th>{{ $t('nodeHelpPage.type') }}</th>
<th>{{ $t('g.description') }}</th>
</tr>
</thead>
<tbody>
<tr v-for="output in outputList" :key="output.name">
<td>
<code>{{ output.name }}</code>
</td>
<td>{{ output.type }}</td>
<td>{{ output.tooltip || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { storeToRefs } from 'pinia'
import Button from 'primevue/button'
import ProgressSpinner from 'primevue/progressspinner'
import { computed } from 'vue'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
const nodeHelpStore = useNodeHelpStore()
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
defineEmits<{
(e: 'close'): void
}>()
const inputList = computed(() =>
Object.values(node.inputs).map((spec) => ({
name: spec.name,
type: spec.type,
tooltip: spec.tooltip || ''
}))
)
const outputList = computed(() =>
node.outputs.map((spec) => ({
name: spec.name,
type: spec.type,
tooltip: spec.tooltip || ''
}))
)
</script>
<style scoped lang="postcss">
.node-help-content :deep(:is(img, video)) {
@apply max-w-full h-auto block mb-4;
}
.markdown-content,
.fallback-content {
@apply text-sm;
}
.markdown-content :deep(h1),
.fallback-content h1 {
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h2),
.fallback-content h2 {
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h3),
.fallback-content h3 {
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(h4),
.markdown-content :deep(h5),
.markdown-content :deep(h6),
.fallback-content h4,
.fallback-content h5,
.fallback-content h6 {
@apply mt-8 mb-4 first:mt-0;
}
.markdown-content :deep(td),
.fallback-content td {
color: var(--drag-text);
}
.markdown-content :deep(a),
.fallback-content a {
color: var(--drag-text);
text-decoration: underline;
}
.markdown-content :deep(th),
.fallback-content th {
color: var(--fg-color);
}
.markdown-content :deep(ul),
.markdown-content :deep(ol),
.fallback-content ul,
.fallback-content ol {
@apply pl-8 my-2;
}
.markdown-content :deep(ul ul),
.markdown-content :deep(ol ol),
.markdown-content :deep(ul ol),
.markdown-content :deep(ol ul),
.fallback-content ul ul,
.fallback-content ol ol,
.fallback-content ul ol,
.fallback-content ol ul {
@apply pl-6 my-2;
}
.markdown-content :deep(li),
.fallback-content li {
@apply my-1;
}
.markdown-content :deep(*:first-child),
.fallback-content > *:first-child {
@apply mt-0;
}
.markdown-content :deep(code),
.fallback-content code {
@apply text-[var(--error-text)] bg-[var(--content-bg)] rounded px-1 py-0.5;
}
.markdown-content :deep(table),
.fallback-content table {
@apply w-full border-collapse;
}
.markdown-content :deep(th),
.markdown-content :deep(td),
.fallback-content th,
.fallback-content td {
@apply px-2 py-2;
}
.markdown-content :deep(tr),
.fallback-content tr {
border-bottom: 1px solid var(--content-bg);
}
.markdown-content :deep(tr:last-child),
.fallback-content tr:last-child {
border-bottom: none;
}
.markdown-content :deep(thead),
.fallback-content thead {
border-bottom: 1px solid var(--p-text-color);
}
</style>

View File

@@ -22,6 +22,15 @@
severity="secondary"
@click.stop="toggleBookmark"
/>
<Button
v-tooltip.bottom="$t('g.learnMore')"
class="help-button"
size="small"
icon="pi pi-question"
text
severity="secondary"
@click.stop="props.openNodeHelp(nodeDef)"
/>
</template>
</TreeExplorerTreeNode>
@@ -54,6 +63,7 @@ import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
const props = defineProps<{
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
}>()
// Note: node.data should be present for leaf nodes.

View File

@@ -63,7 +63,7 @@ describe('CompareSliderThumbnail', () => {
it('positions slider based on default value', () => {
const wrapper = mountThumbnail()
const divider = wrapper.find('.bg-white\\/30')
expect(divider.attributes('style')).toContain('left: 21%')
expect(divider.attributes('style')).toContain('left: 50%')
})
it('passes isHovered prop to BaseThumbnail', () => {

View File

@@ -38,7 +38,7 @@ import { ref, watch } from 'vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const SLIDER_START_POSITION = 21
const SLIDER_START_POSITION = 50
const { baseImageSrc, overlayImageSrc, isHovered, isVideo } = defineProps<{
baseImageSrc: string

View File

@@ -50,9 +50,11 @@ vi.mock('@/composables/auth/useCurrentUser', () => ({
}))
// Mock the useFirebaseAuthActions composable
const mockLogout = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined)
fetchBalance: vi.fn().mockResolvedValue(undefined),
logout: mockLogout
}))
}))
@@ -100,8 +102,7 @@ describe('CurrentUserPopover', () => {
global: {
plugins: [i18n],
stubs: {
Divider: true,
Button: true
Divider: true
}
}
})
@@ -114,6 +115,18 @@ describe('CurrentUserPopover', () => {
expect(wrapper.text()).toContain('test@example.com')
})
it('renders logout button with correct props', () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[1]
// Check that logout button has correct props
expect(logoutButton.props('label')).toBe('Log Out')
expect(logoutButton.props('icon')).toBe('pi pi-sign-out')
})
it('opens user settings and emits close event when settings button is clicked', async () => {
const wrapper = mountComponent()
@@ -132,12 +145,30 @@ describe('CurrentUserPopover', () => {
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('calls logout function and emits close event when logout button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the logout button (second one)
const buttons = wrapper.findAllComponents(Button)
const logoutButton = buttons[1]
// Click the logout button
await logoutButton.trigger('click')
// Verify logout was called
expect(mockLogout).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the API pricing button (second one)
// Find all buttons and get the API pricing button (third one now)
const buttons = wrapper.findAllComponents(Button)
const apiPricingButton = buttons[1]
const apiPricingButton = buttons[2]
// Click the API pricing button
await apiPricingButton.trigger('click')

View File

@@ -37,6 +37,18 @@
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('auth.signOut.signOut')"
icon="pi pi-sign-out"
text
fluid
severity="secondary"
@click="handleLogout"
/>
<Divider class="my-2" />
<Button
class="justify-start"
:label="$t('credits.apiPricing')"
@@ -90,6 +102,11 @@ const handleTopUp = () => {
emit('close')
}
const handleLogout = async () => {
await authActions.logout()
emit('close')
}
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
emit('close')

View File

@@ -1,5 +1,5 @@
<template>
<div class="absolute top-0 left-0 w-auto max-w-full">
<div class="w-auto max-w-full">
<WorkflowTabs />
</div>
</template>

View File

@@ -17,7 +17,7 @@ import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
import { useWorkflowService } from '@/services/workflowService'
import type { ComfyCommand } from '@/stores/commandStore'
import { useTitleEditorStore } from '@/stores/graphStore'
import { useCanvasStore, useTitleEditorStore } from '@/stores/graphStore'
import { useQueueSettingsStore, useQueueStore } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
@@ -34,6 +34,7 @@ export function useCoreCommands(): ComfyCommand[] {
const colorPaletteStore = useColorPaletteStore()
const firebaseAuthActions = useFirebaseAuthActions()
const toastStore = useToastStore()
const canvasStore = useCanvasStore()
const getTracker = () => workflowStore.activeWorkflow?.changeTracker
const getSelectedNodes = (): LGraphNode[] => {
@@ -673,6 +674,30 @@ export function useCoreCommands(): ComfyCommand[] {
function: async () => {
await firebaseAuthActions.logout()
}
},
{
id: 'Comfy.Graph.ConvertToSubgraph',
icon: 'pi pi-sitemap',
label: 'Convert Selection to Subgraph',
versionAdded: '1.20.1',
function: () => {
const canvas = canvasStore.getCanvas()
const graph = canvas.subgraph ?? canvas.graph
if (!graph) throw new TypeError('Canvas has no graph or subgraph set.')
const res = graph.convertToSubgraph(canvas.selectedItems)
if (!res) {
toastStore.add({
severity: 'error',
summary: t('toastMessages.cannotCreateSubgraph'),
detail: t('toastMessages.failedToConvertToSubgraph'),
life: 3000
})
return
}
const { node } = res
canvas.select(node)
}
}
]

View File

@@ -173,5 +173,13 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
key: 'f'
},
commandId: 'Workspace.ToggleFocusMode'
},
{
combo: {
key: 'e',
ctrl: true,
shift: true
},
commandId: 'Comfy.Graph.ConvertToSubgraph'
}
]

View File

@@ -1,4 +1,4 @@
import { LGraphCanvas, LiteGraph } from '@comfyorg/litegraph'
import { LiteGraph } from '@comfyorg/litegraph'
import { LGraphNode, type NodeId } from '@comfyorg/litegraph/dist/LGraphNode'
import { t } from '@/i18n'
@@ -1583,57 +1583,6 @@ export class GroupNodeHandler {
}
}
function addConvertToGroupOptions() {
// @ts-expect-error fixme ts strict error
function addConvertOption(options, index) {
const selected = Object.values(app.canvas.selected_nodes ?? {})
const disabled =
selected.length < 2 ||
selected.find((n) => GroupNodeHandler.isGroupNode(n))
options.splice(index, null, {
content: `Convert to Group Node`,
disabled,
callback: convertSelectedNodesToGroupNode
})
}
// @ts-expect-error fixme ts strict error
function addManageOption(options, index) {
const groups = app.graph.extra?.groupNodes
const disabled = !groups || !Object.keys(groups).length
options.splice(index, null, {
content: `Manage Group Nodes`,
disabled,
callback: () => manageGroupNodes()
})
}
// Add to canvas
const getCanvasMenuOptions = LGraphCanvas.prototype.getCanvasMenuOptions
LGraphCanvas.prototype.getCanvasMenuOptions = function () {
// @ts-expect-error fixme ts strict error
const options = getCanvasMenuOptions.apply(this, arguments)
const index = options.findIndex((o) => o?.content === 'Add Group')
const insertAt = index === -1 ? options.length - 1 : index + 2
addConvertOption(options, insertAt)
addManageOption(options, insertAt + 1)
return options
}
// Add to nodes
const getNodeMenuOptions = LGraphCanvas.prototype.getNodeMenuOptions
LGraphCanvas.prototype.getNodeMenuOptions = function (node) {
// @ts-expect-error fixme ts strict error
const options = getNodeMenuOptions.apply(this, arguments)
if (!GroupNodeHandler.isGroupNode(node)) {
const index = options.findIndex((o) => o?.content === 'Properties')
const insertAt = index === -1 ? options.length - 1 : index
addConvertOption(options, insertAt)
}
return options
}
}
const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
for (const node of nodes) {
if (typeof node.type === 'string' && node.type.startsWith('workflow/')) {
@@ -1723,9 +1672,6 @@ const ext: ComfyExtension = {
}
}
],
setup() {
addConvertToGroupOptions()
},
async beforeConfigureGraph(
graphData: ComfyWorkflowJSON,
missingNodeTypes: string[]

View File

@@ -98,6 +98,9 @@
"Comfy_Feedback": {
"label": "Give Feedback"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convert Selection to Subgraph"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Fit Group To Contents"
},

View File

@@ -30,6 +30,7 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"help": "Help",
"loading": "Loading",
"findIssues": "Find Issues",
"reportIssue": "Send Report",
@@ -416,7 +417,23 @@
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
"nodeLibraryTab": {
"sortOrder": "Sort Order"
"groupBy": "Group By",
"sortMode": "Sort Mode",
"resetView": "Reset View to Default",
"groupStrategies": {
"category": "Category",
"categoryDesc": "Group by node category",
"module": "Module",
"moduleDesc": "Group by module source",
"source": "Source",
"sourceDesc": "Group by source type (Core, Custom, API)"
},
"sortBy": {
"original": "Original",
"originalDesc": "Keep original order",
"alphabetical": "Alphabetical",
"alphabeticalDesc": "Sort alphabetically within groups"
}
},
"modelLibrary": "Model Library",
"downloads": "Downloads",
@@ -794,6 +811,7 @@
"Export": "Export",
"Export (API)": "Export (API)",
"Give Feedback": "Give Feedback",
"Convert Selection to Subgraph": "Convert Selection to Subgraph",
"Fit Group To Contents": "Fit Group To Contents",
"Group Selected Nodes": "Group Selected Nodes",
"Convert selected nodes to group node": "Convert selected nodes to group node",
@@ -1274,7 +1292,9 @@
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
"nothingSelected": "Nothing selected"
"nothingSelected": "Nothing selected",
"cannotCreateSubgraph": "Cannot create subgraph",
"failedToConvertToSubgraph": "Failed to convert items to subgraph"
},
"auth": {
"apiKey": {
@@ -1407,5 +1427,13 @@
"cancelEditTooltip": "Cancel edit",
"copiedTooltip": "Copied",
"copyTooltip": "Copy message to clipboard"
},
"nodeHelpPage": {
"inputs": "Inputs",
"outputs": "Outputs",
"type": "Type",
"moreHelp": "For more help, visit the",
"documentationPage": "documentation page",
"loadError": "Failed to load help: {error}"
}
}

View File

@@ -98,6 +98,9 @@
"Comfy_Feedback": {
"label": "Dar retroalimentación"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir selección en subgrafo"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajustar grupo al contenido"
},

View File

@@ -294,6 +294,7 @@
"findIssues": "Encontrar problemas",
"firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menú > Usar nuevo menú > Desactivado\" para restaurar la antigua interfaz.",
"goToNode": "Ir al nodo",
"help": "Ayuda",
"icon": "Icono",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageUrl": "URL de la imagen",
@@ -692,6 +693,7 @@
"ComfyUI Forum": "Foro de ComfyUI",
"ComfyUI Issues": "Problemas de ComfyUI",
"Contact Support": "Contactar soporte",
"Convert Selection to Subgraph": "Convertir selección en subgrafo",
"Convert selected nodes to group node": "Convertir nodos seleccionados en nodo de grupo",
"Custom Nodes Manager": "Gestor de nodos personalizados",
"Delete Selected Items": "Eliminar elementos seleccionados",
@@ -836,6 +838,14 @@
"video": "video",
"video_models": "modelos_de_video"
},
"nodeHelpPage": {
"documentationPage": "página de documentación",
"inputs": "Entradas",
"loadError": "Error al cargar la ayuda: {error}",
"moreHelp": "Para más ayuda, visita la",
"outputs": "Salidas",
"type": "Tipo"
},
"nodeTemplates": {
"enterName": "Introduzca el nombre",
"saveAsTemplate": "Guardar como plantilla"
@@ -1065,7 +1075,23 @@
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
"nodeLibrary": "Biblioteca de nodos",
"nodeLibraryTab": {
"sortOrder": "Orden de clasificación"
"groupBy": "Agrupar por",
"groupStrategies": {
"category": "Categoría",
"categoryDesc": "Agrupar por categoría de nodo",
"module": "Módulo",
"moduleDesc": "Agrupar por fuente del módulo",
"source": "Fuente",
"sourceDesc": "Agrupar por tipo de fuente (Core, Custom, API)"
},
"resetView": "Restablecer vista a la predeterminada",
"sortBy": {
"alphabetical": "Alfabético",
"alphabeticalDesc": "Ordenar alfabéticamente dentro de los grupos",
"original": "Original",
"originalDesc": "Mantener el orden original"
},
"sortMode": "Modo de ordenación"
},
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
"queue": "Cola",
@@ -1331,6 +1357,7 @@
"title": "Comienza con una Plantilla"
},
"toastMessages": {
"cannotCreateSubgraph": "No se puede crear el subgrafo",
"couldNotDetermineFileType": "No se pudo determinar el tipo de archivo",
"dropFileError": "No se puede procesar el elemento soltado: {error}",
"emptyCanvas": "Lienzo vacío",
@@ -1339,6 +1366,7 @@
"errorSaveSetting": "Error al guardar la configuración {id}: {err}",
"failedToAccessBillingPortal": "No se pudo acceder al portal de facturación: {error}",
"failedToApplyTexture": "Error al aplicar textura",
"failedToConvertToSubgraph": "No se pudo convertir los elementos en subgrafo",
"failedToCreateCustomer": "No se pudo crear el cliente: {error}",
"failedToDownloadFile": "Error al descargar el archivo",
"failedToExportModel": "Error al exportar modelo como {format}",

View File

@@ -98,6 +98,9 @@
"Comfy_Feedback": {
"label": "Retour d'information"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Convertir la sélection en sous-graphe"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Ajuster le groupe au contenu"
},

View File

@@ -294,6 +294,7 @@
"findIssues": "Trouver des problèmes",
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
"goToNode": "Aller au nœud",
"help": "Aide",
"icon": "Icône",
"imageFailedToLoad": "Échec du chargement de l'image",
"imageUrl": "URL de l'image",
@@ -692,6 +693,7 @@
"ComfyUI Forum": "Forum ComfyUI",
"ComfyUI Issues": "Problèmes de ComfyUI",
"Contact Support": "Contacter le support",
"Convert Selection to Subgraph": "Convertir la sélection en sous-graphe",
"Convert selected nodes to group node": "Convertir les nœuds sélectionnés en nœud de groupe",
"Custom Nodes Manager": "Gestionnaire de Nœuds Personnalisés",
"Delete Selected Items": "Supprimer les éléments sélectionnés",
@@ -836,6 +838,14 @@
"video": "vidéo",
"video_models": "modèles_vidéo"
},
"nodeHelpPage": {
"documentationPage": "page de documentation",
"inputs": "Entrées",
"loadError": "Échec du chargement de laide : {error}",
"moreHelp": "Pour plus d'aide, visitez la",
"outputs": "Sorties",
"type": "Type"
},
"nodeTemplates": {
"enterName": "Entrez le nom",
"saveAsTemplate": "Enregistrer comme modèle"
@@ -1065,7 +1075,23 @@
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
"nodeLibrary": "Bibliothèque de nœuds",
"nodeLibraryTab": {
"sortOrder": "Ordre de tri"
"groupBy": "Grouper par",
"groupStrategies": {
"category": "Catégorie",
"categoryDesc": "Grouper par catégorie de nœud",
"module": "Module",
"moduleDesc": "Grouper par source du module",
"source": "Source",
"sourceDesc": "Grouper par type de source (Core, Custom, API)"
},
"resetView": "Réinitialiser la vue par défaut",
"sortBy": {
"alphabetical": "Alphabétique",
"alphabeticalDesc": "Trier alphabétiquement dans les groupes",
"original": "Original",
"originalDesc": "Conserver l'ordre d'origine"
},
"sortMode": "Mode de tri"
},
"openWorkflow": "Ouvrir le flux de travail dans le système de fichiers local",
"queue": "File d'attente",
@@ -1331,6 +1357,7 @@
"title": "Commencez avec un modèle"
},
"toastMessages": {
"cannotCreateSubgraph": "Impossible de créer le sous-graphe",
"couldNotDetermineFileType": "Impossible de déterminer le type de fichier",
"dropFileError": "Impossible de traiter l'élément déposé : {error}",
"emptyCanvas": "Toile vide",
@@ -1339,6 +1366,7 @@
"errorSaveSetting": "Erreur lors de l'enregistrement du paramètre {id}: {err}",
"failedToAccessBillingPortal": "Échec de l'accès au portail de facturation : {error}",
"failedToApplyTexture": "Échec de l'application de la texture",
"failedToConvertToSubgraph": "Échec de la conversion des éléments en sous-graphe",
"failedToCreateCustomer": "Échec de la création du client : {error}",
"failedToDownloadFile": "Échec du téléchargement du fichier",
"failedToExportModel": "Échec de l'exportation du modèle en {format}",

View File

@@ -98,6 +98,9 @@
"Comfy_Feedback": {
"label": "フィードバック"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "選択範囲をサブグラフに変換"
},
"Comfy_Graph_FitGroupToContents": {
"label": "グループを内容に合わせて調整"
},

View File

@@ -294,6 +294,7 @@
"findIssues": "問題を見つける",
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
"goToNode": "ノードに移動",
"help": "ヘルプ",
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imageUrl": "画像URL",
@@ -692,6 +693,7 @@
"ComfyUI Forum": "ComfyUI フォーラム",
"ComfyUI Issues": "ComfyUIの問題",
"Contact Support": "サポートに連絡",
"Convert Selection to Subgraph": "選択範囲をサブグラフに変換",
"Convert selected nodes to group node": "選択したノードをグループノードに変換",
"Custom Nodes Manager": "カスタムノードマネージャ",
"Delete Selected Items": "選択したアイテムを削除",
@@ -836,6 +838,14 @@
"video": "ビデオ",
"video_models": "ビデオモデル"
},
"nodeHelpPage": {
"documentationPage": "ドキュメントページ",
"inputs": "入力",
"loadError": "ヘルプの読み込みに失敗しました: {error}",
"moreHelp": "さらに詳しい情報は、",
"outputs": "出力",
"type": "タイプ"
},
"nodeTemplates": {
"enterName": "名前を入力",
"saveAsTemplate": "テンプレートとして保存"
@@ -1065,7 +1075,23 @@
"newBlankWorkflow": "新しい空のワークフローを作成",
"nodeLibrary": "ノードライブラリ",
"nodeLibraryTab": {
"sortOrder": "並び順"
"groupBy": "グループ化",
"groupStrategies": {
"category": "カテゴリ",
"categoryDesc": "ノードカテゴリでグループ化",
"module": "モジュール",
"moduleDesc": "モジュールソースでグループ化",
"source": "ソース",
"sourceDesc": "ソースタイプCore、Custom、APIでグループ化"
},
"resetView": "ビューをデフォルトにリセット",
"sortBy": {
"alphabetical": "アルファベット順",
"alphabeticalDesc": "グループ内でアルファベット順に並び替え",
"original": "元の順序",
"originalDesc": "元の順序を維持"
},
"sortMode": "並び替えモード"
},
"openWorkflow": "ローカルでワークフローを開く",
"queue": "キュー",
@@ -1331,6 +1357,7 @@
"title": "テンプレートを利用して開始"
},
"toastMessages": {
"cannotCreateSubgraph": "サブグラフを作成できません",
"couldNotDetermineFileType": "ファイルタイプを判断できませんでした",
"dropFileError": "ドロップされたアイテムを処理できません: {error}",
"emptyCanvas": "キャンバスが空です",
@@ -1339,6 +1366,7 @@
"errorSaveSetting": "設定{id}の保存エラー: {err}",
"failedToAccessBillingPortal": "請求ポータルへのアクセスに失敗しました: {error}",
"failedToApplyTexture": "テクスチャの適用に失敗しました",
"failedToConvertToSubgraph": "アイテムをサブグラフに変換できませんでした",
"failedToCreateCustomer": "顧客の作成に失敗しました: {error}",
"failedToDownloadFile": "ファイルのダウンロードに失敗しました",
"failedToExportModel": "{format}としてモデルのエクスポートに失敗しました",

View File

@@ -98,6 +98,9 @@
"Comfy_Feedback": {
"label": "피드백"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "선택 영역을 서브그래프로 변환"
},
"Comfy_Graph_FitGroupToContents": {
"label": "그룹을 내용에 맞게 맞추기"
},

View File

@@ -294,6 +294,7 @@
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"goToNode": "노드로 이동",
"help": "도움말",
"icon": "아이콘",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"imageUrl": "이미지 URL",
@@ -692,6 +693,7 @@
"ComfyUI Forum": "ComfyUI 포럼",
"ComfyUI Issues": "ComfyUI 이슈 페이지",
"Contact Support": "고객 지원 문의",
"Convert Selection to Subgraph": "선택 영역을 서브그래프로 변환",
"Convert selected nodes to group node": "선택한 노드를 그룹 노드로 변환",
"Custom Nodes Manager": "사용자 정의 노드 관리자",
"Delete Selected Items": "선택한 항목 삭제",
@@ -836,6 +838,14 @@
"video": "비디오",
"video_models": "비디오 모델"
},
"nodeHelpPage": {
"documentationPage": "문서 페이지",
"inputs": "입력",
"loadError": "도움말을 불러오지 못했습니다: {error}",
"moreHelp": "더 많은 도움말은",
"outputs": "출력",
"type": "유형"
},
"nodeTemplates": {
"enterName": "이름 입력",
"saveAsTemplate": "템플릿으로 저장"
@@ -1065,7 +1075,23 @@
"newBlankWorkflow": "새 빈 워크플로 만들기",
"nodeLibrary": "노드 라이브러리",
"nodeLibraryTab": {
"sortOrder": "정렬 순서"
"groupBy": "그룹 기준",
"groupStrategies": {
"category": "카테고리",
"categoryDesc": "노드 카테고리별로 그룹화",
"module": "모듈",
"moduleDesc": "모듈 소스별로 그룹화",
"source": "소스",
"sourceDesc": "소스 유형(Core, Custom, API)별로 그룹화"
},
"resetView": "기본 보기로 재설정",
"sortBy": {
"alphabetical": "알파벳순",
"alphabeticalDesc": "그룹 내에서 알파벳순으로 정렬",
"original": "원본 순서",
"originalDesc": "원래 순서를 유지"
},
"sortMode": "정렬 방식"
},
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
"queue": "실행 대기열",
@@ -1331,6 +1357,7 @@
"title": "템플릿으로 시작하기"
},
"toastMessages": {
"cannotCreateSubgraph": "서브그래프를 생성할 수 없습니다",
"couldNotDetermineFileType": "파일 유형을 결정할 수 없습니다",
"dropFileError": "드롭된 항목을 처리할 수 없습니다: {error}",
"emptyCanvas": "빈 캔버스",
@@ -1339,6 +1366,7 @@
"errorSaveSetting": "설정 {id} 저장 오류: {err}",
"failedToAccessBillingPortal": "결제 포털에 접근하지 못했습니다: {error}",
"failedToApplyTexture": "텍스처 적용에 실패했습니다",
"failedToConvertToSubgraph": "항목을 서브그래프로 변환하지 못했습니다",
"failedToCreateCustomer": "고객 생성에 실패했습니다: {error}",
"failedToDownloadFile": "파일 다운로드에 실패했습니다",
"failedToExportModel": "{format} 형식으로 모델 내보내기에 실패했습니다",

View File

@@ -98,6 +98,9 @@
"Comfy_Feedback": {
"label": "Обратная связь"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "Преобразовать выделенное в подграф"
},
"Comfy_Graph_FitGroupToContents": {
"label": "Подогнать группу к содержимому"
},

View File

@@ -294,6 +294,7 @@
"findIssues": "Найти проблемы",
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
"goToNode": "Перейти к ноде",
"help": "Помощь",
"icon": "Иконка",
"imageFailedToLoad": "Не удалось загрузить изображение",
"imageUrl": "URL изображения",
@@ -692,6 +693,7 @@
"ComfyUI Forum": "Форум ComfyUI",
"ComfyUI Issues": "Проблемы ComfyUI",
"Contact Support": "Связаться с поддержкой",
"Convert Selection to Subgraph": "Преобразовать выделенное в подграф",
"Convert selected nodes to group node": "Преобразовать выбранные ноды в групповую ноду",
"Custom Nodes Manager": "Менеджер Пользовательских Узлов",
"Delete Selected Items": "Удалить выбранные элементы",
@@ -836,6 +838,14 @@
"video": "видео",
"video_models": "видеомодели"
},
"nodeHelpPage": {
"documentationPage": "страницу документации",
"inputs": "Входы",
"loadError": "Не удалось загрузить справку: {error}",
"moreHelp": "Для получения дополнительной помощи посетите",
"outputs": "Выходы",
"type": "Тип"
},
"nodeTemplates": {
"enterName": "Введите название",
"saveAsTemplate": "Сохранить как шаблон"
@@ -1065,7 +1075,23 @@
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
"nodeLibrary": "Библиотека нод",
"nodeLibraryTab": {
"sortOrder": "Порядок сортировки"
"groupBy": "Группировать по",
"groupStrategies": {
"category": "Категория",
"categoryDesc": "Группировать по категории узла",
"module": "Модуль",
"moduleDesc": "Группировать по источнику модуля",
"source": "Источник",
"sourceDesc": "Группировать по типу источника (Core, Custom, API)"
},
"resetView": "Сбросить вид по умолчанию",
"sortBy": {
"alphabetical": "По алфавиту",
"alphabeticalDesc": "Сортировать по алфавиту внутри групп",
"original": "Оригинальный порядок",
"originalDesc": "Сохранять исходный порядок"
},
"sortMode": "Режим сортировки"
},
"openWorkflow": "Открыть рабочий процесс в локальной файловой системе",
"queue": "Очередь",
@@ -1331,6 +1357,7 @@
"title": "Начните с шаблона"
},
"toastMessages": {
"cannotCreateSubgraph": "Невозможно создать подграф",
"couldNotDetermineFileType": "Не удалось определить тип файла",
"dropFileError": "Не удалось обработать перетаскиваемый элемент: {error}",
"emptyCanvas": "Пустой холст",
@@ -1339,6 +1366,7 @@
"errorSaveSetting": "Ошибка сохранения настройки {id}: {err}",
"failedToAccessBillingPortal": "Не удалось получить доступ к биллинговому порталу: {error}",
"failedToApplyTexture": "Не удалось применить текстуру",
"failedToConvertToSubgraph": "Не удалось преобразовать элементы в подграф",
"failedToCreateCustomer": "Не удалось создать клиента: {error}",
"failedToDownloadFile": "Не удалось скачать файл",
"failedToExportModel": "Не удалось экспортировать модель как {format}",

View File

@@ -98,6 +98,9 @@
"Comfy_Feedback": {
"label": "反馈"
},
"Comfy_Graph_ConvertToSubgraph": {
"label": "将选区转换为子图"
},
"Comfy_Graph_FitGroupToContents": {
"label": "适应节点框到内容"
},

View File

@@ -294,6 +294,7 @@
"findIssues": "查找问题",
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
"goToNode": "转到节点",
"help": "帮助",
"icon": "图标",
"imageFailedToLoad": "图像加载失败",
"imageUrl": "图片网址",
@@ -692,6 +693,7 @@
"ComfyUI Forum": "ComfyUI 论坛",
"ComfyUI Issues": "ComfyUI 问题",
"Contact Support": "联系支持",
"Convert Selection to Subgraph": "将选中内容转换为子图",
"Convert selected nodes to group node": "将选中节点转换为组节点",
"Custom Nodes Manager": "自定义节点管理器",
"Delete Selected Items": "删除选定的项目",
@@ -836,6 +838,14 @@
"video": "视频",
"video_models": "视频模型"
},
"nodeHelpPage": {
"documentationPage": "文档页面",
"inputs": "输入",
"loadError": "加载帮助失败:{error}",
"moreHelp": "如需更多帮助,请访问",
"outputs": "输出",
"type": "类型"
},
"nodeTemplates": {
"enterName": "输入名称",
"saveAsTemplate": "另存为模板"
@@ -1065,7 +1075,23 @@
"newBlankWorkflow": "创建空白工作流",
"nodeLibrary": "节点库",
"nodeLibraryTab": {
"sortOrder": "排序顺序"
"groupBy": "分组方式",
"groupStrategies": {
"category": "类别",
"categoryDesc": "按节点类别分组",
"module": "模块",
"moduleDesc": "按模块来源分组",
"source": "来源",
"sourceDesc": "按来源类型分组核心自定义API"
},
"resetView": "重置视图为默认",
"sortBy": {
"alphabetical": "字母顺序",
"alphabeticalDesc": "在分组内按字母顺序排序",
"original": "原始顺序",
"originalDesc": "保持原始顺序"
},
"sortMode": "排序模式"
},
"openWorkflow": "在本地文件系统中打开工作流",
"queue": "队列",
@@ -1331,6 +1357,7 @@
"title": "从模板开始"
},
"toastMessages": {
"cannotCreateSubgraph": "无法创建子图",
"couldNotDetermineFileType": "无法确定文件类型",
"dropFileError": "无法处理掉落的项目:{error}",
"emptyCanvas": "画布为空",
@@ -1339,6 +1366,7 @@
"errorSaveSetting": "保存设置 {id} 出错:{err}",
"failedToAccessBillingPortal": "访问账单门户失败:{error}",
"failedToApplyTexture": "应用纹理失败",
"failedToConvertToSubgraph": "无法将项目转换为子图",
"failedToCreateCustomer": "创建客户失败:{error}",
"failedToDownloadFile": "文件下载失败",
"failedToExportModel": "无法将模型导出为 {format}",

View File

@@ -41,10 +41,10 @@ const zModelFile = z.object({
const zGraphState = z
.object({
lastGroupid: z.number().optional(),
lastNodeId: z.number().optional(),
lastLinkId: z.number().optional(),
lastRerouteId: z.number().optional()
lastGroupId: z.number(),
lastNodeId: z.number(),
lastLinkId: z.number(),
lastRerouteId: z.number()
})
.passthrough()
@@ -214,6 +214,32 @@ const zComfyNode = z
})
.passthrough()
export const zSubgraphIO = zNodeInput.extend({
/** Slot ID (internal; never changes once instantiated). */
id: z.string().uuid(),
/** The data type this slot uses. Unlike nodes, this does not support legacy numeric types. */
type: z.string(),
/** Links connected to this slot, or `undefined` if not connected. An ouptut slot should only ever have one link. */
linkIds: z.array(z.number()).optional()
})
const zSubgraphInstance = z
.object({
id: zNodeId,
type: z.string().uuid(),
pos: zVector2,
size: zVector2,
flags: zFlags,
order: z.number(),
mode: z.number(),
inputs: z.array(zSubgraphIO).optional(),
outputs: z.array(zSubgraphIO).optional(),
widgets_values: zWidgetValues.optional(),
color: z.string().optional(),
bgcolor: z.string().optional()
})
.passthrough()
const zGroup = z
.object({
id: z.number().optional(),
@@ -248,9 +274,22 @@ const zExtra = z
})
.passthrough()
export const zGraphDefinitions = z.object({
subgraphs: z.lazy(() => z.array(zSubgraphDefinition))
})
export const zBaseExportableGraph = z.object({
/** Unique graph ID. Automatically generated if not provided. */
id: z.string().uuid().optional(),
revision: z.number().optional(),
config: zConfig.optional().nullable(),
/** Details of the appearance and location of subgraphs shown in this graph. Similar to */
subgraphs: z.array(zSubgraphInstance).optional()
})
/** Schema version 0.4 */
export const zComfyWorkflow = z
.object({
export const zComfyWorkflow = zBaseExportableGraph
.extend({
id: z.string().uuid().optional(),
revision: z.number().optional(),
last_node_id: zNodeId,
@@ -262,13 +301,47 @@ export const zComfyWorkflow = z
config: zConfig.optional().nullable(),
extra: zExtra.optional().nullable(),
version: z.number(),
models: z.array(zModelFile).optional()
models: z.array(zModelFile).optional(),
definitions: zGraphDefinitions.optional()
})
.passthrough()
/** Required for recursive definition of subgraphs. */
interface ComfyWorkflow1BaseType {
id?: string
revision?: number
version: 1
models?: z.infer<typeof zModelFile>[]
state: z.infer<typeof zGraphState>
}
/** Required for recursive definition of subgraphs w/ZodEffects. */
interface ComfyWorkflow1BaseInput extends ComfyWorkflow1BaseType {
groups?: z.input<typeof zGroup>[]
nodes: z.input<typeof zComfyNode>[]
links?: z.input<typeof zComfyLinkObject>[]
floatingLinks?: z.input<typeof zComfyLinkObject>[]
reroutes?: z.input<typeof zReroute>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseInput>[]
}
}
/** Required for recursive definition of subgraphs w/ZodEffects. */
interface ComfyWorkflow1BaseOutput extends ComfyWorkflow1BaseType {
groups?: z.output<typeof zGroup>[]
nodes: z.output<typeof zComfyNode>[]
links?: z.output<typeof zComfyLinkObject>[]
floatingLinks?: z.output<typeof zComfyLinkObject>[]
reroutes?: z.output<typeof zReroute>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>[]
}
}
/** Schema version 1 */
export const zComfyWorkflow1 = z
.object({
export const zComfyWorkflow1 = zBaseExportableGraph
.extend({
id: z.string().uuid().optional(),
revision: z.number().optional(),
version: z.literal(1),
@@ -280,7 +353,96 @@ export const zComfyWorkflow1 = z
floatingLinks: z.array(zComfyLinkObject).optional(),
reroutes: z.array(zReroute).optional(),
extra: zExtra.optional().nullable(),
models: z.array(zModelFile).optional()
models: z.array(zModelFile).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseOutput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => z.array(zSubgraphDefinition)
)
})
.optional()
})
.passthrough()
export const zExportedSubgraphIONode = z.object({
id: zNodeId,
bounding: z.tuple([z.number(), z.number(), z.number(), z.number()]),
pinned: z.boolean().optional()
})
export const zExposedWidget = z.object({
id: z.string(),
name: z.string()
})
interface SubgraphDefinitionBase<
T extends ComfyWorkflow1BaseInput | ComfyWorkflow1BaseOutput
> {
/** Unique graph ID. Automatically generated if not provided. */
id: string
revision: number
name: string
inputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
: z.output<typeof zExportedSubgraphIONode>
outputNode: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExportedSubgraphIONode>
: z.output<typeof zExportedSubgraphIONode>
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
inputs?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zSubgraphIO>[]
: z.output<typeof zSubgraphIO>[]
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
outputs?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zSubgraphIO>[]
: z.output<typeof zSubgraphIO>[]
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
widgets?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExposedWidget>[]
: z.output<typeof zExposedWidget>[]
definitions?: {
subgraphs: SubgraphDefinitionBase<T>[]
}
}
/** A subgraph definition `worfklow.definitions.subgraphs` */
export const zSubgraphDefinition = zComfyWorkflow1
.extend({
/** Unique graph ID. Automatically generated if not provided. */
id: z.string().uuid(),
revision: z.number(),
name: z.string(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */
inputs: z.array(zSubgraphIO).optional(),
/** Ordered list of outputs from the subgraph itself. Similar to a reroute, with the input side in the subgraph, and the output side in the graph. */
outputs: z.array(zSubgraphIO).optional(),
/** A list of node widgets displayed in the parent graph, on the subgraph object. */
widgets: z.array(zExposedWidget).optional(),
definitions: z
.object({
subgraphs: z.lazy(
(): z.ZodArray<
z.ZodType<
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>,
z.ZodTypeDef,
SubgraphDefinitionBase<ComfyWorkflow1BaseInput>
>,
'many'
> => zSubgraphDefinition.array()
)
})
.optional()
})
.passthrough()

View File

@@ -73,6 +73,7 @@ export const zComfyNodeDef = z.object({
name: z.string(),
display_name: z.string(),
description: z.string(),
help: z.string().optional(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),

View File

@@ -219,6 +219,7 @@ export const zComfyNodeDef = z.object({
name: z.string(),
display_name: z.string(),
description: z.string(),
help: z.string().optional(),
category: z.string(),
output_node: z.boolean(),
python_module: z.string(),
@@ -227,7 +228,7 @@ export const zComfyNodeDef = z.object({
/**
* Whether the node is an API node. Running API nodes requires login to
* Comfy Org account.
* https://www.comfy.org/faq
* https://docs.comfy.org/tutorials/api-nodes/overview
*/
api_node: z.boolean().optional()
})

View File

@@ -30,18 +30,14 @@ import {
isComboInputSpecV1,
isComboInputSpecV2
} from '@/schemas/nodeDefSchema'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
import { getMp3Metadata } from '@/scripts/metadata/mp3'
import { getOggMetadata } from '@/scripts/metadata/ogg'
import { getSvgMetadata } from '@/scripts/metadata/svg'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
import { useSubgraphService } from '@/services/subgraphService'
import { useWorkflowService } from '@/services/workflowService'
import { useApiKeyAuthStore } from '@/stores/apiKeyAuthStore'
import { useCommandStore } from '@/stores/commandStore'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useExecutionStore } from '@/stores/executionStore'
import { useExtensionStore } from '@/stores/extensionStore'
import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
@@ -58,6 +54,7 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import { ExtensionManager } from '@/types/extensionTypes'
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import { getFileHandler } from '@/utils/fileHandlers'
import {
executeWidgetsCallback,
fixLinkInputSlots,
@@ -67,18 +64,12 @@ import {
findLegacyRerouteNodes,
noNativeReroutes
} from '@/utils/migration/migrateReroute'
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
import { defaultGraph } from './defaultGraph'
import { pruneWidgets } from './domWidget'
import {
getFlacMetadata,
getLatentMetadata,
getPngMetadata,
getWebpMetadata,
importA1111
} from './pnginfo'
import { importA1111 } from './pnginfo'
import { $el, ComfyUI } from './ui'
import { ComfyAppMenu } from './ui/menu/index'
import { clone } from './utils'
@@ -714,25 +705,23 @@ export class ComfyApp {
}
#addAfterConfigureHandler() {
const app = this
const onConfigure = app.graph.onConfigure
app.graph.onConfigure = function (this: LGraph, ...args) {
const { graph } = this
const { onConfigure } = graph
graph.onConfigure = function (...args) {
fixLinkInputSlots(this)
// Fire callbacks before the onConfigure, this is used by widget inputs to setup the config
for (const node of app.graph.nodes) {
for (const node of graph.nodes) {
node.onGraphConfigured?.()
}
const r = onConfigure?.apply(this, args)
// Fire after onConfigure, used by primitives to generate widget using input nodes config
for (const node of app.graph.nodes) {
for (const node of graph.nodes) {
node.onAfterGraphConfigured?.()
}
pruneWidgets(this.nodes)
return r
}
}
@@ -764,6 +753,21 @@ export class ComfyApp {
this.#graph = new LGraph()
// Register the subgraph - adds type wrapper for Litegraph's `createNode` factory
this.graph.events.addEventListener('subgraph-created', (e) => {
try {
const { subgraph, data } = e.detail
useSubgraphService().registerNewSubgraph(subgraph, data)
} catch (err) {
console.error('Failed to register subgraph', err)
useToastStore().add({
severity: 'error',
summary: 'Failed to register subgraph',
detail: err instanceof Error ? err.message : String(err)
})
}
})
this.#addAfterConfigureHandler()
this.canvas = new LGraphCanvas(canvasEl, this.graph)
@@ -776,6 +780,30 @@ export class ComfyApp {
LiteGraph.alt_drag_do_clone_nodes = true
LiteGraph.macGesturesRequireMac = false
this.canvas.canvas.addEventListener<'litegraph:set-graph'>(
'litegraph:set-graph',
(e) => {
// Assertion: Not yet defined in litegraph.
const { newGraph } = e.detail
const nodeSet = new Set(newGraph.nodes)
const widgetStore = useDomWidgetStore()
// Assertions: UnwrapRef
for (const { widget } of widgetStore.activeWidgetStates) {
if (!nodeSet.has(widget.node)) {
widgetStore.deactivateWidget(widget.id)
}
}
for (const { widget } of widgetStore.inactiveWidgetStates) {
if (nodeSet.has(widget.node)) {
widgetStore.activateWidget(widget.id)
}
}
}
)
this.graph.start()
// Ensure the canvas fills the window
@@ -1012,6 +1040,7 @@ export class ComfyApp {
})
}
useWorkflowService().beforeLoadNewGraph()
useSubgraphService().loadSubgraphs(graphData)
const missingNodeTypes: MissingNodeType[] = []
const missingModels: ModelFile[] = []
@@ -1037,8 +1066,10 @@ export class ComfyApp {
}
// Collect models metadata from node
if (n.properties?.models?.length)
embeddedModels.push(...n.properties.models)
const selectedModels = getSelectedModelsMetadata(n)
if (selectedModels?.length) {
embeddedModels.push(...selectedModels)
}
}
// Merge models from the workflow's root-level 'models' field
@@ -1207,6 +1238,9 @@ export class ComfyApp {
// Allow widgets to run callbacks before a prompt has been queued
// e.g. random seed before every gen
executeWidgetsCallback(this.graph.nodes, 'beforeQueued')
for (const subgraph of this.graph.subgraphs.values()) {
executeWidgetsCallback(subgraph.nodes, 'beforeQueued')
}
const p = await this.graphToPrompt(this.graph, { queueNodeIds })
try {
@@ -1249,9 +1283,13 @@ export class ComfyApp {
executeWidgetsCallback(
p.workflow.nodes
.map((n) => this.graph.getNodeById(n.id))
.filter((n) => !!n) as LGraphNode[],
.filter((n) => !!n),
'afterQueued'
)
for (const subgraph of this.graph.subgraphs.values()) {
executeWidgetsCallback(subgraph.nodes, 'afterQueued')
}
this.canvas.draw(true, true)
await this.ui.queue.update()
}
@@ -1281,161 +1319,44 @@ export class ComfyApp {
return f.substring(0, p)
}
const fileName = removeExt(file.name)
if (file.type === 'image/png') {
const pngInfo = await getPngMetadata(file)
if (pngInfo?.workflow) {
await this.loadGraphData(
JSON.parse(pngInfo.workflow),
true,
true,
fileName
)
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
} else if (pngInfo?.parameters) {
// Note: Not putting this in `importA1111` as it is mostly not used
// by external callers, and `importA1111` has no access to `app`.
// Get the appropriate file handler for this file type
const fileHandler = getFileHandler(file)
if (!fileHandler) {
// No handler found for this file type
this.showErrorOnFileLoad(file)
return
}
try {
// Process the file using the handler
const { workflow, prompt, parameters, jsonTemplateData } =
await fileHandler(file)
if (workflow) {
// We have a workflow, load it
await this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
// We have a prompt in API format, load it
this.loadApiJson(prompt, fileName)
} else if (parameters) {
// We have A1111 parameters, import them
useWorkflowService().beforeLoadNewGraph()
importA1111(this.graph, pngInfo.parameters)
importA1111(this.graph, parameters)
useWorkflowService().afterLoadNewGraph(
fileName,
this.graph.serialize() as unknown as ComfyWorkflowJSON
)
} else if (jsonTemplateData) {
// We have template data from JSON
this.loadTemplateData(jsonTemplateData)
} else {
// No usable data found in the file
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'image/webp') {
const pngInfo = await getWebpMetadata(file)
// Support loading workflows from that webp custom node.
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'audio/mpeg') {
const { workflow, prompt } = await getMp3Metadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'audio/ogg') {
const { workflow, prompt } = await getOggMetadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'audio/flac' || file.type === 'audio/x-flac') {
const pngInfo = await getFlacMetadata(file)
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'video/webm') {
const webmInfo = await getFromWebmFile(file)
if (webmInfo.workflow) {
this.loadGraphData(webmInfo.workflow, true, true, fileName)
} else if (webmInfo.prompt) {
this.loadApiJson(webmInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'video/mp4' ||
file.name?.endsWith('.mp4') ||
file.name?.endsWith('.mov') ||
file.name?.endsWith('.m4v') ||
file.type === 'video/quicktime' ||
file.type === 'video/x-m4v'
) {
const mp4Info = await getFromIsobmffFile(file)
if (mp4Info.workflow) {
this.loadGraphData(mp4Info.workflow, true, true, fileName)
} else if (mp4Info.prompt) {
this.loadApiJson(mp4Info.prompt, fileName)
}
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
const svgInfo = await getSvgMetadata(file)
if (svgInfo.workflow) {
this.loadGraphData(svgInfo.workflow, true, true, fileName)
} else if (svgInfo.prompt) {
this.loadApiJson(svgInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'model/gltf-binary' ||
file.name?.endsWith('.glb')
) {
const gltfInfo = await getGltfBinaryMetadata(file)
if (gltfInfo.workflow) {
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
} else if (gltfInfo.prompt) {
this.loadApiJson(gltfInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'application/json' ||
file.name?.endsWith('.json')
) {
const reader = new FileReader()
reader.onload = async () => {
const readerResult = reader.result as string
const jsonContent = JSON.parse(readerResult)
if (jsonContent?.templates) {
this.loadTemplateData(jsonContent)
} else if (this.isApiJson(jsonContent)) {
this.loadApiJson(jsonContent, fileName)
} else {
await this.loadGraphData(
JSON.parse(readerResult),
true,
true,
fileName
)
}
}
reader.readAsText(file)
} else if (
file.name?.endsWith('.latent') ||
file.name?.endsWith('.safetensors')
) {
const info = await getLatentMetadata(file)
// TODO define schema to LatentMetadata
// @ts-expect-error
if (info.workflow) {
await this.loadGraphData(
// @ts-expect-error
JSON.parse(info.workflow),
true,
true,
fileName
)
// @ts-expect-error
} else if (info.prompt) {
// @ts-expect-error
this.loadApiJson(JSON.parse(info.prompt))
} else {
this.showErrorOnFileLoad(file)
}
} else {
} catch (error) {
console.error('Error processing file:', error)
this.showErrorOnFileLoad(file)
}
}
@@ -1648,6 +1569,8 @@ export class ComfyApp {
const executionStore = useExecutionStore()
executionStore.lastNodeErrors = null
executionStore.lastExecutionError = null
useDomWidgetStore().clear()
}
clientPosToCanvasPos(pos: Vector2): Vector2 {

View File

@@ -6,6 +6,7 @@ import log from 'loglevel'
import type { ExecutedWsMessage } from '@/schemas/apiSchema'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { useExecutionStore } from '@/stores/executionStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
import { api } from './api'
@@ -37,6 +38,10 @@ export class ChangeTracker {
ds?: { scale: number; offset: [number, number] }
nodeOutputs?: Record<string, any>
private subgraphState?: {
navigation: string[]
}
constructor(
/**
* The workflow that this change tracker is tracking
@@ -67,6 +72,8 @@ export class ChangeTracker {
scale: app.canvas.ds.scale,
offset: [app.canvas.ds.offset[0], app.canvas.ds.offset[1]]
}
const navigation = useSubgraphNavigationStore().exportState()
this.subgraphState = navigation.length ? { navigation } : undefined
}
restore() {
@@ -77,6 +84,16 @@ export class ChangeTracker {
if (this.nodeOutputs) {
app.nodeOutputs = this.nodeOutputs
}
if (this.subgraphState) {
const { navigation } = this.subgraphState
useSubgraphNavigationStore().restoreState(navigation)
const activeId = navigation.at(-1)
if (activeId) {
const subgraph = app.graph.subgraphs.get(activeId)
if (subgraph) app.canvas.setGraph(subgraph)
}
}
}
updateModified() {
@@ -376,7 +393,14 @@ export class ChangeTracker {
return false
// Compare other properties normally
for (const key of ['links', 'floatingLinks', 'reroutes', 'groups']) {
for (const key of [
'links',
'floatingLinks',
'reroutes',
'groups',
'definitions',
'subgraphs'
]) {
if (!_.isEqual(a[key], b[key])) {
return false
}
@@ -392,7 +416,12 @@ export class ChangeTracker {
function sortGraphNodes(graph: ComfyWorkflowJSON) {
return {
links: graph.links,
floatingLinks: graph.floatingLinks,
reroutes: graph.reroutes,
groups: graph.groups,
extra: graph.extra,
definitions: graph.definitions,
subgraphs: graph.subgraphs,
nodes: graph.nodes.sort((a, b) => {
if (typeof a.id === 'number' && typeof b.id === 'number') {
return a.id - b.id

View File

@@ -11,7 +11,7 @@ import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { generateUUID } from '@/utils/formatUtil'
export interface BaseDOMWidget<V extends object | string>
export interface BaseDOMWidget<V extends object | string = object | string>
extends IBaseWidget<V, string, DOMWidgetOptions<V>> {
// ICustomWidget properties
type: string
@@ -330,9 +330,8 @@ LGraphNode.prototype.addDOMWidget = function <
export const pruneWidgets = (nodes: LGraphNode[]) => {
const nodeSet = new Set(nodes)
const domWidgetStore = useDomWidgetStore()
for (const widgetState of domWidgetStore.widgetStates.values()) {
const widget = widgetState.widget
if (!nodeSet.has(widget.node as LGraphNode)) {
for (const { widget } of domWidgetStore.widgetStates.values()) {
if (!nodeSet.has(widget.node)) {
domWidgetStore.unregisterWidget(widget.id)
}
}

View File

@@ -124,7 +124,19 @@ export const useAlgoliaSearchService = (
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
} = options
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__, {
hosts: [
{
url: 'search.comfy.org/api/search',
accept: 'read',
protocol: 'https'
}
],
baseHeaders: {
'X-Algolia-Application-Id': __ALGOLIA_APP_ID__,
'X-Algolia-API-Key': __ALGOLIA_API_KEY__
}
})
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
maxSize: maxCacheSize
})

View File

@@ -137,6 +137,8 @@ export const useColorPaletteService = () => {
'--bg-img',
`url('${backgroundImage}') no-repeat center /cover`
)
} else {
rootStyle.removeProperty('--bg-img')
}
}
}

View File

@@ -5,10 +5,13 @@ import {
LGraphNode,
LiteGraph,
RenderShape,
type Subgraph,
SubgraphNode,
type Vector2,
createBounds
} from '@comfyorg/litegraph'
import type {
ExportedSubgraphInstance,
ISerialisableNodeInput,
ISerialisableNodeOutput,
ISerialisedNode
@@ -35,6 +38,7 @@ import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { useWidgetStore } from '@/stores/widgetStore'
import { useWorkflowStore } from '@/stores/workflowStore'
import { normalizeI18nKey } from '@/utils/formatUtil'
import {
isImageNode,
@@ -56,6 +60,260 @@ export const useLitegraphService = () => {
const widgetStore = useWidgetStore()
const canvasStore = useCanvasStore()
// TODO: Dedupe `registerNodeDef`; this should remain synchronous.
function registerSubgraphNodeDef(
nodeDefV1: ComfyNodeDefV1,
subgraph: Subgraph,
instanceData: ExportedSubgraphInstance
) {
const node = class ComfyNode extends SubgraphNode {
static comfyClass: string
static override title: string
static override category: string
static nodeData: ComfyNodeDefV1 & ComfyNodeDefV2
/**
* @internal The initial minimum size of the node.
*/
#initialMinSize = { width: 1, height: 1 }
/**
* @internal The key for the node definition in the i18n file.
*/
get #nodeKey(): string {
return `nodeDefs.${normalizeI18nKey(ComfyNode.nodeData.name)}`
}
constructor() {
super(app.graph, subgraph, instanceData)
this.#setupStrokeStyles()
this.#addInputs(ComfyNode.nodeData.inputs)
this.#addOutputs(ComfyNode.nodeData.outputs)
this.#setInitialSize()
this.serialize_widgets = true
void extensionService.invokeExtensionsAsync('nodeCreated', this)
}
/**
* @internal Setup stroke styles for the node under various conditions.
*/
#setupStrokeStyles() {
this.strokeStyles['running'] = function (this: LGraphNode) {
if (this.id == app.runningNodeId) {
return { color: '#0f0' }
}
}
this.strokeStyles['nodeError'] = function (this: LGraphNode) {
if (app.lastNodeErrors?.[this.id]?.errors) {
return { color: 'red' }
}
}
this.strokeStyles['dragOver'] = function (this: LGraphNode) {
if (app.dragOverNode?.id == this.id) {
return { color: 'dodgerblue' }
}
}
this.strokeStyles['executionError'] = function (this: LGraphNode) {
if (app.lastExecutionError?.node_id == this.id) {
return { color: '#f0f', lineWidth: 2 }
}
}
}
/**
* @internal Add input sockets to the node. (No widget)
*/
#addInputSocket(inputSpec: InputSpec) {
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(
inputSpec.widgetType ?? inputSpec.type
)
if (widgetConstructor && !inputSpec.forceInput) return
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName)
})
}
/**
* @internal Add a widget to the node. For both primitive types and custom widgets
* (unless `socketless`), an input socket is also added.
*/
#addInputWidget(inputSpec: InputSpec) {
const widgetInputSpec = { ...inputSpec }
if (inputSpec.widgetType) {
widgetInputSpec.type = inputSpec.widgetType
}
const inputName = inputSpec.name
const nameKey = `${this.#nodeKey}.inputs.${normalizeI18nKey(inputName)}.name`
const widgetConstructor = widgetStore.widgets.get(widgetInputSpec.type)
if (!widgetConstructor || inputSpec.forceInput) return
const {
widget,
minWidth = 1,
minHeight = 1
} = widgetConstructor(
this,
inputName,
transformInputSpecV2ToV1(widgetInputSpec),
app
) ?? {}
if (widget) {
widget.label = st(nameKey, widget.label ?? inputName)
widget.options ??= {}
Object.assign(widget.options, {
advanced: inputSpec.advanced,
hidden: inputSpec.hidden
})
}
if (!widget?.options?.socketless) {
const inputSpecV1 = transformInputSpecV2ToV1(widgetInputSpec)
this.addInput(inputName, inputSpec.type, {
shape: inputSpec.isOptional ? RenderShape.HollowCircle : undefined,
localized_name: st(nameKey, inputName),
widget: { name: inputName, [GET_CONFIG]: () => inputSpecV1 }
})
}
this.#initialMinSize.width = Math.max(
this.#initialMinSize.width,
minWidth
)
this.#initialMinSize.height = Math.max(
this.#initialMinSize.height,
minHeight
)
}
/**
* @internal Add inputs to the node.
*/
#addInputs(inputs: Record<string, InputSpec>) {
for (const inputSpec of Object.values(inputs))
this.#addInputSocket(inputSpec)
for (const inputSpec of Object.values(inputs))
this.#addInputWidget(inputSpec)
}
/**
* @internal Add outputs to the node.
*/
#addOutputs(outputs: OutputSpec[]) {
for (const output of outputs) {
const { name, type, is_list } = output
const shapeOptions = is_list ? { shape: LiteGraph.GRID_SHAPE } : {}
const nameKey = `${this.#nodeKey}.outputs.${output.index}.name`
const typeKey = `dataTypes.${normalizeI18nKey(type)}`
const outputOptions = {
...shapeOptions,
// If the output name is different from the output type, use the output name.
// e.g.
// - type ("INT"); name ("Positive") => translate name
// - type ("FLOAT"); name ("FLOAT") => translate type
localized_name:
type !== name ? st(nameKey, name) : st(typeKey, name)
}
this.addOutput(name, type, outputOptions)
}
}
/**
* @internal Set the initial size of the node.
*/
#setInitialSize() {
const s = this.computeSize()
// Expand the width a little to fit widget values on screen.
const pad =
this.widgets?.length &&
!useSettingStore().get('LiteGraph.Node.DefaultPadding')
s[0] = Math.max(this.#initialMinSize.width, s[0] + (pad ? 60 : 0))
s[1] = Math.max(this.#initialMinSize.height, s[1])
this.setSize(s)
}
/**
* Configure the node from a serialised node. Keep 'name', 'type', 'shape',
* and 'localized_name' information from the original node definition.
*/
override configure(data: ISerialisedNode): void {
const RESERVED_KEYS = ['name', 'type', 'shape', 'localized_name']
// Note: input name is unique in a node definition, so we can lookup
// input by name.
const inputByName = new Map<string, ISerialisableNodeInput>(
data.inputs?.map((input) => [input.name, input]) ?? []
)
// Inputs defined by the node definition.
const definedInputNames = new Set(
this.inputs.map((input) => input.name)
)
const definedInputs = this.inputs.map((input) => {
const inputData = inputByName.get(input.name)
return inputData
? {
...inputData,
// Whether the input has associated widget follows the
// original node definition.
..._.pick(input, RESERVED_KEYS.concat('widget'))
}
: input
})
// Extra inputs that potentially dynamically added by custom js logic.
const extraInputs = data.inputs?.filter(
(input) => !definedInputNames.has(input.name)
)
data.inputs = [...definedInputs, ...(extraInputs ?? [])]
// Note: output name is not unique, so we cannot lookup output by name.
// Use index instead.
data.outputs = _.zip(this.outputs, data.outputs).map(
([output, outputData]) => {
// If there are extra outputs in the serialised node, use them directly.
// There are currently custom nodes that dynamically add outputs via
// js logic.
if (!output) return outputData as ISerialisableNodeOutput
return outputData
? {
...outputData,
..._.pick(output, RESERVED_KEYS)
}
: output
}
)
data.widgets_values = migrateWidgetsValues(
ComfyNode.nodeData.inputs,
this.widgets ?? [],
data.widgets_values ?? []
)
super.configure(data)
}
}
addNodeContextMenuHandler(node)
addDrawBackgroundHandler(node)
addNodeKeyHandler(node)
// Note: Some extensions expects node.comfyClass to be set in
// `beforeRegisterNodeDef`.
node.prototype.comfyClass = nodeDefV1.name
node.comfyClass = nodeDefV1.name
const nodeDef = new ComfyNodeDefImpl(nodeDefV1)
node.nodeData = nodeDef
LiteGraph.registerNodeType(subgraph.id, node)
// Note: Do not following assignments before `LiteGraph.registerNodeType`
// because `registerNodeType` will overwrite the assignments.
node.category = nodeDef.category
node.title = nodeDef.display_name || nodeDef.name
}
async function registerNodeDef(nodeId: string, nodeDefV1: ComfyNodeDefV1) {
const node = class ComfyNode extends LGraphNode {
static comfyClass: string
@@ -622,8 +880,10 @@ export const useLitegraphService = () => {
options
)
const graph = useWorkflowStore().activeSubgraph ?? app.graph
// @ts-expect-error fixme ts strict error
app.graph.add(node)
graph.add(node)
// @ts-expect-error fixme ts strict error
return node
}
@@ -665,6 +925,7 @@ export const useLitegraphService = () => {
return {
registerNodeDef,
registerSubgraphNodeDef,
addNodeOnGraph,
getCanvasCenter,
goToNode,

View File

@@ -0,0 +1,59 @@
import { api } from '@/scripts/api'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
import { extractCustomNodeName } from '@/utils/nodeHelpUtil'
export class NodeHelpService {
async fetchNodeHelp(node: ComfyNodeDefImpl, locale: string): Promise<string> {
const nodeSource = getNodeSource(node.python_module)
if (nodeSource.type === NodeSourceType.CustomNodes) {
return this.fetchCustomNodeHelp(node, locale)
} else {
return this.fetchCoreNodeHelp(node, locale)
}
}
private async fetchCustomNodeHelp(
node: ComfyNodeDefImpl,
locale: string
): Promise<string> {
const customNodeName = extractCustomNodeName(node.python_module)
if (!customNodeName) {
throw new Error('Invalid custom node module')
}
// Try locale-specific path first
const localePath = `/extensions/${customNodeName}/docs/${node.name}/${locale}.md`
let res = await fetch(api.fileURL(localePath))
if (!res.ok) {
// Fall back to non-locale path
const fallbackPath = `/extensions/${customNodeName}/docs/${node.name}.md`
res = await fetch(api.fileURL(fallbackPath))
}
if (!res.ok) {
throw new Error(res.statusText)
}
return res.text()
}
private async fetchCoreNodeHelp(
node: ComfyNodeDefImpl,
locale: string
): Promise<string> {
const mdUrl = `/docs/${node.name}/${locale}.md`
const res = await fetch(api.fileURL(mdUrl))
if (!res.ok) {
throw new Error(res.statusText)
}
return res.text()
}
}
// Export singleton instance
export const nodeHelpService = new NodeHelpService()

View File

@@ -0,0 +1,159 @@
import { ComfyNodeDefImpl, buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
NodeGroupingStrategy,
NodeOrganizationOptions,
NodeSortStrategy
} from '@/types/nodeOrganizationTypes'
import { NodeSourceType } from '@/types/nodeSource'
import type { TreeNode } from '@/types/treeExplorerTypes'
import { sortedTree } from '@/utils/treeUtil'
const DEFAULT_ICON = 'pi pi-sort'
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export class NodeOrganizationService {
private readonly groupingStrategies: NodeGroupingStrategy[] = [
{
id: 'category',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.category',
icon: 'pi pi-folder',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.categoryDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const category = nodeDef.category || ''
const categoryParts = category ? category.split('/') : []
return [...categoryParts, nodeDef.name]
}
},
{
id: 'module',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.module',
icon: 'pi pi-box',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.moduleDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
const pythonModule = nodeDef.python_module || ''
if (!pythonModule) {
return ['unknown_module', nodeDef.name]
}
// Split the module path into components
const parts = pythonModule.split('.')
// Remove common prefixes and organize
if (parts[0] === 'nodes') {
// Core nodes - just use 'core'
return ['core', nodeDef.name]
} else if (parts[0] === 'custom_nodes') {
// Custom nodes - use the package name as the folder
if (parts.length > 1) {
// Return the custom node package name
return [parts[1], nodeDef.name]
}
return ['custom_nodes', nodeDef.name]
}
// For other modules, use the full path structure plus node name
return [...parts, nodeDef.name]
}
},
{
id: 'source',
label: 'sideToolbar.nodeLibraryTab.groupStrategies.source',
icon: 'pi pi-server',
description: 'sideToolbar.nodeLibraryTab.groupStrategies.sourceDesc',
getNodePath: (nodeDef: ComfyNodeDefImpl) => {
if (nodeDef.api_node) {
return ['API nodes', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.Core) {
return ['Core', nodeDef.name]
} else if (nodeDef.nodeSource.type === NodeSourceType.CustomNodes) {
return ['Custom nodes', nodeDef.name]
} else {
return ['Unknown', nodeDef.name]
}
}
}
]
private readonly sortingStrategies: NodeSortStrategy[] = [
{
id: 'original',
label: 'sideToolbar.nodeLibraryTab.sortBy.original',
icon: 'pi pi-sort-alt',
description: 'sideToolbar.nodeLibraryTab.sortBy.originalDesc',
compare: () => 0
},
{
id: 'alphabetical',
label: 'sideToolbar.nodeLibraryTab.sortBy.alphabetical',
icon: 'pi pi-sort-alpha-down',
description: 'sideToolbar.nodeLibraryTab.sortBy.alphabeticalDesc',
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) =>
(a.display_name ?? '').localeCompare(b.display_name ?? '')
}
]
getGroupingStrategies(): NodeGroupingStrategy[] {
return [...this.groupingStrategies]
}
getGroupingStrategy(id: string): NodeGroupingStrategy | undefined {
return this.groupingStrategies.find((strategy) => strategy.id === id)
}
getSortingStrategies(): NodeSortStrategy[] {
return [...this.sortingStrategies]
}
getSortingStrategy(id: string): NodeSortStrategy | undefined {
return this.sortingStrategies.find((strategy) => strategy.id === id)
}
organizeNodes(
nodes: ComfyNodeDefImpl[],
options: NodeOrganizationOptions = {}
): TreeNode {
const { groupBy = DEFAULT_GROUPING_ID, sortBy = DEFAULT_SORTING_ID } =
options
const groupingStrategy = this.getGroupingStrategy(groupBy)
const sortingStrategy = this.getSortingStrategy(sortBy)
if (!groupingStrategy) {
throw new Error(`Unknown grouping strategy: ${groupBy}`)
}
if (!sortingStrategy) {
throw new Error(`Unknown sorting strategy: ${sortBy}`)
}
const sortedNodes =
sortingStrategy.id !== 'original'
? [...nodes].sort(sortingStrategy.compare)
: nodes
const tree = buildNodeDefTree(sortedNodes, {
pathExtractor: groupingStrategy.getNodePath
})
if (sortBy === 'alphabetical') {
return sortedTree(tree, { groupLeaf: true })
}
return tree
}
getGroupingIcon(groupingId: string): string {
const strategy = this.getGroupingStrategy(groupingId)
return strategy?.icon || DEFAULT_ICON
}
getSortingIcon(sortingId: string): string {
const strategy = this.getSortingStrategy(sortingId)
return strategy?.icon || DEFAULT_ICON
}
}
export const nodeOrganizationService = new NodeOrganizationService()

View File

@@ -0,0 +1,91 @@
import {
type ExportedSubgraph,
type ExportedSubgraphInstance,
type Subgraph
} from '@comfyorg/litegraph'
import type { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import type { ComfyNodeDef as ComfyNodeDefV1 } from '@/schemas/nodeDefSchema'
import { app as comfyApp } from '@/scripts/app'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useLitegraphService } from './litegraphService'
export const useSubgraphService = () => {
const nodeDefStore = useNodeDefStore()
/** Loads a single subgraph definition and registers it with the node def store */
function registerLitegraphNode(
nodeDef: ComfyNodeDefV1,
subgraph: Subgraph,
exportedSubgraph: ExportedSubgraph
) {
const instanceData: ExportedSubgraphInstance = {
id: -1,
type: exportedSubgraph.id,
pos: [0, 0],
size: [100, 100],
inputs: [],
outputs: [],
flags: {},
order: 0,
mode: 0
}
useLitegraphService().registerSubgraphNodeDef(
nodeDef,
subgraph,
instanceData
)
}
function createNodeDef(exportedSubgraph: ExportedSubgraph) {
const { id, name } = exportedSubgraph
const nodeDef: ComfyNodeDefV1 = {
input: { required: {} },
output: [],
output_is_list: [],
output_name: [],
output_tooltips: [],
name: id,
display_name: name,
description: `Subgraph node for ${name}`,
category: 'subgraph',
output_node: false,
python_module: 'nodes'
}
nodeDefStore.addNodeDef(nodeDef)
return nodeDef
}
/** Loads all exported subgraph definitions from workflow */
function loadSubgraphs(graphData: ComfyWorkflowJSON) {
const subgraphs = graphData.definitions?.subgraphs
if (!subgraphs) return
// Assertion: overriding Zod schema
for (const subgraphData of subgraphs as ExportedSubgraph[]) {
const subgraph =
comfyApp.graph.subgraphs.get(subgraphData.id) ??
comfyApp.graph.createSubgraph(subgraphData)
registerNewSubgraph(subgraph, subgraphData)
}
}
/** Registers a new subgraph (e.g. user converted from nodes) */
function registerNewSubgraph(
subgraph: Subgraph,
exportedSubgraph: ExportedSubgraph
) {
const nodeDef = createNodeDef(exportedSubgraph)
registerLitegraphNode(nodeDef, subgraph, exportedSubgraph)
}
return {
loadSubgraphs,
registerNewSubgraph
}
}

View File

@@ -7,6 +7,7 @@ import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { app } from '@/scripts/app'
import { blankGraph, defaultGraph } from '@/scripts/defaultGraph'
import { downloadBlob } from '@/scripts/utils'
import { useDomWidgetStore } from '@/stores/domWidgetStore'
import { useSettingStore } from '@/stores/settingStore'
import { useToastStore } from '@/stores/toastStore'
import { ComfyWorkflow, useWorkflowStore } from '@/stores/workflowStore'
@@ -20,6 +21,7 @@ export const useWorkflowService = () => {
const workflowStore = useWorkflowStore()
const toastStore = useToastStore()
const dialogService = useDialogService()
const domWidgetStore = useDomWidgetStore()
async function getFilename(defaultName: string): Promise<string | null> {
if (settingStore.get('Comfy.PromptFilename')) {
@@ -285,11 +287,8 @@ export const useWorkflowService = () => {
*/
const beforeLoadNewGraph = () => {
// Use workspaceStore here as it is patched in unit tests.
const workflowStore = useWorkspaceStore().workflow
const activeWorkflow = workflowStore.activeWorkflow
if (activeWorkflow) {
activeWorkflow.changeTracker.store()
}
useWorkspaceStore().workflow.activeWorkflow?.changeTracker?.store()
domWidgetStore.clear()
}
/**
@@ -345,8 +344,7 @@ export const useWorkflowService = () => {
options: { position?: Vector2 } = {}
) => {
const loadedWorkflow = await workflow.load()
const data = loadedWorkflow.initialState
const workflowJSON = data
const workflowJSON = toRaw(loadedWorkflow.initialState)
const old = localStorage.getItem('litegrapheditor_clipboard')
// unknown conversion: ComfyWorkflowJSON is stricter than LiteGraph's
// serialisation schema.

View File

@@ -2,7 +2,7 @@
* Stores all DOM widgets that are used in the canvas.
*/
import { defineStore } from 'pinia'
import { type Raw, markRaw, ref } from 'vue'
import { type Raw, computed, markRaw, ref } from 'vue'
import type { PositionConfig } from '@/composables/element/useAbsolutePosition'
import type { BaseDOMWidget } from '@/scripts/domWidget'
@@ -13,11 +13,20 @@ export interface DomWidgetState extends PositionConfig {
visible: boolean
readonly: boolean
zIndex: number
/** If the widget belongs to the current graph/subgraph. */
active: boolean
}
export const useDomWidgetStore = defineStore('domWidget', () => {
const widgetStates = ref<Map<string, DomWidgetState>>(new Map())
const activeWidgetStates = computed(() =>
[...widgetStates.value.values()].filter((state) => state.active)
)
const inactiveWidgetStates = computed(() =>
[...widgetStates.value.values()].filter((state) => !state.active)
)
// Register a widget with the store
const registerWidget = <V extends object | string>(
widget: BaseDOMWidget<V>
@@ -28,7 +37,8 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
readonly: false,
zIndex: 0,
pos: [0, 0],
size: [0, 0]
size: [0, 0],
active: true
})
}
@@ -37,9 +47,28 @@ export const useDomWidgetStore = defineStore('domWidget', () => {
widgetStates.value.delete(widgetId)
}
const activateWidget = (widgetId: string) => {
const state = widgetStates.value.get(widgetId)
if (state) state.active = true
}
const deactivateWidget = (widgetId: string) => {
const state = widgetStates.value.get(widgetId)
if (state) state.active = false
}
const clear = () => {
widgetStates.value.clear()
}
return {
widgetStates,
activeWidgetStates,
inactiveWidgetStates,
registerWidget,
unregisterWidget
unregisterWidget,
activateWidget,
deactivateWidget,
clear
}
})

View File

@@ -3,7 +3,7 @@ import type { Positionable } from '@comfyorg/litegraph/dist/interfaces'
import { defineStore } from 'pinia'
import { type Raw, computed, markRaw, ref, shallowRef } from 'vue'
import { isLGraphGroup, isLGraphNode } from '@/utils/litegraphUtil'
import { isLGraphGroup, isLGraphNode, isReroute } from '@/utils/litegraphUtil'
export const useTitleEditorStore = defineStore('titleEditor', () => {
const titleEditorTarget = shallowRef<LGraphNode | LGraphGroup | null>(null)
@@ -31,6 +31,7 @@ export const useCanvasStore = defineStore('canvas', () => {
const nodeSelected = computed(() => selectedItems.value.some(isLGraphNode))
const groupSelected = computed(() => selectedItems.value.some(isLGraphGroup))
const rerouteSelected = computed(() => selectedItems.value.some(isReroute))
const getCanvas = () => {
if (!canvas.value) throw new Error('getCanvas: canvas is null')
@@ -42,6 +43,7 @@ export const useCanvasStore = defineStore('canvas', () => {
selectedItems,
nodeSelected,
groupSelected,
rerouteSelected,
updateSelectedItems,
getCanvas
}

View File

@@ -38,6 +38,7 @@ export class ComfyNodeDefImpl
category: string
readonly python_module: string
readonly description: string
readonly help: string
readonly deprecated: boolean
readonly experimental: boolean
readonly output_node: boolean
@@ -118,6 +119,7 @@ export class ComfyNodeDefImpl
this.category = obj.category
this.python_module = obj.python_module
this.description = obj.description
this.help = obj.help ?? ''
this.deprecated = obj.deprecated ?? obj.category === ''
this.experimental =
obj.experimental ?? obj.category.startsWith('_for_testing')
@@ -216,10 +218,22 @@ export const SYSTEM_NODE_DEFS: Record<string, ComfyNodeDefV1> = {
}
}
export function buildNodeDefTree(nodeDefs: ComfyNodeDefImpl[]): TreeNode {
return buildTree(nodeDefs, (nodeDef: ComfyNodeDefImpl) =>
export interface BuildNodeDefTreeOptions {
/**
* Custom function to extract the tree path from a node definition.
* If not provided, uses the default path based on nodeDef.nodePath.
*/
pathExtractor?: (nodeDef: ComfyNodeDefImpl) => string[]
}
export function buildNodeDefTree(
nodeDefs: ComfyNodeDefImpl[],
options: BuildNodeDefTreeOptions = {}
): TreeNode {
const { pathExtractor } = options
const defaultPathExtractor = (nodeDef: ComfyNodeDefImpl) =>
nodeDef.nodePath.split('/')
)
return buildTree(nodeDefs, pathExtractor || defaultPathExtractor)
}
export function createDummyFolderNodeDef(folderPath: string): ComfyNodeDefImpl {
@@ -292,8 +306,7 @@ export const useNodeDefStore = defineStore('nodeDef', () => {
}
function fromLGraphNode(node: LGraphNode): ComfyNodeDefImpl | null {
// Frontend-only nodes don't have nodeDef
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore Optional chaining used in index
// @ts-expect-error Optional chaining used in index
return nodeDefsByName.value[node.constructor?.nodeData?.name] ?? null
}

View File

@@ -0,0 +1,87 @@
import type { Subgraph } from '@comfyorg/litegraph'
import { defineStore } from 'pinia'
import { computed, shallowReactive, shallowRef, watch } from 'vue'
import { app } from '@/scripts/app'
import { isNonNullish } from '@/utils/typeGuardUtil'
import { useWorkflowStore } from './workflowStore'
/**
* Stores the current subgraph navigation state; a stack representing subgraph
* navigation history from the root graph to the subgraph that is currently
* open.
*/
export const useSubgraphNavigationStore = defineStore(
'subgraphNavigation',
() => {
const workflowStore = useWorkflowStore()
/** The currently opened subgraph. */
const activeSubgraph = shallowRef<Subgraph>()
/** The stack of subgraph IDs from the root graph to the currently opened subgraph. */
const idStack = shallowReactive<string[]>([])
/**
* A stack representing subgraph navigation history from the root graph to
* the current opened subgraph.
*/
const navigationStack = computed(() =>
idStack.map((id) => app.graph.subgraphs.get(id)).filter(isNonNullish)
)
/**
* Restore the navigation stack from a list of subgraph IDs.
* @param subgraphIds The list of subgraph IDs to restore the navigation stack from.
* @see exportState
*/
const restoreState = (subgraphIds: string[]) => {
idStack.length = 0
for (const id of subgraphIds) idStack.push(id)
}
/**
* Export the navigation stack as a list of subgraph IDs.
* @returns The list of subgraph IDs, ending with the currently active subgraph.
* @see restoreState
*/
const exportState = () => [...idStack]
// Reset on workflow change
watch(
() => workflowStore.activeWorkflow,
() => (idStack.length = 0)
)
// Update navigation stack when opened subgraph changes
watch(
() => workflowStore.activeSubgraph,
(subgraph) => {
// Navigated back to the root graph
if (!subgraph) {
idStack.length = 0
return
}
const index = idStack.lastIndexOf(subgraph.id)
const lastIndex = idStack.length - 1
if (index === -1) {
// Opened a new subgraph
idStack.push(subgraph.id)
} else if (index !== lastIndex) {
// Navigated to a different subgraph
idStack.splice(index + 1, lastIndex - index)
}
}
)
return {
activeSubgraph,
navigationStack,
restoreState,
exportState
}
}
)

View File

@@ -1,6 +1,7 @@
import type { Subgraph } from '@comfyorg/litegraph'
import _ from 'lodash'
import { defineStore } from 'pinia'
import { computed, markRaw, ref, watch } from 'vue'
import { type Raw, computed, markRaw, ref, shallowRef, watch } from 'vue'
import { ComfyWorkflowJSON } from '@/schemas/comfyWorkflowSchema'
import { api } from '@/scripts/api'
@@ -156,10 +157,9 @@ export interface WorkflowStore {
syncWorkflows: (dir?: string) => Promise<void>
reorderWorkflows: (from: number, to: number) => void
/** An ordered list of all parent subgraphs, ending with the current subgraph. */
subgraphNamePath: string[]
/** `true` if any subgraph is currently being viewed. */
isSubgraphActive: boolean
activeSubgraph: Subgraph | undefined
/** Updates the {@link subgraphNamePath} and {@link isSubgraphActive} values. */
updateActiveGraph: () => void
}
@@ -427,25 +427,19 @@ export const useWorkflowStore = defineStore('workflow', () => {
}
}
/** @see WorkflowStore.subgraphNamePath */
const subgraphNamePath = ref<string[]>([])
/** @see WorkflowStore.isSubgraphActive */
const isSubgraphActive = ref(false)
/** @see WorkflowStore.activeSubgraph */
const activeSubgraph = shallowRef<Raw<Subgraph>>()
/** @see WorkflowStore.updateActiveGraph */
const updateActiveGraph = () => {
const subgraph = comfyApp.canvas?.subgraph
activeSubgraph.value = subgraph ? markRaw(subgraph) : undefined
if (!comfyApp.canvas) return
const { subgraph } = comfyApp.canvas
isSubgraphActive.value = isSubgraph(subgraph)
if (subgraph) {
const [, ...pathFromRoot] = subgraph.pathToRootGraph
subgraphNamePath.value = pathFromRoot.map((graph) => graph.name)
} else {
subgraphNamePath.value = []
}
}
watch(activeWorkflow, updateActiveGraph)
@@ -473,8 +467,8 @@ export const useWorkflowStore = defineStore('workflow', () => {
getWorkflowByPath,
syncWorkflows,
subgraphNamePath,
isSubgraphActive,
activeSubgraph,
updateActiveGraph
}
}) satisfies () => WorkflowStore

View File

@@ -0,0 +1,69 @@
import { defineStore } from 'pinia'
import { computed, ref, watch } from 'vue'
import { i18n } from '@/i18n'
import { nodeHelpService } from '@/services/nodeHelpService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import { getNodeHelpBaseUrl } from '@/utils/nodeHelpUtil'
export const useNodeHelpStore = defineStore('nodeHelp', () => {
const currentHelpNode = ref<ComfyNodeDefImpl | null>(null)
const isHelpOpen = computed(() => currentHelpNode.value !== null)
const helpContent = ref<string>('')
const isLoading = ref<boolean>(false)
const errorMsg = ref<string | null>(null)
function openHelp(nodeDef: ComfyNodeDefImpl) {
currentHelpNode.value = nodeDef
}
function closeHelp() {
currentHelpNode.value = null
}
// Base URL for relative assets in node docs markdown
const baseUrl = computed(() => {
const node = currentHelpNode.value
if (!node) return ''
return getNodeHelpBaseUrl(node)
})
// Watch for help node changes and fetch its docs markdown
watch(
() => currentHelpNode.value,
async (node) => {
helpContent.value = ''
errorMsg.value = null
if (node) {
isLoading.value = true
try {
const locale = i18n.global.locale.value || 'en'
helpContent.value = await nodeHelpService.fetchNodeHelp(node, locale)
} catch (e: any) {
errorMsg.value = e.message
helpContent.value = node.description || ''
} finally {
isLoading.value = false
}
}
},
{ immediate: true }
)
const renderedHelpHtml = computed(() => {
return renderMarkdownToHtml(helpContent.value, baseUrl.value)
})
return {
currentHelpNode,
isHelpOpen,
openHelp,
closeHelp,
baseUrl,
renderedHelpHtml,
isLoading,
error: errorMsg
}
})

View File

@@ -75,6 +75,22 @@ declare module '@comfyorg/litegraph' {
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
interface BaseWidget extends IBaseWidget {}
/** Actual members required for execution. */
type ExecutableLGraphNode = Pick<
LGraphNode,
| 'id'
| 'type'
| 'comfyClass'
| 'title'
| 'mode'
| 'inputs'
| 'widgets'
| 'isVirtualNode'
| 'applyToGraph'
| 'getInputNode'
| 'getInputLink'
>
interface LGraphNode {
constructor: LGraphNodeConstructor
@@ -88,7 +104,10 @@ declare module '@comfyorg/litegraph' {
/** @deprecated groupNode */
setInnerNodes?(nodes: LGraphNode[]): void
/** Originally a group node API. */
getInnerNodes?(): LGraphNode[]
getInnerNodes?(
nodes?: ExecutableLGraphNode[],
subgraphs?: WeakSet<LGraphNode>
): ExecutableLGraphNode[]
/** @deprecated groupNode */
convertToNodes?(): LGraphNode[]
recreate?(): Promise<LGraphNode>

View File

@@ -0,0 +1,44 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
export type GroupingStrategyId = 'category' | 'module' | 'source'
export type SortingStrategyId = 'original' | 'alphabetical'
/**
* Strategy for grouping nodes into tree structure
*/
export interface NodeGroupingStrategy {
/** Unique identifier for the grouping strategy */
id: string
/** Display name for UI (i18n key) */
label: string
/** Icon class for the grouping option */
icon: string
/** Description for tooltips (i18n key) */
description?: string
/** Function to extract the tree path from a node definition */
getNodePath: (nodeDef: ComfyNodeDefImpl) => string[]
}
/**
* Strategy for sorting nodes within groups
*/
export interface NodeSortStrategy {
/** Unique identifier for the sort strategy */
id: string
/** Display name for UI (i18n key) */
label: string
/** Icon class for the sort option */
icon: string
/** Description for tooltips (i18n key) */
description?: string
/** Compare function for sorting nodes within the same group */
compare: (a: ComfyNodeDefImpl, b: ComfyNodeDefImpl) => number
}
/**
* Options for organizing nodes
*/
export interface NodeOrganizationOptions {
groupBy?: string
sortBy?: string
}

336
src/utils/fileHandlers.ts Normal file
View File

@@ -0,0 +1,336 @@
/**
* Maps MIME types and file extensions to handler functions for extracting
* workflow data from various file formats. Uses supportedWorkflowFormats.ts
* as the source of truth for supported formats.
*/
import {
AUDIO_WORKFLOW_FORMATS,
DATA_WORKFLOW_FORMATS,
IMAGE_WORKFLOW_FORMATS,
MODEL_WORKFLOW_FORMATS,
VIDEO_WORKFLOW_FORMATS
} from '@/constants/supportedWorkflowFormats'
import type {
ComfyApiWorkflow,
ComfyWorkflowJSON
} from '@/schemas/comfyWorkflowSchema'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
import { getMp3Metadata } from '@/scripts/metadata/mp3'
import { getOggMetadata } from '@/scripts/metadata/ogg'
import { getSvgMetadata } from '@/scripts/metadata/svg'
import {
getFlacMetadata,
getLatentMetadata,
getPngMetadata,
getWebpMetadata
} from '@/scripts/pnginfo'
/**
* Type for the file handler function
*/
export type WorkflowFileHandler = (file: File) => Promise<{
workflow?: ComfyWorkflowJSON
prompt?: ComfyApiWorkflow
parameters?: string
jsonTemplateData?: any // For template JSON data
}>
/**
* Maps MIME types to file handlers for loading workflows from different file formats
*/
export const mimeTypeHandlers = new Map<string, WorkflowFileHandler>()
/**
* Maps file extensions to file handlers for loading workflows
* Used as a fallback when MIME type detection fails
*/
export const extensionHandlers = new Map<string, WorkflowFileHandler>()
/**
* Handler for PNG files
*/
const handlePngFile: WorkflowFileHandler = async (file) => {
const pngInfo = await getPngMetadata(file)
return {
workflow: pngInfo?.workflow ? JSON.parse(pngInfo.workflow) : undefined,
prompt: pngInfo?.prompt ? JSON.parse(pngInfo.prompt) : undefined,
parameters: pngInfo?.parameters
}
}
/**
* Handler for WebP files
*/
const handleWebpFile: WorkflowFileHandler = async (file) => {
const pngInfo = await getWebpMetadata(file)
// Support loading workflows from that webp custom node.
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
return {
workflow: workflow ? JSON.parse(workflow) : undefined,
prompt: prompt ? JSON.parse(prompt) : undefined
}
}
/**
* Handler for SVG files
*/
const handleSvgFile: WorkflowFileHandler = async (file) => {
const svgInfo = await getSvgMetadata(file)
return {
workflow: svgInfo.workflow,
prompt: svgInfo.prompt
}
}
/**
* Handler for MP3 files
*/
const handleMp3File: WorkflowFileHandler = async (file) => {
const { workflow, prompt } = await getMp3Metadata(file)
return { workflow, prompt }
}
/**
* Handler for OGG files
*/
const handleOggFile: WorkflowFileHandler = async (file) => {
const { workflow, prompt } = await getOggMetadata(file)
return { workflow, prompt }
}
/**
* Handler for FLAC files
*/
const handleFlacFile: WorkflowFileHandler = async (file) => {
const pngInfo = await getFlacMetadata(file)
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
return {
workflow: workflow ? JSON.parse(workflow) : undefined,
prompt: prompt ? JSON.parse(prompt) : undefined
}
}
/**
* Handler for WebM files
*/
const handleWebmFile: WorkflowFileHandler = async (file) => {
const webmInfo = await getFromWebmFile(file)
return {
workflow: webmInfo.workflow,
prompt: webmInfo.prompt
}
}
/**
* Handler for MP4/MOV/M4V files
*/
const handleMp4File: WorkflowFileHandler = async (file) => {
const mp4Info = await getFromIsobmffFile(file)
return {
workflow: mp4Info.workflow,
prompt: mp4Info.prompt
}
}
/**
* Handler for GLB files
*/
const handleGlbFile: WorkflowFileHandler = async (file) => {
const gltfInfo = await getGltfBinaryMetadata(file)
return {
workflow: gltfInfo.workflow,
prompt: gltfInfo.prompt
}
}
/**
* Handler for JSON files
*/
const handleJsonFile: WorkflowFileHandler = async (file) => {
// For JSON files, we need to preserve the exact behavior from app.ts
// This code intentionally mirrors the original implementation
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => {
try {
const readerResult = reader.result as string
const jsonContent = JSON.parse(readerResult)
if (jsonContent?.templates) {
// This case will be handled separately in handleFile
resolve({
workflow: undefined,
prompt: undefined,
jsonTemplateData: jsonContent
})
} else if (isApiJson(jsonContent)) {
// API JSON format
resolve({ workflow: undefined, prompt: jsonContent })
} else {
// Regular workflow JSON
resolve({ workflow: JSON.parse(readerResult), prompt: undefined })
}
} catch (error) {
reject(error)
}
}
reader.onerror = () => reject(reader.error)
reader.readAsText(file)
})
}
/**
* Handler for .latent and .safetensors files
*/
const handleLatentFile: WorkflowFileHandler = async (file) => {
// Preserve the exact behavior from app.ts for latent files
const info = await getLatentMetadata(file)
// Direct port of the original code, preserving behavior for TS compatibility
if (info && typeof info === 'object' && 'workflow' in info && info.workflow) {
return {
workflow: JSON.parse(info.workflow as string),
prompt: undefined
}
} else if (
info &&
typeof info === 'object' &&
'prompt' in info &&
info.prompt
) {
return {
workflow: undefined,
prompt: JSON.parse(info.prompt as string)
}
} else {
return { workflow: undefined, prompt: undefined }
}
}
/**
* Helper function to determine if a JSON object is in the API JSON format
*/
function isApiJson(data: unknown) {
return (
typeof data === 'object' &&
data !== null &&
Object.values(data as Record<string, any>).every((v) => v.class_type)
)
}
// Register image format handlers
IMAGE_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'image/png') {
mimeTypeHandlers.set(mimeType, handlePngFile)
} else if (mimeType === 'image/webp') {
mimeTypeHandlers.set(mimeType, handleWebpFile)
} else if (mimeType === 'image/svg+xml') {
mimeTypeHandlers.set(mimeType, handleSvgFile)
}
})
IMAGE_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.png') {
extensionHandlers.set(ext, handlePngFile)
} else if (ext === '.webp') {
extensionHandlers.set(ext, handleWebpFile)
} else if (ext === '.svg') {
extensionHandlers.set(ext, handleSvgFile)
}
})
// Register audio format handlers
AUDIO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'audio/mpeg') {
mimeTypeHandlers.set(mimeType, handleMp3File)
} else if (mimeType === 'audio/ogg') {
mimeTypeHandlers.set(mimeType, handleOggFile)
} else if (mimeType === 'audio/flac' || mimeType === 'audio/x-flac') {
mimeTypeHandlers.set(mimeType, handleFlacFile)
}
})
AUDIO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.mp3') {
extensionHandlers.set(ext, handleMp3File)
} else if (ext === '.ogg') {
extensionHandlers.set(ext, handleOggFile)
} else if (ext === '.flac') {
extensionHandlers.set(ext, handleFlacFile)
}
})
// Register video format handlers
VIDEO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'video/webm') {
mimeTypeHandlers.set(mimeType, handleWebmFile)
} else if (
mimeType === 'video/mp4' ||
mimeType === 'video/quicktime' ||
mimeType === 'video/x-m4v'
) {
mimeTypeHandlers.set(mimeType, handleMp4File)
}
})
VIDEO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.webm') {
extensionHandlers.set(ext, handleWebmFile)
} else if (ext === '.mp4' || ext === '.mov' || ext === '.m4v') {
extensionHandlers.set(ext, handleMp4File)
}
})
// Register 3D model format handlers
MODEL_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'model/gltf-binary') {
mimeTypeHandlers.set(mimeType, handleGlbFile)
}
})
MODEL_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.glb') {
extensionHandlers.set(ext, handleGlbFile)
}
})
// Register data format handlers
DATA_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
if (mimeType === 'application/json') {
mimeTypeHandlers.set(mimeType, handleJsonFile)
}
})
DATA_WORKFLOW_FORMATS.extensions.forEach((ext) => {
if (ext === '.json') {
extensionHandlers.set(ext, handleJsonFile)
} else if (ext === '.latent' || ext === '.safetensors') {
extensionHandlers.set(ext, handleLatentFile)
}
})
/**
* Gets the appropriate file handler for a given file based on mime type or extension
*/
export function getFileHandler(file: File): WorkflowFileHandler | null {
// First try to match by MIME type
if (file.type && mimeTypeHandlers.has(file.type)) {
return mimeTypeHandlers.get(file.type) || null
}
// If no MIME type match, try to match by file extension
if (file.name) {
const extension = '.' + file.name.split('.').pop()?.toLowerCase()
if (extension && extensionHandlers.has(extension)) {
return extensionHandlers.get(extension) || null
}
}
return null
}

View File

@@ -1,4 +1,4 @@
import type { ColorOption, LGraph } from '@comfyorg/litegraph'
import { ColorOption, LGraph, Reroute } from '@comfyorg/litegraph'
import { LGraphGroup, LGraphNode, isColorable } from '@comfyorg/litegraph'
import type { ISerialisedGraph } from '@comfyorg/litegraph/dist/types/serialisation'
import type {
@@ -50,6 +50,10 @@ export const isLGraphGroup = (item: unknown): item is LGraphGroup => {
return item instanceof LGraphGroup
}
export const isReroute = (item: unknown): item is Reroute => {
return item instanceof Reroute
}
/**
* Get the color option of all canvas items if they are all the same.
* @param items - The items to get the color option of.

View File

@@ -0,0 +1,53 @@
import DOMPurify from 'dompurify'
import { Renderer, marked } from 'marked'
const ALLOWED_TAGS = ['video', 'source']
const ALLOWED_ATTRS = [
'controls',
'autoplay',
'loop',
'muted',
'preload',
'poster'
]
// Matches relative src attributes in img, source, and video HTML tags
// Captures: 1) opening tag with src=", 2) relative path, 3) closing quote
// Excludes absolute paths (starting with /) and URLs (http:// or https://)
const MEDIA_SRC_REGEX =
/(<(?:img|source|video)[^>]*\ssrc=['"])(?!(?:\/|https?:\/\/))([^'"\s>]+)(['"])/gi
// Create a marked Renderer that prefixes relative URLs with base
export function createMarkdownRenderer(baseUrl?: string): Renderer {
const normalizedBase = baseUrl ? baseUrl.replace(/\/+$/, '') : ''
const renderer = new Renderer()
renderer.image = ({ href, title, text }) => {
let src = href
if (normalizedBase && !/^(?:\/|https?:\/\/)/.test(href)) {
src = `${normalizedBase}/${href}`
}
const titleAttr = title ? ` title="${title}"` : ''
return `<img src="${src}" alt="${text}"${titleAttr} />`
}
return renderer
}
export function renderMarkdownToHtml(
markdown: string,
baseUrl?: string
): string {
if (!markdown) return ''
let html = marked.parse(markdown, {
renderer: createMarkdownRenderer(baseUrl)
}) as string
if (baseUrl) {
html = html.replace(MEDIA_SRC_REGEX, `$1${baseUrl}$2$3`)
}
return DOMPurify.sanitize(html, {
ADD_TAGS: ALLOWED_TAGS,
ADD_ATTR: ALLOWED_ATTRS
})
}

View File

@@ -0,0 +1,51 @@
import type { ModelFile } from '@/schemas/comfyWorkflowSchema'
/**
* Gets models from the node's `properties.models` field, excluding those
* not currently selected in at least 1 of the node's widget values.
*
* @example
* ```ts
* const node = {
* type: 'CheckpointLoaderSimple',
* widgets_values: ['model1', 'model2'],
* properties: { models: [{ name: 'model1' }, { name: 'model2' }, { name: 'model3' }] }
* ... other properties
* }
* const selectedModels = getSelectedModelsMetadata(node)
* // selectedModels = [{ name: 'model1' }, { name: 'model2' }]
* ```
*
* @param node - The workflow node to process
* @returns Filtered array containing only models that are currently selected
*/
export function getSelectedModelsMetadata(node: {
type: string
widgets_values?: unknown[] | Record<string, unknown>
properties?: { models?: ModelFile[] }
}): ModelFile[] | undefined {
try {
if (!node.properties?.models?.length) return
if (!node.widgets_values) return
const widgetValues = Array.isArray(node.widgets_values)
? node.widgets_values
: Object.values(node.widgets_values)
if (!widgetValues.length) return
const stringWidgetValues = new Set<string>()
for (const widgetValue of widgetValues) {
if (typeof widgetValue === 'string' && widgetValue.trim()) {
stringWidgetValues.add(widgetValue)
}
}
// Return the node's models that are present in the widget values
return node.properties.models.filter((model) =>
stringWidgetValues.has(model.name)
)
} catch (error) {
console.error('Error filtering models by current selection:', error)
}
}

23
src/utils/nodeHelpUtil.ts Normal file
View File

@@ -0,0 +1,23 @@
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
export function extractCustomNodeName(
pythonModule: string | undefined
): string | null {
const modules = pythonModule?.split('.') || []
if (modules.length >= 2 && modules[0] === 'custom_nodes') {
return modules[1].split('@')[0]
}
return null
}
export function getNodeHelpBaseUrl(node: ComfyNodeDefImpl): string {
const nodeSource = getNodeSource(node.python_module)
if (nodeSource.type === NodeSourceType.CustomNodes) {
const customNodeName = extractCustomNodeName(node.python_module)
if (customNodeName) {
return `/extensions/${customNodeName}/docs/`
}
}
return `/docs/${node.name}/`
}

View File

@@ -21,3 +21,9 @@ export const isAbortError = (
export const isSubgraph = (
item: LGraph | Subgraph | undefined | null
): item is Subgraph => item?.isRootGraph === false
/**
* Check if an item is non-nullish.
*/
export const isNonNullish = <T>(item: T | undefined | null): item is T =>
item != null

View File

@@ -0,0 +1,330 @@
import { describe, expect, it } from 'vitest'
import { nodeOrganizationService } from '@/services/nodeOrganizationService'
import { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { NodeSourceType } from '@/types/nodeSource'
describe('nodeOrganizationService', () => {
const createMockNodeDef = (overrides: any = {}) => {
const mockNodeDef = {
name: 'TestNode',
display_name: 'Test Node',
category: 'test/subcategory',
python_module: 'custom_nodes.MyPackage.nodes',
api_node: false,
nodeSource: {
type: NodeSourceType.CustomNodes,
className: 'comfy-custom',
displayText: 'Custom',
badgeText: 'C'
},
...overrides
}
Object.setPrototypeOf(mockNodeDef, ComfyNodeDefImpl.prototype)
return mockNodeDef as ComfyNodeDefImpl
}
describe('getGroupingStrategies', () => {
it('should return all grouping strategies', () => {
const strategies = nodeOrganizationService.getGroupingStrategies()
expect(strategies).toHaveLength(3)
expect(strategies.map((s) => s.id)).toEqual([
'category',
'module',
'source'
])
})
it('should return immutable copy', () => {
const strategies1 = nodeOrganizationService.getGroupingStrategies()
const strategies2 = nodeOrganizationService.getGroupingStrategies()
expect(strategies1).not.toBe(strategies2)
expect(strategies1).toEqual(strategies2)
})
})
describe('getGroupingStrategy', () => {
it('should return strategy by id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('category')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('getSortingStrategies', () => {
it('should return all sorting strategies', () => {
const strategies = nodeOrganizationService.getSortingStrategies()
expect(strategies).toHaveLength(2)
expect(strategies.map((s) => s.id)).toEqual(['original', 'alphabetical'])
})
})
describe('getSortingStrategy', () => {
it('should return strategy by id', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
expect(strategy).toBeDefined()
expect(strategy?.id).toBe('alphabetical')
})
it('should return undefined for unknown id', () => {
const strategy = nodeOrganizationService.getSortingStrategy('unknown')
expect(strategy).toBeUndefined()
})
})
describe('organizeNodes', () => {
const mockNodes = [
createMockNodeDef({ name: 'NodeA', display_name: 'Zebra Node' }),
createMockNodeDef({ name: 'NodeB', display_name: 'Apple Node' })
]
it('should organize nodes with default options', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes)
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom grouping', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'module'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should organize nodes with custom sorting', () => {
const tree = nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'alphabetical'
})
expect(tree).toBeDefined()
expect(tree.children).toBeDefined()
})
it('should throw error for unknown grouping strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
groupBy: 'unknown'
})
}).toThrow('Unknown grouping strategy: unknown')
})
it('should throw error for unknown sorting strategy', () => {
expect(() => {
nodeOrganizationService.organizeNodes(mockNodes, {
sortBy: 'unknown'
})
}).toThrow('Unknown sorting strategy: unknown')
})
})
describe('getGroupingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getGroupingIcon('category')
expect(icon).toBe('pi pi-folder')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getGroupingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('getSortingIcon', () => {
it('should return strategy icon', () => {
const icon = nodeOrganizationService.getSortingIcon('alphabetical')
expect(icon).toBe('pi pi-sort-alpha-down')
})
it('should return fallback icon for unknown strategy', () => {
const icon = nodeOrganizationService.getSortingIcon('unknown')
expect(icon).toBe('pi pi-sort')
})
})
describe('grouping path extraction', () => {
const mockNodeDef = createMockNodeDef()
it('category grouping should use category path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['test', 'subcategory', 'TestNode'])
})
it('module grouping should extract module path', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['MyPackage', 'TestNode'])
})
it('source grouping should categorize by source type', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
const path = strategy?.getNodePath(mockNodeDef)
expect(path).toEqual(['Custom nodes', 'TestNode'])
})
})
describe('edge cases', () => {
describe('module grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('module')
it('should handle empty python_module', () => {
const nodeDef = createMockNodeDef({ python_module: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle undefined python_module', () => {
const nodeDef = createMockNodeDef({ python_module: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['unknown_module', 'TestNode'])
})
it('should handle modules with spaces in the name', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.My Package With Spaces.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['My Package With Spaces', 'TestNode'])
})
it('should handle modules with special characters', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.my-package_v2.0.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['my-package_v2', 'TestNode'])
})
it('should handle deeply nested modules', () => {
const nodeDef = createMockNodeDef({
python_module: 'custom_nodes.package.subpackage.module.nodes'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['package', 'TestNode'])
})
it('should handle core nodes module path', () => {
const nodeDef = createMockNodeDef({ python_module: 'nodes' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['core', 'TestNode'])
})
it('should handle non-standard module paths', () => {
const nodeDef = createMockNodeDef({
python_module: 'some.other.module.path'
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['some', 'other', 'module', 'path', 'TestNode'])
})
})
describe('category grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('category')
it('should handle empty category', () => {
const nodeDef = createMockNodeDef({ category: '' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle undefined category', () => {
const nodeDef = createMockNodeDef({ category: undefined })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['TestNode'])
})
it('should handle category with trailing slash', () => {
const nodeDef = createMockNodeDef({ category: 'test/subcategory/' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', '', 'TestNode'])
})
it('should handle category with multiple consecutive slashes', () => {
const nodeDef = createMockNodeDef({ category: 'test//subcategory' })
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', '', 'subcategory', 'TestNode'])
})
})
describe('source grouping edge cases', () => {
const strategy = nodeOrganizationService.getGroupingStrategy('source')
it('should handle API nodes', () => {
const nodeDef = createMockNodeDef({
api_node: true,
nodeSource: {
type: NodeSourceType.Core,
className: 'comfy-core',
displayText: 'Core',
badgeText: 'C'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['API nodes', 'TestNode'])
})
it('should handle unknown source type', () => {
const nodeDef = createMockNodeDef({
nodeSource: {
type: 'unknown' as any,
className: 'unknown',
displayText: 'Unknown',
badgeText: '?'
}
})
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['Unknown', 'TestNode'])
})
})
describe('node name edge cases', () => {
it('should handle nodes with special characters in name', () => {
const nodeDef = createMockNodeDef({
name: 'Test/Node:With*Special<Chars>'
})
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual([
'test',
'subcategory',
'Test/Node:With*Special<Chars>'
])
})
it('should handle nodes with very long names', () => {
const longName = 'A'.repeat(100)
const nodeDef = createMockNodeDef({ name: longName })
const strategy = nodeOrganizationService.getGroupingStrategy('category')
const path = strategy?.getNodePath(nodeDef)
expect(path).toEqual(['test', 'subcategory', longName])
})
})
})
describe('sorting comparison', () => {
it('original sort should keep order', () => {
const strategy = nodeOrganizationService.getSortingStrategy('original')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBe(0)
})
it('alphabetical sort should compare display names', () => {
const strategy =
nodeOrganizationService.getSortingStrategy('alphabetical')
const nodeA = createMockNodeDef({ display_name: 'Zebra' })
const nodeB = createMockNodeDef({ display_name: 'Apple' })
expect(strategy?.compare(nodeA, nodeB)).toBeGreaterThan(0)
expect(strategy?.compare(nodeB, nodeA)).toBeLessThan(0)
})
})
})

View File

@@ -0,0 +1,399 @@
import { flushPromises } from '@vue/test-utils'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
vi.mock('@/scripts/api', () => ({
api: {
fileURL: vi.fn((url) => url)
}
}))
vi.mock('@/i18n', () => ({
i18n: {
global: {
locale: {
value: 'en'
}
}
}
}))
vi.mock('@/types/nodeSource', () => ({
NodeSourceType: {
Core: 'core',
CustomNodes: 'custom_nodes'
},
getNodeSource: vi.fn((pythonModule) => {
if (pythonModule?.startsWith('custom_nodes.')) {
return { type: 'custom_nodes' }
}
return { type: 'core' }
})
}))
vi.mock('dompurify', () => ({
default: {
sanitize: vi.fn((html) => html)
}
}))
vi.mock('marked', () => ({
marked: {
parse: vi.fn((markdown, options) => {
if (options?.renderer) {
if (markdown.includes('![')) {
const matches = markdown.match(/!\[(.*?)\]\((.*?)\)/)
if (matches) {
const [, text, href] = matches
return options.renderer.image({ href, text, title: '' })
}
}
}
return `<p>${markdown}</p>`
})
},
Renderer: class Renderer {
image = vi.fn(
({ href, title, text }) =>
`<img src="${href}" alt="${text}"${title ? ` title="${title}"` : ''} />`
)
link = vi.fn(
({ href, title, text }) =>
`<a href="${href}"${title ? ` title="${title}"` : ''}>${text}</a>`
)
}
}))
describe('nodeHelpStore', () => {
// Define a mock node for testing
const mockCoreNode = {
name: 'TestNode',
display_name: 'Test Node',
description: 'A test node',
inputs: {},
outputs: [],
python_module: 'comfy.test_node'
}
const mockCustomNode = {
name: 'CustomNode',
display_name: 'Custom Node',
description: 'A custom node',
inputs: {},
outputs: [],
python_module: 'custom_nodes.test_module.custom@1.0.0'
}
// Mock fetch responses
const mockFetch = vi.fn()
global.fetch = mockFetch
beforeEach(() => {
// Setup Pinia
setActivePinia(createPinia())
mockFetch.mockReset()
})
it('should initialize with empty state', () => {
const nodeHelpStore = useNodeHelpStore()
expect(nodeHelpStore.currentHelpNode).toBeNull()
expect(nodeHelpStore.isHelpOpen).toBe(false)
})
it('should open help for a node', () => {
const nodeHelpStore = useNodeHelpStore()
nodeHelpStore.openHelp(mockCoreNode as any)
expect(nodeHelpStore.currentHelpNode).toStrictEqual(mockCoreNode)
expect(nodeHelpStore.isHelpOpen).toBe(true)
})
it('should close help', () => {
const nodeHelpStore = useNodeHelpStore()
nodeHelpStore.openHelp(mockCoreNode as any)
expect(nodeHelpStore.isHelpOpen).toBe(true)
nodeHelpStore.closeHelp()
expect(nodeHelpStore.currentHelpNode).toBeNull()
expect(nodeHelpStore.isHelpOpen).toBe(false)
})
it('should generate correct baseUrl for core nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
nodeHelpStore.openHelp(mockCoreNode as any)
await nextTick()
expect(nodeHelpStore.baseUrl).toBe(`/docs/${mockCoreNode.name}/`)
})
it('should generate correct baseUrl for custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
nodeHelpStore.openHelp(mockCustomNode as any)
await nextTick()
expect(nodeHelpStore.baseUrl).toBe('/extensions/test_module/docs/')
})
it('should render markdown content correctly', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test Help\nThis is test help content'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'This is test help content'
)
})
it('should handle fetch errors and fall back to description', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: false,
statusText: 'Not Found'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.error).toBe('Not Found')
expect(nodeHelpStore.renderedHelpHtml).toContain(mockCoreNode.description)
})
it('should include alt attribute for images', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '![image](test.jpg)'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="image"')
})
it('should prefix relative video src in custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '<video src="video.mp4"></video>'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="/extensions/test_module/docs/video.mp4"'
)
})
it('should prefix relative video src for core nodes with node-specific base URL', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '<video src="video.mp4"></video>'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/video.mp4"`
)
})
it('should prefix relative source src in custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () =>
'<video><source src="video.mp4" type="video/mp4" /></video>'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="/extensions/test_module/docs/video.mp4"'
)
})
it('should prefix relative source src for core nodes with node-specific base URL', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () =>
'<video><source src="video.webm" type="video/webm" /></video>'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/video.webm"`
)
})
it('should handle loading state', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves
nodeHelpStore.openHelp(mockCoreNode as any)
await nextTick()
expect(nodeHelpStore.isLoading).toBe(true)
})
it('should try fallback URL for custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch
.mockResolvedValueOnce({
ok: false,
statusText: 'Not Found'
})
.mockResolvedValueOnce({
ok: true,
text: async () => '# Fallback content'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(mockFetch).toHaveBeenCalledTimes(2)
expect(mockFetch).toHaveBeenCalledWith(
'/extensions/test_module/docs/CustomNode/en.md'
)
expect(mockFetch).toHaveBeenCalledWith(
'/extensions/test_module/docs/CustomNode.md'
)
})
it('should prefix relative img src in raw HTML for custom nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="/extensions/test_module/docs/image.png"'
)
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"')
})
it('should prefix relative img src in raw HTML for core nodes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '# Test\n<img src="image.png" alt="Test image">'
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/image.png"`
)
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"')
})
it('should not prefix absolute img src in raw HTML', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => '<img src="/absolute/image.png" alt="Absolute">'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="/absolute/image.png"'
)
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Absolute"')
})
it('should not prefix external img src in raw HTML', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () =>
'<img src="https://example.com/image.png" alt="External">'
})
nodeHelpStore.openHelp(mockCustomNode as any)
await flushPromises()
expect(nodeHelpStore.renderedHelpHtml).toContain(
'src="https://example.com/image.png"'
)
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="External"')
})
it('should handle various quote styles in media src attributes', async () => {
const nodeHelpStore = useNodeHelpStore()
mockFetch.mockResolvedValueOnce({
ok: true,
text: async () => `# Media Test
Testing quote styles in properly formed HTML:
<video src="video1.mp4" controls></video>
<video src='video2.mp4' controls></video>
<img src="image1.png" alt="Double quotes">
<img src='image2.png' alt='Single quotes'>
<video controls>
<source src="video3.mp4" type="video/mp4">
<source src='video3.webm' type='video/webm'>
</video>
The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.`
})
nodeHelpStore.openHelp(mockCoreNode as any)
await flushPromises()
// Check that all media elements with different quote styles are prefixed correctly
// Double quotes remain as double quotes
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/video1.mp4"`
)
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/image1.png"`
)
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src="/docs/${mockCoreNode.name}/video3.mp4"`
)
// Single quotes remain as single quotes in the output
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src='/docs/${mockCoreNode.name}/video2.mp4'`
)
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src='/docs/${mockCoreNode.name}/image2.png'`
)
expect(nodeHelpStore.renderedHelpHtml).toContain(
`src='/docs/${mockCoreNode.name}/video3.webm'`
)
})
})

View File

@@ -492,7 +492,7 @@ describe('useWorkflowStore', () => {
// Assert
console.debug(store.isSubgraphActive)
expect(store.isSubgraphActive).toBe(false) // Should default to false
expect(store.subgraphNamePath).toEqual([]) // Should default to empty
expect(store.activeSubgraph).toBeUndefined() // Should default to empty
})
it('should correctly update state when the root graph is active', async () => {
@@ -505,7 +505,7 @@ describe('useWorkflowStore', () => {
// Assert: Check store state
expect(store.isSubgraphActive).toBe(false)
expect(store.subgraphNamePath).toEqual([]) // Path is empty for root graph
expect(store.activeSubgraph).toBeUndefined()
})
it('should correctly update state when a subgraph is active', async () => {
@@ -527,10 +527,7 @@ describe('useWorkflowStore', () => {
// Assert: Check store state
expect(store.isSubgraphActive).toBe(true)
expect(store.subgraphNamePath).toEqual([
'Level 1 Subgraph',
'Level 2 Subgraph'
]) // Path excludes the root
expect(store.activeSubgraph).toEqual(mockSubgraph)
})
it('should update automatically when activeWorkflow changes', async () => {
@@ -548,7 +545,7 @@ describe('useWorkflowStore', () => {
// Verify initial state
expect(store.isSubgraphActive).toBe(true)
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
expect(store.activeSubgraph).toEqual(initialSubgraph)
// Act: Change the active workflow
const workflow2 = store.createTemporary('workflow2.json')
@@ -569,7 +566,7 @@ describe('useWorkflowStore', () => {
// Assert: Check that the state was updated by the watcher based on the *new* canvas state
expect(store.isSubgraphActive).toBe(false) // Should reflect the change to undefined subgraph
expect(store.subgraphNamePath).toEqual([]) // Path should be empty for root
expect(store.activeSubgraph).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,127 @@
import { describe, expect, it } from 'vitest'
import {
AUDIO_WORKFLOW_FORMATS,
DATA_WORKFLOW_FORMATS,
IMAGE_WORKFLOW_FORMATS,
MODEL_WORKFLOW_FORMATS,
VIDEO_WORKFLOW_FORMATS
} from '../../../src/constants/supportedWorkflowFormats'
import {
extensionHandlers,
getFileHandler,
mimeTypeHandlers
} from '../../../src/utils/fileHandlers'
describe('fileHandlers', () => {
describe('handler registrations', () => {
it('should register handlers for all image MIME types', () => {
IMAGE_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all image extensions', () => {
IMAGE_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
it('should register handlers for all audio MIME types', () => {
AUDIO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all audio extensions', () => {
AUDIO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
it('should register handlers for all video MIME types', () => {
VIDEO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all video extensions', () => {
VIDEO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
it('should register handlers for all 3D model MIME types', () => {
MODEL_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all 3D model extensions', () => {
MODEL_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
it('should register handlers for all data MIME types', () => {
DATA_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
})
})
it('should register handlers for all data extensions', () => {
DATA_WORKFLOW_FORMATS.extensions.forEach((ext) => {
expect(extensionHandlers.has(ext)).toBe(true)
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
})
})
})
describe('getFileHandler', () => {
it('should return a handler when a file with a recognized MIME type is provided', () => {
const file = new File(['test content'], 'test.png', { type: 'image/png' })
const handler = getFileHandler(file)
expect(handler).not.toBeNull()
expect(handler).toBeTypeOf('function')
})
it('should return a handler when a file with a recognized extension but no MIME type is provided', () => {
// File with empty MIME type but recognizable extension
const file = new File(['test content'], 'test.webp', { type: '' })
const handler = getFileHandler(file)
expect(handler).not.toBeNull()
expect(handler).toBeTypeOf('function')
})
it('should return null when no handler is found for the file type', () => {
const file = new File(['test content'], 'test.txt', {
type: 'text/plain'
})
const handler = getFileHandler(file)
expect(handler).toBeNull()
})
it('should prioritize MIME type over extension when both are present and different', () => {
// A file with a JSON MIME type but SVG extension
const file = new File(['{}'], 'test.svg', { type: 'application/json' })
const handler = getFileHandler(file)
// Make a shadow copy of the handlers for comparison
const jsonHandler = mimeTypeHandlers.get('application/json')
const svgHandler = extensionHandlers.get('.svg')
// The handler should match the JSON handler, not the SVG handler
expect(handler).toBe(jsonHandler)
expect(handler).not.toBe(svgHandler)
})
})
})

View File

@@ -0,0 +1,248 @@
import { describe, expect, it } from 'vitest'
import { getSelectedModelsMetadata } from '@/utils/modelMetadataUtil'
describe('modelMetadataUtil', () => {
describe('filterModelsByCurrentSelection', () => {
it('should filter models to only include those selected in widget values', () => {
const node = {
type: 'CheckpointLoaderSimple',
widgets_values: ['model_a.safetensors'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
},
{
name: 'model_b.safetensors',
url: 'https://example.com/model_b.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('model_a.safetensors')
})
it('should return empty array when no models match selection', () => {
const node = {
type: 'SomeNode',
widgets_values: ['unmatched_model.safetensors'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(0)
})
it('should handle multiple widget values', () => {
const node = {
type: 'SomeNode',
widgets_values: ['model_a.safetensors', 42, 'model_c.ckpt'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
},
{
name: 'model_b.safetensors',
url: 'https://example.com/model_b.safetensors',
directory: 'checkpoints'
},
{
name: 'model_c.ckpt',
url: 'https://example.com/model_c.ckpt',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(2)
expect(result!.map((m) => m.name)).toEqual([
'model_a.safetensors',
'model_c.ckpt'
])
})
it('should ignore non-string widget values', () => {
const node = {
type: 'SomeNode',
widgets_values: [42, true, null, undefined, 'model_a.safetensors'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('model_a.safetensors')
})
it('should ignore empty strings', () => {
const node = {
type: 'SomeNode',
widgets_values: ['', ' ', 'model_a.safetensors'],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('model_a.safetensors')
})
it('should return undefined for nodes without model metadata', () => {
const node = {
type: 'SomeNode',
widgets_values: ['model_a.safetensors']
}
const result = getSelectedModelsMetadata(node)
expect(result).toBeUndefined()
})
it('should return undefined for nodes without widgets_values', () => {
const node = {
type: 'SomeNode',
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toBeUndefined()
})
it('should return undefined for nodes with empty widgets_values', () => {
const node = {
type: 'SomeNode',
widgets_values: [],
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toBeUndefined()
})
it('should return undefined for nodes with empty models array', () => {
const node = {
type: 'SomeNode',
widgets_values: ['model_a.safetensors'],
properties: {
models: []
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toBeUndefined()
})
it('should handle object widget values', () => {
const node = {
type: 'SomeNode',
widgets_values: {
ckpt_name: 'model_a.safetensors',
seed: 42
},
properties: {
models: [
{
name: 'model_a.safetensors',
url: 'https://example.com/model_a.safetensors',
directory: 'checkpoints'
},
{
name: 'model_b.safetensors',
url: 'https://example.com/model_b.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('model_a.safetensors')
})
it('should work end-to-end to filter outdated metadata', () => {
const node = {
type: 'CheckpointLoaderSimple',
widgets_values: ['current_model.safetensors'],
properties: {
models: [
{
name: 'current_model.safetensors',
url: 'https://example.com/current_model.safetensors',
directory: 'checkpoints'
},
{
name: 'old_model.safetensors',
url: 'https://example.com/old_model.safetensors',
directory: 'checkpoints'
}
]
}
}
const result = getSelectedModelsMetadata(node)
expect(result).toHaveLength(1)
expect(result![0].name).toBe('current_model.safetensors')
})
})
})

View File

@@ -8,7 +8,11 @@ import { createHtmlPlugin } from 'vite-plugin-html'
import vueDevTools from 'vite-plugin-vue-devtools'
import type { UserConfigExport } from 'vitest/config'
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
import {
addElementVnodeExportPlugin,
comfyAPIPlugin,
generateImportMapPlugin
} from './build/plugins'
dotenv.config()
@@ -52,6 +56,18 @@ export default defineConfig({
target: DEV_SERVER_COMFYUI_URL
},
// Proxy extension assets (images/videos) under /extensions to the ComfyUI backend
'/extensions': {
target: DEV_SERVER_COMFYUI_URL,
changeOrigin: true
},
// Proxy docs markdown from backend
'/docs': {
target: DEV_SERVER_COMFYUI_URL,
changeOrigin: true
},
...(!DISABLE_TEMPLATES_PROXY
? {
'/templates': {
@@ -73,40 +89,11 @@ export default defineConfig({
: [vue()]),
comfyAPIPlugin(IS_DEV),
generateImportMapPlugin([
{
name: 'vue',
pattern: 'vue',
entry: './dist/vue.esm-browser.prod.js'
},
{
name: 'vue-i18n',
pattern: 'vue-i18n',
entry: './dist/vue-i18n.esm-browser.prod.js'
},
{
name: 'primevue',
pattern: /^primevue\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/themes',
pattern: /^@primevue\/themes\/?.*/,
entry: './index.mjs',
recursiveDependence: true
},
{
name: '@primevue/forms',
pattern: /^@primevue\/forms\/?.*/,
entry: './index.mjs',
recursiveDependence: true,
override: {
'@primeuix/forms': {
entry: ''
}
}
}
{ name: 'vue', pattern: /[\\/]node_modules[\\/]vue[\\/]/ },
{ name: 'primevue', pattern: /[\\/]node_modules[\\/]primevue[\\/]/ },
{ name: 'vue-i18n', pattern: /[\\/]node_modules[\\/]vue-i18n[\\/]/ }
]),
addElementVnodeExportPlugin(),
Icons({
compiler: 'vue3'