Compare commits

..

5 Commits

Author SHA1 Message Date
Chenlei Hu
33dc74fa2a scroll queue item for long text 2025-05-22 10:39:11 -04:00
Chenlei Hu
bd37da7161 nit 2025-05-22 10:39:11 -04:00
Chenlei Hu
0b8d98e1e7 Add to preview gallery 2025-05-22 10:39:09 -04:00
Chenlei Hu
25a6b9f393 Copy to clipboard 2025-05-22 10:38:52 -04:00
Chenlei Hu
bdf32790c9 wip 2025-05-22 10:38:48 -04:00
134 changed files with 2199 additions and 22215 deletions

View File

@@ -1,43 +0,0 @@
# 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

@@ -1,57 +0,0 @@
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,25 +1,26 @@
# Vue 3 Composition API Project Rules
// Vue 3 Composition API .cursorrules
## Vue 3 Composition API Best Practices
- Use setup() function for component logic
- Utilize ref and reactive for reactive state
- Implement computed properties with computed()
- Use watch and watchEffect for side effects
- Implement lifecycle hooks with onMounted, onUpdated, etc.
- Utilize provide/inject for dependency injection
- Use vue 3.5 style of default prop declaration. Example:
// Vue 3 Composition API best practices
const vue3CompositionApiBestPractices = [
"Use setup() function for component logic",
"Utilize ref and reactive for reactive state",
"Implement computed properties with computed()",
"Use watch and watchEffect for side effects",
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
"Utilize provide/inject for dependency injection",
"Use vue 3.5 style of default prop declaration. Example:
```typescript
const { nodes, showTotal = true } = defineProps<{
nodes: ApiNodeCost[]
showTotal?: boolean
}>()
```
- Organize vue component in <template> <script> <style> order
",
"Organize vue component in <template> <script> <style> order",
]
## Project Structure
```
// Folder structure
const folderStructure = `
src/
components/
constants/
@@ -29,25 +30,16 @@ src/
services/
App.vue
main.ts
```
`;
## Styling Guidelines
- Use Tailwind CSS for styling
- Implement responsive design with Tailwind CSS
// Tailwind CSS best practices
const tailwindCssBestPractices = [
"Use Tailwind CSS for styling",
"Implement responsive design with Tailwind CSS",
]
## PrimeVue Component Guidelines
DO NOT use deprecated PrimeVue components. Use these replacements instead:
- Dropdown → Use Select (import from 'primevue/select')
- OverlayPanel → Use Popover (import from 'primevue/popover')
- Calendar → Use DatePicker (import from 'primevue/datepicker')
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
- Sidebar → Use Drawer (import from 'primevue/drawer')
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
- TabMenu → Use Tabs without panels
- Steps → Use Stepper without panels
- InlineMessage → Use Message component
## Development Guidelines
// Additional instructions
const additionalInstructions = `
1. Leverage VueUse functions for performance-enhancing styles
2. Use lodash for utility functions
3. Use TypeScript for type safety
@@ -57,5 +49,6 @@ DO NOT use deprecated PrimeVue components. Use these replacements instead:
7. Implement proper error handling
8. Follow Vue 3 style guide and naming conventions
9. Use Vite for fast development and building
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
11. Never use deprecated PrimeVue components listed above
10. Use vue-i18n in composition API for any string literals. Place new translation
entries in src/locales/en/main.json.
`;

View File

@@ -29,7 +29,3 @@ DISABLE_TEMPLATES_PROXY=false
# If playwright tests are being run via vite dev server, Vue plugins will
# invalidate screenshots. When `true`, vite plugins will not be loaded.
DISABLE_VUE_PLUGINS=false
# Algolia credentials required for developing with the new custom node manager.
ALGOLIA_APP_ID=4E0RO38HS8
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579

View File

@@ -1,9 +1,7 @@
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:
@@ -12,15 +10,8 @@ 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.
- 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
- **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: textarea
attributes:

View File

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

View File

@@ -66,7 +66,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -136,7 +136,7 @@ jobs:
git commit -m "Update locales"
- name: Install SSH key For PUSH
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
uses: shimataro/ssh-key-action@v2
with:
# PR private key from action server
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}

View File

@@ -33,7 +33,7 @@ jobs:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
working-directory: ComfyUI_frontend
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: "Update locales for node definitions"

View File

@@ -54,7 +54,7 @@ jobs:
name: dist-files
- name: Create release
id: create_release
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
uses: softprops/action-gh-release@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
@@ -93,7 +93,7 @@ jobs:
env:
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
- name: Publish pypi package
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
uses: pypa/gh-action-pypi-publish@release/v1
with:
password: ${{ secrets.PYPI_TOKEN }}
packages-dir: comfyui_frontend_package/dist

View File

@@ -30,7 +30,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -29,7 +29,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update litegraph to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -1,92 +0,0 @@
name: Update ComfyUI-Manager API Types
on:
# Manual trigger
workflow_dispatch:
jobs:
update-manager-types:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Checkout ComfyUI-Manager repository
uses: actions/checkout@v4
with:
repository: Comfy-Org/ComfyUI-Manager
path: ComfyUI-Manager
clean: true
- name: Get Manager commit information
id: manager-info
run: |
cd ComfyUI-Manager
MANAGER_COMMIT=$(git rev-parse --short HEAD)
echo "commit=${MANAGER_COMMIT}" >> $GITHUB_OUTPUT
cd ..
- name: Generate Manager API types
run: |
echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..."
npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
- name: Validate generated types
run: |
if [ ! -f ./src/types/generatedManagerTypes.ts ]; then
echo "Error: Types file was not generated."
exit 1
fi
# Check if file is not empty
if [ ! -s ./src/types/generatedManagerTypes.ts ]; then
echo "Error: Generated types file is empty."
exit 1
fi
- name: Check for changes
id: check-changes
run: |
if [[ -z $(git status --porcelain ./src/types/generatedManagerTypes.ts) ]]; then
echo "No changes to ComfyUI-Manager API types detected."
echo "changed=false" >> $GITHUB_OUTPUT
exit 0
else
echo "Changes detected in ComfyUI-Manager API types."
echo "changed=true" >> $GITHUB_OUTPUT
fi
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
title: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
body: |
## Automated API Type Update
This PR updates the ComfyUI-Manager API types from the latest ComfyUI-Manager OpenAPI specification.
- Manager commit: ${{ steps.manager-info.outputs.commit }}
- Generated on: ${{ github.event.repository.updated_at }}
These types are automatically generated using openapi-typescript.
branch: update-manager-types-${{ steps.manager-info.outputs.commit }}
base: main
labels: Manager
delete-branch: true
add-paths: |
src/types/generatedManagerTypes.ts

View File

@@ -75,7 +75,7 @@ jobs:
- name: Create Pull Request
if: steps.check-changes.outputs.changed == 'true'
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'

View File

@@ -38,7 +38,7 @@ jobs:
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
- name: Create Pull Request
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
uses: peter-evans/create-pull-request@v7
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Bump version to ${{ steps.bump-version.outputs.NEW_VERSION }}'

View File

@@ -1,8 +0,0 @@
{
"mcpServers": {
"playwright": {
"command": "npx",
"args": ["-y", "@executeautomation/playwright-mcp-server"]
}
}
}

View File

@@ -1,10 +1,11 @@
- use npm run to see what commands are available
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
- Never add lines to PR descriptions that say "Generated with Claude Code"
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading speicifc branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
@@ -12,7 +13,7 @@
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- IMPORTANT: Never add Co-Authored by Claude or any refrence to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- The npm script to type check is called "typecheck" NOT "type check"
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
@@ -35,22 +36,3 @@
- Follow Vue 3 style guide and naming conventions
- Use Vite for fast development and building
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
- DO NOT use deprecated PrimeVue components. Use these replacements instead:
* `Dropdown` → Use `Select` (import from 'primevue/select')
* `OverlayPanel` → Use `Popover` (import from 'primevue/popover')
* `Calendar` → Use `DatePicker` (import from 'primevue/datepicker')
* `InputSwitch` → Use `ToggleSwitch` (import from 'primevue/toggleswitch')
* `Sidebar` → Use `Drawer` (import from 'primevue/drawer')
* `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
* `TabMenu` → Use `Tabs` without panels
* `Steps` → Use `Stepper` without panels
* `InlineMessage` → Use `Message` component
* Use `api.apiURL()` for all backend API calls and routes
- Actual API endpoints like /prompt, /queue, /view, etc.
- Image previews: `api.apiURL('/view?...')`
- Any backend-generated content or dynamic routes
* Use `api.fileURL()` for static files served from the public folder:
- Templates: `api.fileURL('/templates/default.json')`
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
- Any static assets that exist in the public directory

162
README.md
View File

@@ -609,68 +609,6 @@ This project includes `.vscode/launch.json.default` and `.vscode/settings.json.d
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
### Recommended MCP Integrations
#### Playwright Browser Automation
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
##### Setup for Claude Code
After installing dependencies with `npm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
```
> navigate to localhost:5173 and take a screenshot.
tell me the items in the menubar
● I'll navigate to localhost:5173 and take a
screenshot to see the menubar items.
● playwright:playwright_navigate (MCP)(url: …
"http://local
host:5173")
⎿ Navigated to http://localhost:5173
● playwright:playwright_screenshot (MCP)(name: "lo…
alhost_5173
_menubar")
⎿ Screenshot saved to: ../../../Downloads/localho
st_5173_menubar-2025-05-30T17-23-00-942Z.png
⎿ Screenshot also stored in memory with name:
'localhost_5173_menubar'
● Read(../../../Downloads/localhost_5173_menubar-2…
25-05-30T17-23-00-942Z.png)
⎿ Read image (94.3KB)
● Based on the screenshot, I can see the menubar at
the top of the ComfyUI interface contains the
following items:
Main Menubar Items:
- Workflow - Menu for workflow-related actions
- Edit - Menu for editing operations
- Help - Menu for help and documentation
Additional UI Elements:
- ComfyUI Logo - On the far left
- Workflow Tab - Shows "Unsaved Workflow" with a
dropdown and close button
- Layout Controls - On the far right (grid view
and hamburger menu icons)
The interface shows a typical ComfyUI workflow
graph with nodes like "Load Checkpoint", "CLIP
Text Encode (Prompt)", "KSampler", and "Empty
Latent Image" connected with colored cables.
```
### Unit Test
- `npm i` to install all dependencies
@@ -703,8 +641,100 @@ See [locales/README.md](src/locales/README.md) for details.
## Troubleshooting
For comprehensive troubleshooting and technical support, please refer to our official documentation:
> **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.
- **[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
> **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.

View File

@@ -1,59 +0,0 @@
{
"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

@@ -762,7 +762,7 @@ export class ComfyPage {
y: 625
}
})
await this.page.mouse.move(10, 10)
this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -774,7 +774,7 @@ export class ComfyPage {
},
button: 'right'
})
await this.page.mouse.move(10, 10)
this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -1046,8 +1046,6 @@ export class ComfyPage {
}
}
export const testComfySnapToGridGridSize = 50
export const comfyPageFixture = base.extend<{
comfyPage: ComfyPage
comfyMouse: ComfyMouse
@@ -1074,8 +1072,7 @@ export const comfyPageFixture = base.extend<{
'Comfy.EnableTooltips': false,
'Comfy.userId': userId,
// Set tutorial completed to true to avoid loading the tutorial workflow.
'Comfy.TutorialCompleted': true,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize
'Comfy.TutorialCompleted': true
})
} catch (e) {
console.error(e)

View File

@@ -103,7 +103,7 @@ test.describe('Missing models warning', () => {
}
])
}
await comfyPage.page.route(
comfyPage.page.route(
'**/api/experiment/models',
(route) => route.fulfill(modelFoldersRes),
{ times: 1 }
@@ -121,7 +121,7 @@ test.describe('Missing models warning', () => {
}
])
}
await comfyPage.page.route(
comfyPage.page.route(
'**/api/experiment/models/text_encoders',
(route) => route.fulfill(clipModelsRes),
{ times: 1 }
@@ -133,18 +133,6 @@ 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 ({

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 99 KiB

View File

@@ -1,12 +1,6 @@
import { expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -63,10 +57,8 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
const dragSelectNodes = async (
comfyPage: ComfyPage,
clipNodes: NodeReference[]
) => {
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
@@ -82,67 +74,10 @@ test.describe('Node Interaction', () => {
}
)
await comfyPage.page.keyboard.up('Meta')
}
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
await dragSelectNodes(comfyPage, clipNodes)
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
clipNodes.length
)
})
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
comfyPage
}) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const getPositions = () =>
Promise.all(clipNodes.map((node) => node.getPosition()))
const testDirection = async ({
direction,
expectedPosition
}: {
direction: string
expectedPosition: (originalPosition: Position) => Position
}) => {
const originalPositions = await getPositions()
await dragSelectNodes(comfyPage, clipNodes)
await comfyPage.executeCommand(
`Comfy.Canvas.MoveSelectedNodes.${direction}`
)
await comfyPage.canvas.press(`Control+Arrow${direction}`)
const newPositions = await getPositions()
expect(newPositions).toEqual(originalPositions.map(expectedPosition))
}
await testDirection({
direction: 'Down',
expectedPosition: (originalPosition) => ({
...originalPosition,
y: originalPosition.y + testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Right',
expectedPosition: (originalPosition) => ({
...originalPosition,
x: originalPosition.x + testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Up',
expectedPosition: (originalPosition) => ({
...originalPosition,
y: originalPosition.y - testComfySnapToGridGridSize
})
})
await testDirection({
direction: 'Left',
expectedPosition: (originalPosition) => ({
...originalPosition,
x: originalPosition.x - testComfySnapToGridGridSize
})
})
})
})
test('Can drag node', async ({ comfyPage }) => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 101 KiB

View File

@@ -32,9 +32,7 @@ test.describe('Templates', () => {
}
})
// TODO: Re-enable this test once issue resolved
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
test.skip('should have all required thumbnail media for each template', async ({
test('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 190 KiB

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 116 KiB

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 238 KiB

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -24,7 +24,7 @@ export default [
},
parser: tseslint.parser,
parserOptions: {
project: ['./tsconfig.json', './tsconfig.eslint.json'],
project: './tsconfig.json',
ecmaVersion: 2020,
sourceType: 'module',
extraFileExtensions: ['.vue']

1899
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.22.1",
"version": "1.21.0",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -29,13 +29,11 @@
},
"devDependencies": {
"@eslint/js": "^9.8.0",
"@executeautomation/playwright-mcp-server": "^1.0.5",
"@iconify/json": "^2.2.245",
"@lobehub/i18n-cli": "^1.20.0",
"@pinia/testing": "^0.1.5",
"@playwright/test": "^1.52.0",
"@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",
@@ -76,7 +74,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.15",
"@comfyorg/litegraph": "^0.15.11",
"@primevue/forms": "^4.2.5",
"@primevue/themes": "^4.2.5",
"@sentry/vue": "^8.48.0",
@@ -93,14 +91,12 @@
"@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

@@ -30,15 +30,6 @@
@click="download.triggerBrowserDownload"
/>
</div>
<div>
<Button
:label="$t('g.copyURL')"
size="small"
outlined
:disabled="!!props.error"
@click="copyURL"
/>
</div>
</div>
</template>
@@ -47,7 +38,6 @@ import Button from 'primevue/button'
import Message from 'primevue/message'
import { computed } from 'vue'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { useDownload } from '@/composables/useDownload'
import { formatSize } from '@/utils/formatUtil'
@@ -59,15 +49,9 @@ const props = defineProps<{
}>()
const label = computed(() => props.label || props.url.split('/').pop())
const hint = computed(() => props.hint || props.url)
const download = useDownload(props.url)
const fileSize = computed(() =>
download.fileSize.value ? formatSize(download.fileSize.value) : '?'
)
const copyURL = async () => {
await copyToClipboard(props.url)
}
const { copyToClipboard } = useCopyToClipboard()
</script>

View File

@@ -1,12 +1,14 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-for="item in dialogStore.dialogStack"
v-for="(item, index) in dialogStore.dialogStack"
:key="item.key"
v-model:visible="item.visible"
class="global-dialog"
v-bind="item.dialogComponentProps"
:auto-z-index="false"
:pt="item.dialogComponentProps.pt"
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
:aria-labelledby="item.key"
>
<template #header>
@@ -33,11 +35,25 @@
</template>
<script setup lang="ts">
import { ZIndex } from '@primeuix/utils/zindex'
import { usePrimeVue } from '@primevue/core'
import Dialog from 'primevue/dialog'
import { computed, onMounted } from 'vue'
import { useDialogStore } from '@/stores/dialogStore'
const dialogStore = useDialogStore()
const primevue = usePrimeVue()
const baseZIndex = computed(() => {
return primevue?.config?.zIndex?.modal ?? 1100
})
onMounted(() => {
const mask = document.createElement('div')
ZIndex.set('model', mask, baseZIndex.value)
})
</script>
<style>

View File

@@ -408,30 +408,19 @@ const handleGridContainerClick = (event: MouseEvent) => {
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
// Track the last pack ID for which we've fetched full registry data
const lastFetchedPackId = ref<string | null>(null)
// Whenever a single pack is selected, fetch its full info once
whenever(selectedNodePack, async () => {
// Cancel any in-flight requests from previously selected node pack
getPackById.cancel()
if (!selectedNodePack.value?.id) return
// If only a single node pack is selected, fetch full node pack info from registry
const pack = selectedNodePack.value
if (!pack?.id) return
if (hasMultipleSelections.value) return
// Only fetch if we haven't already for this pack
if (lastFetchedPackId.value === pack.id) return
const data = await getPackById.call(pack.id)
// If selected node hasn't changed since request, merge registry & Algolia data
if (data?.id === pack.id) {
lastFetchedPackId.value = pack.id
const mergedPack = merge({}, pack, data)
selectedNodePacks.value = [mergedPack]
// Replace pack in displayPacks so that children receive a fresh prop reference
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
if (idx !== -1) {
displayPacks.value.splice(idx, 1, mergedPack)
}
const data = await getPackById.call(selectedNodePack.value.id)
if (data?.id === selectedNodePack.value?.id) {
// If selected node hasn't changed since request, merge registry & Algolia data
selectedNodePacks.value = [merge(selectedNodePack.value, data)]
}
})

View File

@@ -1,64 +0,0 @@
<template>
<div :style="{ width: cssWidth, height: cssHeight }" class="overflow-hidden">
<!-- default banner show -->
<div v-if="showDefaultBanner" class="w-full h-full">
<img
:src="DEFAULT_BANNER"
alt="default banner"
class="w-full h-full object-cover"
/>
</div>
<!-- banner_url or icon show -->
<div v-else class="relative w-full h-full">
<!-- blur background -->
<div
v-if="imgSrc"
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
:style="{
backgroundImage: `url(${imgSrc})`,
filter: 'blur(10px)'
}"
></div>
<!-- image -->
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
:class="
isImageError
? 'relative w-full h-full object-cover z-10'
: 'relative w-full h-full object-contain z-10'
"
@error="isImageError = true"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { components } from '@/types/comfyRegistryTypes'
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
const {
nodePack,
width = '100%',
height = '12rem'
} = defineProps<{
nodePack: components['schemas']['Node'] & { banner?: string } // Temporary measure until banner is in backend
width?: string
height?: string
}>()
const isImageError = ref(false)
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
const convertToCssValue = (value: string | number) =>
typeof value === 'number' ? `${value}rem` : value
const cssWidth = computed(() => convertToCssValue(width))
const cssHeight = computed(() => convertToCssValue(height))
</script>

View File

@@ -7,15 +7,19 @@
}"
:pt="{
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
content: { class: 'flex-1 flex flex-col rounded-2xl min-h-0' },
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
content: { class: 'flex-1 flex flex-col rounded-2xl' },
title: {
class:
'self-stretch w-full px-4 py-3 inline-flex justify-start items-center gap-6'
},
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<PackBanner :node-pack="nodePack" />
<PackCardHeader :node-pack="nodePack" />
</template>
<template #content>
<ContentDivider />
<template v-if="isInstalling">
<div
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
@@ -30,63 +34,46 @@
</template>
<template v-else>
<div
class="self-stretch inline-flex flex-col justify-start items-start"
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
>
<PackIcon :node-pack="nodePack" />
<div
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
>
<div
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
<span
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<div
class="self-stretch inline-flex justify-center items-center gap-2.5"
>
<span
class="text-base font-bold truncate overflow-hidden text-ellipsis"
>
{{ nodePack.name }}
</span>
<p
v-if="nodePack.description"
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
>
{{ nodePack.description }}
</p>
<div class="flex flex-col gap-y-2">
</div>
<div
class="self-stretch inline-flex justify-start items-center gap-2"
>
<div
v-if="nodesCount"
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div class="text-center justify-center font-medium leading-3">
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
class="self-stretch inline-flex justify-start items-center gap-1"
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<div
v-if="nodesCount"
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
>
<div
class="text-center justify-center font-medium leading-3"
>
{{ nodesCount }} {{ $t('g.nodes') }}
</div>
</div>
<div class="px-2 py-1 flex justify-center items-center gap-1">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
<div
v-if="formattedLatestVersionDate"
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
>
{{ formattedLatestVersionDate }}
</div>
</div>
<div class="flex">
<span
v-if="publisherName"
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
>
{{ publisherName }}
</span>
<i class="pi pi-arrow-circle-up text-blue-600" />
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
</div>
</div>
@@ -105,12 +92,11 @@ import { whenever } from '@vueuse/core'
import Card from 'primevue/card'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, provide, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import ContentDivider from '@/components/common/ContentDivider.vue'
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
import { IsInstallingKey } from '@/types/comfyManagerTypes'
@@ -121,8 +107,6 @@ const { nodePack, isSelected = false } = defineProps<{
isSelected?: boolean
}>()
const { d } = useI18n()
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
@@ -138,19 +122,4 @@ whenever(isInstalled, () => (isInstalling.value = false))
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
const publisherName = computed(() => {
if (!nodePack) return null
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
const formattedLatestVersionDate = computed(() => {
if (!nodePack.latest_version?.createdAt) return null
return d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
})
</script>

View File

@@ -1,29 +1,39 @@
<template>
<div
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
>
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
<div class="flex items-center gap-2 cursor-pointer">
<span v-if="publisherName" class="max-w-40 truncate">
{{ publisherName }}
</span>
</div>
<div
v-if="nodePack.latest_version?.createdAt"
class="flex items-center gap-2 truncate"
>
{{ $t('g.updated') }}
{{
$d(new Date(nodePack.latest_version.createdAt), {
dateStyle: 'medium'
})
}}
</div>
<PackInstallButton :node-packs="[nodePack]" />
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
import type { components } from '@/types/comfyRegistryTypes'
const { nodePack } = defineProps<{
nodePack: components['schemas']['Node']
}>()
const { n } = useI18n()
const publisherName = computed(() => {
if (!nodePack) return null
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
</script>

View File

@@ -54,21 +54,4 @@ describe('SettingItem', () => {
{ text: 'Correctly Translated', value: 'Correctly Translated' }
])
})
it('handles tooltips with @ symbols without errors', () => {
const wrapper = mountComponent({
setting: {
id: 'TestSetting',
name: 'Test Setting',
type: 'boolean',
tooltip:
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
}
})
// Should not throw an error and tooltip should be preserved as-is
expect(wrapper.vm.formItem.tooltip).toBe(
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
)
})
})

View File

@@ -28,7 +28,6 @@ import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import FormItem from '@/components/common/FormItem.vue'
import { st } from '@/i18n'
import { useSettingStore } from '@/stores/settingStore'
import type { SettingOption, SettingParams } from '@/types/settingTypes'
import { normalizeI18nKey } from '@/utils/formatUtil'
@@ -65,7 +64,7 @@ const formItem = computed(() => {
...props.setting,
name: t(`settings.${normalizedId}.name`, props.setting.name),
tooltip: props.setting.tooltip
? st(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
? t(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
: undefined,
options: props.setting.options
? translateOptions(props.setting.options)

View File

@@ -1,293 +0,0 @@
import { Form } from '@primevue/forms'
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import PrimeVue from 'primevue/config'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import ToastService from 'primevue/toastservice'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import SignInForm from './SignInForm.vue'
type ComponentInstance = InstanceType<typeof SignInForm>
// Mock firebase auth modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn(),
sendPasswordResetEmail: vi.fn()
}))
// Mock the auth composables and stores
const mockSendPasswordReset = vi.fn()
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
sendPasswordReset: mockSendPasswordReset
}))
}))
let mockLoading = false
vi.mock('@/stores/firebaseAuthStore', () => ({
useFirebaseAuthStore: vi.fn(() => ({
get loading() {
return mockLoading
}
}))
}))
// Mock toast
const mockToastAdd = vi.fn()
vi.mock('primevue/usetoast', () => ({
useToast: vi.fn(() => ({
add: mockToastAdd
}))
}))
describe('SignInForm', () => {
beforeEach(() => {
vi.clearAllMocks()
mockSendPasswordReset.mockReset()
mockToastAdd.mockReset()
mockLoading = false
})
const mountComponent = (
props = {},
options = {}
): VueWrapper<ComponentInstance> => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(SignInForm, {
global: {
plugins: [PrimeVue, i18n, ToastService],
components: {
Form,
Button,
InputText,
Password,
ProgressSpinner
}
},
props,
...options
})
}
describe('Forgot Password Link', () => {
it('shows disabled style when email is empty', async () => {
const wrapper = mountComponent()
await nextTick()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
expect(forgotPasswordSpan.classes()).toContain('text-link-disabled')
})
it('shows toast and focuses email input when clicked while disabled', async () => {
const wrapper = mountComponent()
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Click forgot password link while email is empty
await forgotPasswordSpan.trigger('click')
await nextTick()
// Should show toast warning
expect(mockToastAdd).toHaveBeenCalledWith({
severity: 'warn',
summary: enMessages.auth.login.emailPlaceholder,
life: 5000
})
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
// Should NOT call sendPasswordReset
expect(mockSendPasswordReset).not.toHaveBeenCalled()
})
it('calls handleForgotPassword with email when link is clicked', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Spy on handleForgotPassword
const handleForgotPasswordSpy = vi.spyOn(
component,
'handleForgotPassword'
)
const forgotPasswordSpan = wrapper.find(
'span.text-muted.text-base.font-medium.cursor-pointer'
)
// Click the forgot password link
await forgotPasswordSpan.trigger('click')
// Should call handleForgotPassword
expect(handleForgotPasswordSpy).toHaveBeenCalled()
})
})
describe('Form Submission', () => {
it('emits submit event when onSubmit is called with valid data', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit directly with valid data
component.onSubmit({
valid: true,
values: { email: 'test@example.com', password: 'password123' }
})
// Check emitted event
expect(wrapper.emitted('submit')).toBeTruthy()
expect(wrapper.emitted('submit')?.[0]).toEqual([
{
email: 'test@example.com',
password: 'password123'
}
])
})
it('does not emit submit event when form is invalid', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Call onSubmit with invalid form
component.onSubmit({ valid: false, values: {} })
// Should not emit submit event
expect(wrapper.emitted('submit')).toBeFalsy()
})
})
describe('Loading State', () => {
it('shows spinner when loading', async () => {
mockLoading = true
try {
const wrapper = mountComponent()
await nextTick()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(true)
expect(wrapper.findComponent(Button).exists()).toBe(false)
} catch (error) {
// Fallback test - check HTML content if component rendering fails
mockLoading = true
const wrapper = mountComponent()
expect(wrapper.html()).toContain('p-progressspinner')
expect(wrapper.html()).not.toContain('<button')
}
})
it('shows button when not loading', () => {
mockLoading = false
const wrapper = mountComponent()
expect(wrapper.findComponent(ProgressSpinner).exists()).toBe(false)
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
})
describe('Component Structure', () => {
it('renders email input with correct attributes', () => {
const wrapper = mountComponent()
const emailInput = wrapper.findComponent(InputText)
expect(emailInput.attributes('id')).toBe('comfy-org-sign-in-email')
expect(emailInput.attributes('autocomplete')).toBe('email')
expect(emailInput.attributes('name')).toBe('email')
expect(emailInput.attributes('type')).toBe('text')
})
it('renders password input with correct attributes', () => {
const wrapper = mountComponent()
const passwordInput = wrapper.findComponent(Password)
// Check props instead of attributes for Password component
expect(passwordInput.props('inputId')).toBe('comfy-org-sign-in-password')
// Password component passes name as prop, not attribute
expect(passwordInput.props('name')).toBe('password')
expect(passwordInput.props('feedback')).toBe(false)
expect(passwordInput.props('toggleMask')).toBe(true)
})
it('renders form with correct resolver', () => {
const wrapper = mountComponent()
const form = wrapper.findComponent(Form)
expect(form.props('resolver')).toBeDefined()
})
})
describe('Focus Behavior', () => {
it('focuses email input when handleForgotPassword is called with invalid email', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById to track focus
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with no email
await component.handleForgotPassword('', false)
// Should focus email input
expect(document.getElementById).toHaveBeenCalledWith(
'comfy-org-sign-in-email'
)
expect(mockFocus).toHaveBeenCalled()
})
it('does not focus email input when valid email is provided', async () => {
const wrapper = mountComponent()
const component = wrapper.vm as any
// Mock getElementById
const mockFocus = vi.fn()
const mockElement = { focus: mockFocus }
vi.spyOn(document, 'getElementById').mockReturnValue(mockElement as any)
// Call handleForgotPassword with valid email
await component.handleForgotPassword('test@example.com', true)
// Should NOT focus email input
expect(document.getElementById).not.toHaveBeenCalled()
expect(mockFocus).not.toHaveBeenCalled()
// Should call sendPasswordReset
expect(mockSendPasswordReset).toHaveBeenCalledWith('test@example.com')
})
})
})

View File

@@ -7,12 +7,15 @@
>
<!-- Email Field -->
<div class="flex flex-col gap-2">
<label class="opacity-80 text-base font-medium mb-2" :for="emailInputId">
<label
class="opacity-80 text-base font-medium mb-2"
for="comfy-org-sign-in-email"
>
{{ t('auth.login.emailLabel') }}
</label>
<InputText
:id="emailInputId"
autocomplete="email"
pt:root:id="comfy-org-sign-in-email"
pt:root:autocomplete="email"
class="h-10"
name="email"
type="text"
@@ -34,11 +37,8 @@
{{ t('auth.login.passwordLabel') }}
</label>
<span
class="text-muted text-base font-medium cursor-pointer select-none"
:class="{
'text-link-disabled': !$form.email?.value || $form.email?.invalid
}"
@click="handleForgotPassword($form.email?.value, $form.email?.valid)"
class="text-muted text-base font-medium cursor-pointer"
@click="handleForgotPassword($form.email?.value)"
>
{{ t('auth.login.forgotPassword') }}
</span>
@@ -77,7 +77,6 @@ import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import Password from 'primevue/password'
import ProgressSpinner from 'primevue/progressspinner'
import { useToast } from 'primevue/usetoast'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -88,7 +87,6 @@ import { useFirebaseAuthStore } from '@/stores/firebaseAuthStore'
const authStore = useFirebaseAuthStore()
const firebaseAuthActions = useFirebaseAuthActions()
const loading = computed(() => authStore.loading)
const toast = useToast()
const { t } = useI18n()
@@ -96,34 +94,14 @@ const emit = defineEmits<{
submit: [values: SignInData]
}>()
const emailInputId = 'comfy-org-sign-in-email'
const onSubmit = (event: FormSubmitEvent) => {
if (event.valid) {
emit('submit', event.values as SignInData)
}
}
const handleForgotPassword = async (
email: string,
isValid: boolean | undefined
) => {
if (!email || !isValid) {
toast.add({
severity: 'warn',
summary: t('auth.login.emailPlaceholder'),
life: 5_000
})
// Focus the email input
document.getElementById(emailInputId)?.focus?.()
return
}
const handleForgotPassword = async (email: string) => {
if (!email) return
await firebaseAuthActions.sendPasswordReset(email)
}
</script>
<style scoped>
.text-link-disabled {
@apply opacity-50 cursor-not-allowed;
}
</style>

View File

@@ -18,7 +18,6 @@
:key="command.id"
:command="command"
/>
<HelpButton />
</Panel>
</template>
@@ -31,7 +30,6 @@ import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerBu
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

@@ -1,49 +0,0 @@
<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

@@ -150,8 +150,8 @@ const handleStopRecording = () => {
if (load3DSceneRef.value?.load3d) {
load3DSceneRef.value.load3d.stopRecording()
isRecording.value = false
hasRecording.value = true
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
@@ -294,8 +294,8 @@ const listenRecordingStatusChange = (value: boolean) => {
isRecording.value = value
if (!value && load3DSceneRef.value?.load3d) {
hasRecording.value = true
recordingDuration.value = load3DSceneRef.value.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}

View File

@@ -168,8 +168,8 @@ const handleStopRecording = () => {
if (sceneRef?.load3d) {
sceneRef.load3d.stopRecording()
isRecording.value = false
hasRecording.value = true
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
@@ -197,8 +197,8 @@ const listenRecordingStatusChange = (value: boolean) => {
if (!value) {
const sceneRef = load3DAnimationSceneRef.value?.load3DSceneRef
if (sceneRef?.load3d) {
hasRecording.value = true
recordingDuration.value = sceneRef.load3d.getRecordingDuration()
hasRecording.value = recordingDuration.value > 0
}
}
}

View File

@@ -99,6 +99,8 @@ const emit = defineEmits<{
}>()
const resizeNodeMatchOutput = () => {
console.log('resizeNodeMatchOutput')
const outputWidth = node.widgets?.find((w) => w.name === 'width')
const outputHeight = node.widgets?.find((w) => w.name === 'height')

View File

@@ -166,11 +166,10 @@ const showContextMenu = (e: CanvasPointerEvent) => {
showSearchBox(e)
}
}
const afterRerouteId = firstLink.fromReroute?.id
const connectionOptions =
toType === 'input'
? { nodeFrom: node, slotFrom: fromSlot, afterRerouteId }
: { nodeTo: node, slotTo: fromSlot, afterRerouteId }
? { nodeFrom: node, slotFrom: fromSlot }
: { nodeTo: node, slotTo: fromSlot }
const canvas = canvasStore.getCanvas()
const menu = canvas.showConnectionMenu({

View File

@@ -1,124 +1,67 @@
<template>
<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"
/>
<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"
/>
<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>
<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>
<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'
@@ -130,31 +73,24 @@ 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 {
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 {
ComfyNodeDefImpl,
buildNodeDefTree,
useNodeDefStore
} from '@/stores/nodeDefStore'
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)
@@ -162,70 +98,13 @@ const nodeBookmarkTreeExplorerRef = ref<InstanceType<
typeof NodeBookmarkTreeExplorer
> | null>(null)
const searchFilter = ref<InstanceType<typeof Popover> | null>(null)
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 alphabeticalSort = ref(false)
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(() => {
// 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 root = filteredRoot.value || nodeDefStore.nodeTree
return alphabeticalSort.value ? sortedTree(root, { groupLeaf: true }) : root
})
const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
@@ -265,6 +144,12 @@ 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([])
@@ -290,10 +175,8 @@ const handleSearch = async (query: string) => {
)
await nextTick()
// Expand the search results tree
if (filteredNodeDefs.value.length > 0) {
expandNode(root.value)
}
// @ts-expect-error fixme ts strict error
expandNode(filteredRoot.value)
}
const onAddFilter = async (

View File

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

View File

@@ -1,230 +0,0 @@
<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,15 +22,6 @@
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>
@@ -63,7 +54,6 @@ 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

@@ -36,6 +36,7 @@
/>
<ResultVideo v-else-if="item.isVideo" :result="item" />
<ResultAudio v-else-if="item.isAudio" :result="item" />
<ResultText v-else-if="item.isText" :result="item" />
</template>
</Galleria>
</template>
@@ -48,12 +49,13 @@ import ComfyImage from '@/components/common/ComfyImage.vue'
import { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultText from './ResultText.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)
const emit = defineEmits<{
(e: 'update:activeIndex', value: number): void
'update:activeIndex': [number]
}>()
const props = defineProps<{

View File

@@ -13,6 +13,7 @@
/>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<ResultAudio v-else-if="result.isAudio" :result="result" />
<ResultText v-else-if="result.isText" :result="result" />
<div v-else class="task-result-preview">
<i class="pi pi-file" />
<span>{{ result.mediaType }}</span>
@@ -28,6 +29,7 @@ import { ResultItemImpl } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import ResultAudio from './ResultAudio.vue'
import ResultText from './ResultText.vue'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{

View File

@@ -0,0 +1,81 @@
<template>
<div class="result-text-container">
<div class="text-content">
{{ result.text }}
</div>
<Button
class="copy-button"
icon="pi pi-copy"
text
@click.stop="copyToClipboard(result.text ?? '')"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { useCopyToClipboard } from '@/composables/useCopyToClipboard'
import { ResultItemImpl } from '@/stores/queueStore'
defineProps<{
result: ResultItemImpl
}>()
const { copyToClipboard } = useCopyToClipboard()
</script>
<style scoped>
.result-text-container {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
position: relative;
cursor: pointer;
padding: 1rem;
text-align: center;
word-break: break-word;
}
.text-content {
font-size: 0.875rem;
color: var(--text-color);
width: 100%;
max-height: 100%;
max-width: 80vw;
overflow-y: auto;
line-height: 1.5;
padding-right: 0.5rem;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* Hide scrollbar but keep functionality */
.text-content::-webkit-scrollbar {
width: 0;
background: transparent;
}
.copy-button {
position: absolute;
top: 0.5rem;
right: 0.5rem;
opacity: 0;
transition: opacity 0.2s ease;
color: var(--text-color-secondary);
padding: 0.25rem;
border-radius: 0.25rem;
background-color: var(--surface-ground);
}
.result-text-container:hover .copy-button {
opacity: 1;
}
.copy-button:hover {
background-color: var(--surface-hover);
}
</style>

View File

@@ -46,68 +46,10 @@ vi.mock('@vueuse/core', () => ({
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => `/fileURL${path}`,
apiURL: (path: string) => `/apiURL${path}`,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
apiURL: (path: string) => `/apiURL${path}`
}
}))
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: () => ({
closeDialog: vi.fn()
})
}))
vi.mock('@/stores/workflowTemplatesStore', () => ({
useWorkflowTemplatesStore: () => ({
isLoaded: true,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
groupedTemplates: []
})
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, fallback: string) => fallback || key
})
}))
vi.mock('@/composables/useTemplateWorkflows', () => ({
useTemplateWorkflows: () => ({
getTemplateThumbnailUrl: (
template: TemplateInfo,
sourceModule: string,
index = ''
) => {
const basePath =
sourceModule === 'default'
? `/fileURL/templates/${template.name}`
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
},
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
},
getTemplateDescription: (template: TemplateInfo, sourceModule: string) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
},
loadWorkflowTemplate: vi.fn()
})
}))
describe('TemplateWorkflowCard', () => {
const createTemplate = (overrides = {}): TemplateInfo => ({
name: 'test-template',

View File

@@ -86,7 +86,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { api } from '@/scripts/api'
import { TemplateInfo } from '@/types/workflowTemplateTypes'
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
@@ -102,36 +102,36 @@ const { sourceModule, loading, template } = defineProps<{
const cardRef = ref<HTMLElement | null>(null)
const isHovered = useElementHover(cardRef)
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
useTemplateWorkflows()
const getThumbnailUrl = (index = '') => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
// Determine the effective source module to use (from template or prop)
const effectiveSourceModule = computed(
() => template.sourceModule || sourceModule
)
// For templates from custom nodes, multiple images is not yet supported
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
const baseThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '1' : ''
)
getThumbnailUrl(sourceModule === 'default' ? '1' : '')
)
const overlayThumbnailSrc = computed(() =>
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '2' : ''
)
getThumbnailUrl(sourceModule === 'default' ? '2' : '')
)
const description = computed(() =>
getTemplateDescription(template, effectiveSourceModule.value)
)
const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
const description = computed(() => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description.replace(/[-_]/g, ' ').trim()
})
const title = computed(() => {
return sourceModule === 'default'
? template.localizedTitle ?? ''
: template.name
})
defineEmits<{
loadWorkflow: [name: string]

View File

@@ -1,19 +1,21 @@
<template>
<DataTable
v-model:selection="selectedTemplate"
:value="enrichedTemplates"
:value="templates"
striped-rows
selection-mode="single"
>
<Column field="title" :header="$t('g.title')">
<template #body="slotProps">
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
<span :title="getTemplateTitle(slotProps.data)">{{
getTemplateTitle(slotProps.data)
}}</span>
</template>
</Column>
<Column field="description" :header="$t('g.description')">
<template #body="slotProps">
<span :title="slotProps.data.description">
{{ slotProps.data.description }}
<span :title="getTemplateDescription(slotProps.data)">
{{ getTemplateDescription(slotProps.data) }}
</span>
</template>
</Column>
@@ -36,9 +38,8 @@
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
const { sourceModule, loading, templates } = defineProps<{
@@ -49,20 +50,21 @@ const { sourceModule, loading, templates } = defineProps<{
}>()
const selectedTemplate = ref(null)
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
const enrichedTemplates = computed(() => {
return templates.map((template) => {
const actualSourceModule = template.sourceModule || sourceModule
return {
...template,
title: getTemplateTitle(template, actualSourceModule),
description: getTemplateDescription(template, actualSourceModule)
}
})
})
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
const getTemplateTitle = (template: TemplateInfo) => {
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
}
const getTemplateDescription = (template: TemplateInfo) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description.replace(/[-_]/g, ' ').trim()
}
</script>

View File

@@ -20,12 +20,12 @@
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
>
<ProgressSpinner
v-if="!isTemplatesLoaded || !isReady"
v-if="!workflowTemplatesStore.isLoaded || !isReady"
class="absolute w-8 h-full inset-0"
/>
<TemplateWorkflowsSideNav
:tabs="allTemplateGroups"
:selected-tab="selectedTemplate"
:tabs="tabs"
:selected-tab="selectedTab"
@update:selected-tab="handleTabSelection"
/>
</aside>
@@ -37,14 +37,14 @@
}"
>
<TemplateWorkflowView
v-if="isReady && selectedTemplate"
v-if="isReady && selectedTab"
class="px-12 py-4"
:title="selectedTemplate.title"
:source-module="selectedTemplate.moduleName"
:templates="selectedTemplate.templates"
:loading="loadingTemplateId"
:category-title="selectedTemplate.title"
@load-workflow="handleLoadWorkflow"
:title="selectedTab.title"
:source-module="selectedTab.moduleName"
:templates="selectedTab.templates"
:loading="workflowLoading"
:category-title="selectedTab.title"
@load-workflow="loadWorkflow"
/>
</div>
</div>
@@ -56,46 +56,47 @@ import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
const { t } = useI18n()
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const {
selectedTemplate,
loadingTemplateId,
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
watch(
isReady,
() => {
if (isReady.value) {
selectFirstTemplateCategory()
}
},
{ once: true }
const workflowTemplatesStore = useWorkflowTemplatesStore()
const { isReady } = useAsyncState(
workflowTemplatesStore.loadWorkflowTemplates,
null
)
const selectedTab = ref<WorkflowTemplates | null>(null)
const selectFirstTab = () => {
const firstTab = workflowTemplatesStore.groupedTemplates[0].modules[0]
handleTabSelection(firstTab)
}
watch(isReady, selectFirstTab, { once: true })
const workflowLoading = ref<string | null>(null)
const tabs = computed(() => workflowTemplatesStore.groupedTemplates)
const handleTabSelection = (selection: WorkflowTemplates | null) => {
if (selection !== null) {
selectTemplateCategory(selection)
//Listbox allows deselecting so this special case is ignored here
if (selection !== selectedTab.value && selection !== null) {
selectedTab.value = selection
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
@@ -104,9 +105,30 @@ const handleTabSelection = (selection: WorkflowTemplates | null) => {
}
}
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false
const loadWorkflow = async (id: string) => {
if (!isReady.value) return
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
workflowLoading.value = id
let json
if (selectedTab.value?.moduleName === 'default') {
// Default templates provided by frontend are served on this separate endpoint
json = await fetch(api.fileURL(`/templates/${id}.json`)).then((r) =>
r.json()
)
} else {
json = await fetch(
api.apiURL(
`/workflow_templates/${selectedTab.value?.moduleName}/${id}.json`
)
).then((r) => r.json())
}
useDialogStore().closeDialog()
const workflowName =
selectedTab.value?.moduleName === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
await app.loadGraphData(json, true, true, workflowName)
return false
}
</script>

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: 50%')
expect(divider.attributes('style')).toContain('left: 21%')
})
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 = 50
const SLIDER_START_POSITION = 21
const { baseImageSrc, overlayImageSrc, isHovered, isVideo } = defineProps<{
baseImageSrc: string

View File

@@ -50,11 +50,9 @@ 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),
logout: mockLogout
fetchBalance: vi.fn().mockResolvedValue(undefined)
}))
}))
@@ -102,7 +100,8 @@ describe('CurrentUserPopover', () => {
global: {
plugins: [i18n],
stubs: {
Divider: true
Divider: true,
Button: true
}
}
})
@@ -115,18 +114,6 @@ 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()
@@ -145,30 +132,12 @@ 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 (third one now)
// Find all buttons and get the API pricing button (second one)
const buttons = wrapper.findAllComponents(Button)
const apiPricingButton = buttons[2]
const apiPricingButton = buttons[1]
// Click the API pricing button
await apiPricingButton.trigger('click')

View File

@@ -37,18 +37,6 @@
<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')"
@@ -102,11 +90,6 @@ 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

@@ -101,7 +101,6 @@ Composables for sidebar functionality:
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
- `useQueueSidebarTab` - Manages the queue sidebar tab
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
- `useTemplateWorkflows` - Manages template workflow loading, selection, and display
### Widgets

View File

@@ -7,7 +7,6 @@ import _ from 'lodash'
import { computed, onMounted, watch } from 'vue'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
@@ -112,15 +111,10 @@ export const useNodeBadge = () => {
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
// Get the pricing function to determine if this node has dynamic pricing
const pricingConfig = nodePricing.getNodePricingConfig(node)
const hasDynamicPricing =
typeof pricingConfig?.displayPrice === 'function'
let creditsBadge
const createBadge = () => {
const price = nodePricing.getNodeDisplayPrice(node)
const price = nodePricing.getNodeDisplayPrice(node)
// Always add the badge for API nodes, with or without price text
const creditsBadge = computed(() => {
// Use dynamic background color based on the theme
const isLightTheme =
colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
@@ -143,24 +137,7 @@ export const useNodeBadge = () => {
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
}
if (hasDynamicPricing) {
// For dynamic pricing nodes, use computed that watches widget changes
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
node.constructor.nodeData?.name
)
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
widgetNames: relevantWidgetNames,
triggerCanvasRedraw: true
})
creditsBadge = computedWithWidgetWatch(createBadge)
} else {
// For static pricing nodes, use regular computed
creditsBadge = computed(createBadge)
}
})
node.badges.push(() => creditsBadge.value)
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,85 +0,0 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { computedWithControl } from '@vueuse/core'
import { type ComputedRef, ref } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback'
export interface UseComputedWithWidgetWatchOptions {
/**
* Names of widgets to observe for changes.
* If not provided, all widgets will be observed.
*/
widgetNames?: string[]
/**
* Whether to trigger a canvas redraw when widget values change.
* @default false
*/
triggerCanvasRedraw?: boolean
}
/**
* A composable that creates a computed that has a node's widget values as a dependencies.
* Essentially `computedWithControl` (https://vueuse.org/shared/computedWithControl/) where
* the explicitly defined extra dependencies are LGraphNode widgets.
*
* @param node - The LGraphNode whose widget values are to be watched
* @param options - Configuration options for the watcher
* @returns A function to create computed that responds to widget changes
*
* @example
* ```ts
* const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
* widgetNames: ['width', 'height'],
* triggerCanvasRedraw: true
* })
*
* const dynamicPrice = computedWithWidgetWatch(() => {
* return calculatePrice(node)
* })
* ```
*/
export const useComputedWithWidgetWatch = (
node: LGraphNode,
options: UseComputedWithWidgetWatchOptions = {}
) => {
const { widgetNames, triggerCanvasRedraw = false } = options
// Create a reactive trigger based on widget values
const widgetValues = ref<Record<string, any>>({})
// Initialize widget observers
if (node.widgets) {
const widgetsToObserve = widgetNames
? node.widgets.filter((widget) => widgetNames.includes(widget.name))
: node.widgets
// Initialize current values
const currentValues: Record<string, any> = {}
widgetsToObserve.forEach((widget) => {
currentValues[widget.name] = widget.value
})
widgetValues.value = currentValues
widgetsToObserve.forEach((widget) => {
widget.callback = useChainCallback(widget.callback, () => {
// Update the reactive widget values
widgetValues.value = {
...widgetValues.value,
[widget.name]: widget.value
}
// Optionally trigger a canvas redraw
if (triggerCanvasRedraw) {
node.graph?.setDirtyCanvas(true, true)
}
})
})
}
// Returns a function that creates a computed that responds to widget changes.
// The computed will be re-evaluated whenever any observed widget changes.
return <T>(computeFn: () => T): ComputedRef<T> => {
return computedWithControl(widgetValues, computeFn)
}
}

View File

@@ -4,7 +4,6 @@ import {
LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import { Point } from '@comfyorg/litegraph'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import {
@@ -28,8 +27,6 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
const moveSelectedNodesVersionAdded = '1.22.2'
export function useCoreCommands(): ComfyCommand[] {
const workflowService = useWorkflowService()
const workflowStore = useWorkflowStore()
@@ -61,20 +58,6 @@ export function useCoreCommands(): ComfyCommand[] {
})
}
const moveSelectedNodes = (
positionUpdater: (pos: Point, gridSize: number) => Point
) => {
const selectedNodes = getSelectedNodes()
if (selectedNodes.length === 0) return
const gridSize = useSettingStore().get('Comfy.SnapToGrid.GridSize')
selectedNodes.forEach((node) => {
node.pos = positionUpdater(node.pos, gridSize)
})
app.canvas.state.selectionChanged = true
app.canvas.setDirty(true, true)
}
const commands = [
{
id: 'Comfy.NewBlankWorkflow',
@@ -690,34 +673,6 @@ export function useCoreCommands(): ComfyCommand[] {
function: async () => {
await firebaseAuthActions.logout()
}
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Up',
icon: 'pi pi-arrow-up',
label: 'Move Selected Nodes Up',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y - gridSize])
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Down',
icon: 'pi pi-arrow-down',
label: 'Move Selected Nodes Down',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y + gridSize])
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Left',
icon: 'pi pi-arrow-left',
label: 'Move Selected Nodes Left',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x - gridSize, y])
},
{
id: 'Comfy.Canvas.MoveSelectedNodes.Right',
icon: 'pi pi-arrow-right',
label: 'Move Selected Nodes Right',
versionAdded: moveSelectedNodesVersionAdded,
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
}
]

View File

@@ -1,190 +0,0 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import type {
TemplateGroup,
TemplateInfo,
WorkflowTemplates
} from '@/types/workflowTemplateTypes'
export function useTemplateWorkflows() {
const { t } = useI18n()
const workflowTemplatesStore = useWorkflowTemplatesStore()
const dialogStore = useDialogStore()
// State
const selectedTemplate = ref<WorkflowTemplates | null>(null)
const loadingTemplateId = ref<string | null>(null)
// Computed
const isTemplatesLoaded = computed(() => workflowTemplatesStore.isLoaded)
const allTemplateGroups = computed<TemplateGroup[]>(
() => workflowTemplatesStore.groupedTemplates
)
/**
* Loads all template workflows from the API
*/
const loadTemplates = async () => {
if (!workflowTemplatesStore.isLoaded) {
await workflowTemplatesStore.loadWorkflowTemplates()
}
return workflowTemplatesStore.isLoaded
}
/**
* Selects the first template category as default
*/
const selectFirstTemplateCategory = () => {
if (allTemplateGroups.value.length > 0) {
const firstCategory = allTemplateGroups.value[0].modules[0]
selectTemplateCategory(firstCategory)
}
}
/**
* Selects a template category
*/
const selectTemplateCategory = (category: WorkflowTemplates | null) => {
selectedTemplate.value = category
return category !== null
}
/**
* Gets template thumbnail URL
*/
const getTemplateThumbnailUrl = (
template: TemplateInfo,
sourceModule: string,
index = ''
) => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
/**
* Gets formatted template title
*/
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
const fallback =
template.title ?? template.name ?? `${sourceModule} Template`
return sourceModule === 'default'
? template.localizedTitle ?? fallback
: fallback
}
/**
* Gets formatted template description
*/
const getTemplateDescription = (
template: TemplateInfo,
sourceModule: string
) => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
}
/**
* Loads a workflow template
*/
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
if (!isTemplatesLoaded.value) return false
loadingTemplateId.value = id
let json
try {
// Handle "All" category as a special case
if (sourceModule === 'all') {
// Find "All" category in the ComfyUI Examples group
const comfyExamplesGroup = allTemplateGroups.value.find(
(g) =>
g.label ===
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
)
const allCategory = comfyExamplesGroup?.modules.find(
(m) => m.moduleName === 'all'
)
const template = allCategory?.templates.find((t) => t.name === id)
if (!template || !template.sourceModule) return false
// Use the stored source module for loading
const actualSourceModule = template.sourceModule
json = await fetchTemplateJson(id, actualSourceModule)
// Use source module for name
const workflowName =
actualSourceModule === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)
return true
}
// Regular case for normal categories
json = await fetchTemplateJson(id, sourceModule)
const workflowName =
sourceModule === 'default'
? t(`templateWorkflows.template.${id}`, id)
: id
dialogStore.closeDialog()
await app.loadGraphData(json, true, true, workflowName)
return true
} catch (error) {
console.error('Error loading workflow template:', error)
return false
} finally {
loadingTemplateId.value = null
}
}
/**
* Fetches template JSON from the appropriate endpoint
*/
const fetchTemplateJson = async (id: string, sourceModule: string) => {
if (sourceModule === 'default') {
// Default templates provided by frontend are served on this separate endpoint
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
} else {
return fetch(
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
).then((r) => r.json())
}
}
return {
// State
selectedTemplate,
loadingTemplateId,
// Computed
isTemplatesLoaded,
allTemplateGroups,
// Methods
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
getTemplateThumbnailUrl,
getTemplateTitle,
getTemplateDescription,
loadWorkflowTemplate
}
}

View File

@@ -1,16 +1,11 @@
import {
BaseWidget,
type CanvasPointer,
type LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import { type LGraphNode, LiteGraph } from '@comfyorg/litegraph'
import type {
IBaseWidget,
ICustomWidget,
IWidgetOptions
} from '@comfyorg/litegraph/dist/types/widgets'
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { app } from '@/scripts/app'
import { calculateImageGrid } from '@/scripts/ui/imagePreview'
import { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useCanvasStore } from '@/stores/graphStore'
@@ -240,61 +235,34 @@ const renderPreview = (
}
}
class ImagePreviewWidget extends BaseWidget {
constructor(
class ImagePreviewWidget implements ICustomWidget {
readonly type: 'custom'
readonly name: string
readonly options: IWidgetOptions<string | object>
/** Dummy value to satisfy type requirements. */
value: string
y: number = 0
/** Don't serialize the widget value. */
serialize: boolean = false
constructor(name: string, options: IWidgetOptions<string | object>) {
this.type = 'custom'
this.name = name
this.options = options
this.value = ''
}
draw(
ctx: CanvasRenderingContext2D,
node: LGraphNode,
name: string,
options: IWidgetOptions<string | object>
) {
const widget: IBaseWidget = {
name,
options,
type: 'custom',
/** Dummy value to satisfy type requirements. */
value: '',
y: 0
}
super(widget, node)
// Don't serialize the widget value
this.serialize = false
_width: number,
y: number,
_height: number
): void {
renderPreview(ctx, node, y)
}
override drawWidget(ctx: CanvasRenderingContext2D): void {
renderPreview(ctx, this.node, this.y)
}
override onPointerDown(pointer: CanvasPointer, node: LGraphNode): boolean {
pointer.onDragStart = () => {
const { canvas } = app
const { graph } = canvas
canvas.emitBeforeChange()
graph?.beforeChange()
// Ensure that dragging is properly cleaned up, on success or failure.
pointer.finally = () => {
canvas.isDragging = false
graph?.afterChange()
canvas.emitAfterChange()
}
canvas.processSelect(node, pointer.eDown)
canvas.isDragging = true
}
pointer.onDragEnd = (e) => {
const { canvas } = app
if (e.shiftKey || LiteGraph.alwaysSnapToGrid)
canvas.graph?.snapToGrid(canvas.selectedItems)
canvas.setDirty(true, true)
}
return true
}
override onClick(): void {}
override computeLayoutSize() {
computeLayoutSize(this: IBaseWidget) {
return {
minHeight: 220,
minWidth: 1
@@ -308,7 +276,7 @@ export const useImagePreviewWidget = () => {
inputSpec: InputSpec
) => {
return node.addCustomWidget(
new ImagePreviewWidget(node, inputSpec.name, {
new ImagePreviewWidget(inputSpec.name, {
serialize: false
})
)

View File

@@ -28,8 +28,7 @@ export const useTextPreviewWidget = (
setValue: (value: string | object) => {
widgetValue.value = typeof value === 'string' ? value : String(value)
},
getMinHeight: () => options.minHeight ?? 42 + PADDING,
serialize: false
getMinHeight: () => options.minHeight ?? 42 + PADDING
}
})
addWidget(node, widget)

View File

@@ -266,7 +266,7 @@ useExtensionService().registerExtension({
LOAD_3D_ANIMATION(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.gltf,.glb,.fbx'
fileInput.accept = '.fbx,glb,gltf'
fileInput.style.display = 'none'
fileInput.onchange = async () => {
if (fileInput.files?.length) {
@@ -452,43 +452,31 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
let filePath = message.result[0]
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
let cameraState = message.result[1]
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
useLoad3dService().waitForLoad3d(node, (load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
const config = new Load3DConfiguration(load3d)
config.configure('output', modelWidget, cameraState)
}
}
})
})
}
}
})
@@ -538,42 +526,29 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
let filePath = message.result[0]
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
let cameraState = message.result[1]
useLoad3dService().waitForLoad3d(node, (load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
const config = new Load3DConfiguration(load3d)
config.configure('output', modelWidget, cameraState)
}
}
})
})
}
}
})

View File

@@ -160,43 +160,22 @@ class Load3d {
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.INITIAL_RENDER_DONE = true
}
renderMainScene(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(true)
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
}
resetViewport(): void {
const width = this.renderer.domElement.clientWidth
const height = this.renderer.domElement.clientHeight
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
this.INITIAL_RENDER_DONE = true
}
private getActiveCamera(): THREE.Camera {
@@ -219,17 +198,20 @@ class Load3d {
return
}
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
const delta = this.clock.getDelta()
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
@@ -316,18 +298,17 @@ class Load3d {
setBackgroundColor(color: string): void {
this.sceneManager.setBackgroundColor(color)
this.previewManager.setPreviewBackgroundColor(color)
this.forceRender()
}
async setBackgroundImage(uploadPath: string): Promise<void> {
await this.sceneManager.setBackgroundImage(uploadPath)
this.previewManager.updateBackgroundTexture(
this.sceneManager.backgroundTexture
)
if (this.previewManager.previewRenderer) {
this.previewManager.updateBackgroundTexture(
this.sceneManager.backgroundTexture
)
}
this.forceRender()
}
@@ -335,9 +316,12 @@ class Load3d {
removeBackgroundImage(): void {
this.sceneManager.removeBackgroundImage()
this.previewManager.setPreviewBackgroundColor(
this.sceneManager.currentBackgroundColor
)
if (
this.previewManager.previewRenderer &&
this.previewManager.previewCamera
) {
this.previewManager.updateBackgroundTexture(null)
}
this.forceRender()
}
@@ -364,6 +348,10 @@ class Load3d {
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
if (this.previewManager.showPreview) {
this.previewManager.syncWithMainCamera()
}
this.forceRender()
}

View File

@@ -42,6 +42,10 @@ class Load3dAnimation extends Load3d {
return
}
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
const delta = this.clock.getDelta()
this.animationManager.update(delta)
@@ -50,13 +54,12 @@ class Load3dAnimation extends Load3d {
this.controlsManager.update()
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)

View File

@@ -132,14 +132,6 @@ export class LoaderManager implements LoaderManagerInterface {
if (this.modelManager.materialMode === 'original') {
const mtlUrl = url.replace(/(filename=.*?)\.obj/, '$1.mtl')
const subfolderMatch = url.match(/[?&]subfolder=([^&]*)/)
const subfolder = subfolderMatch
? decodeURIComponent(subfolderMatch[1])
: '3d'
this.mtlLoader.setSubfolder(subfolder)
try {
const materials = await this.mtlLoader.loadAsync(mtlUrl)
materials.preload()

View File

@@ -4,6 +4,7 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
import { EventManagerInterface, PreviewManagerInterface } from './interfaces'
export class PreviewManager implements PreviewManagerInterface {
previewRenderer: THREE.WebGLRenderer | null = null
previewCamera: THREE.Camera
previewContainer: HTMLDivElement = {} as HTMLDivElement
showPreview: boolean = true
@@ -16,6 +17,7 @@ export class PreviewManager implements PreviewManagerInterface {
private getControls: () => OrbitControls
private eventManager: EventManagerInterface
// @ts-expect-error unused variable
private getRenderer: () => THREE.WebGLRenderer
private previewBackgroundScene: THREE.Scene
@@ -23,9 +25,6 @@ export class PreviewManager implements PreviewManagerInterface {
private previewBackgroundMesh: THREE.Mesh | null = null
private previewBackgroundTexture: THREE.Texture | null = null
private previewBackgroundColorMaterial: THREE.MeshBasicMaterial | null = null
private currentBackgroundColor: THREE.Color = new THREE.Color(0x282828)
constructor(
scene: THREE.Scene,
getActiveCamera: () => THREE.Camera,
@@ -46,24 +45,15 @@ export class PreviewManager implements PreviewManagerInterface {
this.previewBackgroundScene = backgroundScene.clone()
this.previewBackgroundCamera = backgroundCamera.clone()
this.initPreviewBackgroundScene()
}
private initPreviewBackgroundScene(): void {
const planeGeometry = new THREE.PlaneGeometry(2, 2)
this.previewBackgroundColorMaterial = new THREE.MeshBasicMaterial({
color: this.currentBackgroundColor.clone(),
transparent: false,
const planeMaterial = new THREE.MeshBasicMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.previewBackgroundMesh = new THREE.Mesh(
planeGeometry,
this.previewBackgroundColorMaterial
)
this.previewBackgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
this.previewBackgroundMesh.position.set(0, 0, 0)
this.previewBackgroundScene.add(this.previewBackgroundMesh)
}
@@ -71,23 +61,40 @@ export class PreviewManager implements PreviewManagerInterface {
init(): void {}
dispose(): void {
if (this.previewRenderer) {
this.previewRenderer.forceContextLoss()
const canvas = this.previewRenderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
this.previewRenderer.dispose()
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
if (this.previewBackgroundColorMaterial) {
this.previewBackgroundColorMaterial.dispose()
}
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.geometry.dispose()
if (this.previewBackgroundMesh.material instanceof THREE.Material) {
this.previewBackgroundMesh.material.dispose()
}
;(this.previewBackgroundMesh.material as THREE.Material).dispose()
}
}
createCapturePreview(container: Element | HTMLElement): void {
this.previewRenderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true,
preserveDrawingBuffer: true
})
this.previewRenderer.setSize(this.targetWidth, this.targetHeight)
this.previewRenderer.setClearColor(0x282828)
this.previewRenderer.autoClear = false
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
this.previewContainer = document.createElement('div')
this.previewContainer.style.cssText = `
position: absolute;
@@ -97,6 +104,7 @@ export class PreviewManager implements PreviewManagerInterface {
display: block;
transition: border-color 0.1s ease;
`
this.previewContainer.appendChild(this.previewRenderer.domElement)
const MIN_PREVIEW_WIDTH = 120
const MAX_PREVIEW_WIDTH = 240
@@ -123,6 +131,7 @@ export class PreviewManager implements PreviewManagerInterface {
}
this.updatePreviewSize()
this.updatePreviewRender()
})
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
@@ -150,54 +159,57 @@ export class PreviewManager implements PreviewManagerInterface {
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewContainer.style.width = `${this.previewWidth}px`
this.previewContainer.style.height = `${previewHeight}px`
this.previewRenderer?.setSize(this.previewWidth, previewHeight, false)
}
getPreviewViewport(): {
left: number
bottom: number
width: number
height: number
} | null {
if (!this.showPreview || !this.previewContainer) {
return null
syncWithMainCamera(): void {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview) {
return
}
const renderer = this.getRenderer()
const canvas = renderer.domElement
this.previewCamera = this.getActiveCamera().clone()
const containerRect = this.previewContainer.getBoundingClientRect()
const canvasRect = canvas.getBoundingClientRect()
this.previewCamera.position.copy(this.getActiveCamera().position)
this.previewCamera.rotation.copy(this.getActiveCamera().rotation)
if (
containerRect.bottom < canvasRect.top ||
containerRect.top > canvasRect.bottom ||
containerRect.right < canvasRect.left ||
containerRect.left > canvasRect.right
) {
return null
const aspect = this.targetWidth / this.targetHeight
if (this.getActiveCamera() instanceof THREE.OrthographicCamera) {
const activeOrtho = this.getActiveCamera() as THREE.OrthographicCamera
const previewOrtho = this.previewCamera as THREE.OrthographicCamera
previewOrtho.zoom = activeOrtho.zoom
const frustumHeight =
(activeOrtho.top - activeOrtho.bottom) / activeOrtho.zoom
const frustumWidth = frustumHeight * aspect
previewOrtho.top = frustumHeight / 2
previewOrtho.left = -frustumWidth / 2
previewOrtho.right = frustumWidth / 2
previewOrtho.bottom = -frustumHeight / 2
previewOrtho.updateProjectionMatrix()
} else {
const activePerspective =
this.getActiveCamera() as THREE.PerspectiveCamera
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
previewPerspective.fov = activePerspective.fov
previewPerspective.zoom = activePerspective.zoom
previewPerspective.aspect = aspect
previewPerspective.updateProjectionMatrix()
}
const width = parseFloat(this.previewContainer.style.width)
const height = parseFloat(this.previewContainer.style.height)
this.previewCamera.lookAt(this.getControls().target)
const left = this.getRenderer().domElement.clientWidth - width
const bottom = 0
return { left, bottom, width, height }
this.updatePreviewRender()
}
renderPreview(): void {
const viewport = this.getPreviewViewport()
if (!viewport) return
const renderer = this.getRenderer()
const originalClearColor = renderer.getClearColor(new THREE.Color())
const originalClearAlpha = renderer.getClearAlpha()
updatePreviewRender(): void {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
return
if (
!this.previewCamera ||
@@ -231,77 +243,45 @@ export class PreviewManager implements PreviewManagerInterface {
previewOrtho.updateProjectionMatrix()
} else {
const activePerspective =
;(this.previewCamera as THREE.PerspectiveCamera).aspect = aspect
;(this.previewCamera as THREE.PerspectiveCamera).fov = (
this.getActiveCamera() as THREE.PerspectiveCamera
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
previewPerspective.fov = activePerspective.fov
previewPerspective.zoom = activePerspective.zoom
previewPerspective.aspect = aspect
previewPerspective.updateProjectionMatrix()
).fov
;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix()
}
this.previewCamera.lookAt(this.getControls().target)
renderer.setViewport(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
renderer.setScissor(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewRenderer.setSize(this.previewWidth, previewHeight, false)
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
this.previewRenderer.clear()
renderer.setClearColor(0x000000, 0)
renderer.clear()
if (this.previewBackgroundMesh && this.previewBackgroundTexture) {
const material = this.previewBackgroundMesh
.material as THREE.MeshBasicMaterial
if (material.map) {
const currentToneMapping = this.previewRenderer.toneMapping
const currentExposure = this.previewRenderer.toneMappingExposure
this.renderPreviewBackground(renderer)
this.previewRenderer.toneMapping = THREE.NoToneMapping
this.previewRenderer.render(
this.previewBackgroundScene,
this.previewBackgroundCamera
)
renderer.render(this.scene, this.previewCamera)
renderer.setClearColor(originalClearColor, originalClearAlpha)
}
private renderPreviewBackground(renderer: THREE.WebGLRenderer): void {
if (this.previewBackgroundMesh) {
const currentToneMapping = renderer.toneMapping
const currentExposure = renderer.toneMappingExposure
renderer.toneMapping = THREE.NoToneMapping
renderer.render(this.previewBackgroundScene, this.previewBackgroundCamera)
renderer.toneMapping = currentToneMapping
renderer.toneMappingExposure = currentExposure
}
}
setPreviewBackgroundColor(color: string | number | THREE.Color): void {
this.currentBackgroundColor.set(color)
if (!this.previewBackgroundMesh || !this.previewBackgroundColorMaterial) {
this.initPreviewBackgroundScene()
this.previewRenderer.toneMapping = currentToneMapping
this.previewRenderer.toneMappingExposure = currentExposure
}
}
this.previewBackgroundColorMaterial!.color.copy(this.currentBackgroundColor)
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.material = this.previewBackgroundColorMaterial!
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
this.previewBackgroundTexture = null
}
this.previewRenderer.render(this.scene, this.previewCamera)
}
togglePreview(showPreview: boolean): void {
this.showPreview = showPreview
if (this.previewContainer) {
if (this.previewRenderer) {
this.showPreview = showPreview
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
}
@@ -326,7 +306,7 @@ export class PreviewManager implements PreviewManagerInterface {
)
}
if (this.previewCamera) {
if (this.previewRenderer && this.previewCamera) {
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
this.previewCamera.aspect = width / height
this.previewCamera.updateProjectionMatrix()
@@ -342,45 +322,30 @@ export class PreviewManager implements PreviewManagerInterface {
handleResize(): void {
this.updatePreviewSize()
this.updatePreviewRender()
}
updateBackgroundTexture(texture: THREE.Texture | null): void {
if (texture) {
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
this.previewBackgroundTexture = texture
this.previewBackgroundTexture = texture
if (this.previewBackgroundMesh) {
const imageMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
if (texture && this.previewBackgroundMesh) {
const material2 = this.previewBackgroundMesh
.material as THREE.MeshBasicMaterial
material2.map = texture
material2.needsUpdate = true
if (
this.previewBackgroundMesh.material instanceof THREE.Material &&
this.previewBackgroundMesh.material !==
this.previewBackgroundColorMaterial
) {
this.previewBackgroundMesh.material.dispose()
}
this.previewBackgroundMesh.position.set(0, 0, 0)
this.previewBackgroundMesh.material = imageMaterial
this.previewBackgroundMesh.position.set(0, 0, 0)
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
this.targetWidth,
this.targetHeight
)
}
} else {
this.setPreviewBackgroundColor(this.currentBackgroundColor)
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
this.targetWidth,
this.targetHeight
)
}
}

View File

@@ -13,10 +13,6 @@ export class SceneManager implements SceneManagerInterface {
backgroundMesh: THREE.Mesh | null = null
backgroundTexture: THREE.Texture | null = null
backgroundColorMaterial: THREE.MeshBasicMaterial | null = null
currentBackgroundType: 'color' | 'image' = 'color'
currentBackgroundColor: string = '#282828'
private eventManager: EventManagerInterface
private renderer: THREE.WebGLRenderer
@@ -44,28 +40,17 @@ export class SceneManager implements SceneManagerInterface {
this.backgroundScene = new THREE.Scene()
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
this.initBackgroundScene()
}
private initBackgroundScene(): void {
const planeGeometry = new THREE.PlaneGeometry(2, 2)
this.backgroundColorMaterial = new THREE.MeshBasicMaterial({
color: new THREE.Color(this.currentBackgroundColor),
transparent: false,
const planeMaterial = new THREE.MeshBasicMaterial({
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.backgroundMesh = new THREE.Mesh(
planeGeometry,
this.backgroundColorMaterial
)
this.backgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
this.backgroundMesh.position.set(0, 0, 0)
this.backgroundScene.add(this.backgroundMesh)
this.renderer.setClearColor(0x000000, 0)
}
init(): void {}
@@ -75,15 +60,9 @@ export class SceneManager implements SceneManagerInterface {
this.backgroundTexture.dispose()
}
if (this.backgroundColorMaterial) {
this.backgroundColorMaterial.dispose()
}
if (this.backgroundMesh) {
this.backgroundMesh.geometry.dispose()
if (this.backgroundMesh.material instanceof THREE.Material) {
this.backgroundMesh.material.dispose()
}
;(this.backgroundMesh.material as THREE.Material).dispose()
}
this.scene.clear()
@@ -98,39 +77,18 @@ export class SceneManager implements SceneManagerInterface {
}
setBackgroundColor(color: string): void {
this.currentBackgroundColor = color
this.currentBackgroundType = 'color'
if (!this.backgroundMesh || !this.backgroundColorMaterial) {
this.initBackgroundScene()
}
this.backgroundColorMaterial!.color.set(color)
this.backgroundColorMaterial!.map = null
this.backgroundColorMaterial!.transparent = false
this.backgroundColorMaterial!.needsUpdate = true
if (this.backgroundMesh) {
this.backgroundMesh.material = this.backgroundColorMaterial!
}
if (this.backgroundTexture) {
this.backgroundTexture.dispose()
this.backgroundTexture = null
}
this.renderer.setClearColor(new THREE.Color(color))
this.eventManager.emitEvent('backgroundColorChange', color)
}
async setBackgroundImage(uploadPath: string): Promise<void> {
if (uploadPath === '') {
this.setBackgroundColor(this.currentBackgroundColor)
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
if (uploadPath === '') {
this.removeBackgroundImage()
return
}
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
let imageUrl = Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath)
)
@@ -152,31 +110,12 @@ export class SceneManager implements SceneManagerInterface {
texture.colorSpace = THREE.SRGBColorSpace
this.backgroundTexture = texture
this.currentBackgroundType = 'image'
if (!this.backgroundMesh) {
this.initBackgroundScene()
}
const material = this.backgroundMesh?.material as THREE.MeshBasicMaterial
material.map = texture
material.needsUpdate = true
const imageMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
if (this.backgroundMesh) {
if (
this.backgroundMesh.material !== this.backgroundColorMaterial &&
this.backgroundMesh.material instanceof THREE.Material
) {
this.backgroundMesh.material.dispose()
}
this.backgroundMesh.material = imageMaterial
this.backgroundMesh.position.set(0, 0, 0)
}
this.backgroundMesh?.position.set(0, 0, 0)
this.updateBackgroundSize(
this.backgroundTexture,
@@ -190,12 +129,20 @@ export class SceneManager implements SceneManagerInterface {
} catch (error) {
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
console.error('Error loading background image:', error)
this.setBackgroundColor(this.currentBackgroundColor)
}
}
removeBackgroundImage(): void {
this.setBackgroundColor(this.currentBackgroundColor)
if (this.backgroundMesh) {
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
material.map = null
material.needsUpdate = true
}
if (this.backgroundTexture) {
this.backgroundTexture.dispose()
this.backgroundTexture = null
}
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
}
@@ -225,11 +172,7 @@ export class SceneManager implements SceneManagerInterface {
}
handleResize(width: number, height: number): void {
if (
this.backgroundTexture &&
this.backgroundMesh &&
this.currentBackgroundType === 'image'
) {
if (this.backgroundTexture && this.backgroundMesh) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
@@ -240,25 +183,18 @@ export class SceneManager implements SceneManagerInterface {
}
renderBackground(): void {
if (this.backgroundMesh) {
const currentToneMapping = this.renderer.toneMapping
const currentExposure = this.renderer.toneMappingExposure
if (this.backgroundMesh && this.backgroundTexture) {
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
if (material.map) {
const currentToneMapping = this.renderer.toneMapping
const currentExposure = this.renderer.toneMappingExposure
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = currentToneMapping
this.renderer.toneMappingExposure = currentExposure
}
}
getCurrentBackgroundInfo(): { type: 'color' | 'image'; value: string } {
return {
type: this.currentBackgroundType,
value:
this.currentBackgroundType === 'color'
? this.currentBackgroundColor
: ''
this.renderer.toneMapping = currentToneMapping
this.renderer.toneMappingExposure = currentExposure
}
}
}
@@ -274,6 +210,8 @@ export class SceneManager implements SceneManagerInterface {
new THREE.Color()
)
const originalClearAlpha = this.renderer.getClearAlpha()
const originalToneMapping = this.renderer.toneMapping
const originalExposure = this.renderer.toneMappingExposure
const originalOutputColorSpace = this.renderer.outputColorSpace
this.renderer.setSize(width, height)
@@ -299,11 +237,7 @@ export class SceneManager implements SceneManagerInterface {
orthographicCamera.updateProjectionMatrix()
}
if (
this.backgroundTexture &&
this.backgroundMesh &&
this.currentBackgroundType === 'image'
) {
if (this.backgroundTexture && this.backgroundMesh) {
this.updateBackgroundSize(
this.backgroundTexture,
this.backgroundMesh,
@@ -318,7 +252,19 @@ export class SceneManager implements SceneManagerInterface {
>()
this.renderer.clear()
this.renderBackground()
if (this.backgroundMesh && this.backgroundTexture) {
const material = this.backgroundMesh
.material as THREE.MeshBasicMaterial
if (material.map) {
this.renderer.toneMapping = THREE.NoToneMapping
this.renderer.render(this.backgroundScene, this.backgroundCamera)
this.renderer.toneMapping = originalToneMapping
this.renderer.toneMappingExposure = originalExposure
}
}
this.renderer.render(this.scene, this.getActiveCamera())
const sceneData = this.renderer.domElement.toDataURL('image/png')

View File

@@ -100,23 +100,18 @@ export interface ViewHelperManagerInterface extends BaseManager {
}
export interface PreviewManagerInterface extends BaseManager {
previewRenderer: THREE.WebGLRenderer | null
previewCamera: THREE.Camera
previewContainer: HTMLDivElement
showPreview: boolean
previewWidth: number
createCapturePreview(container: Element | HTMLElement): void
updatePreviewSize(): void
updatePreviewRender(): void
togglePreview(showPreview: boolean): void
setTargetSize(width: number, height: number): void
handleResize(): void
updateBackgroundTexture(texture: THREE.Texture | null): void
getPreviewViewport(): {
left: number
bottom: number
width: number
height: number
} | null
renderPreview(): void
}
export interface EventManagerInterface {

View File

@@ -38,10 +38,6 @@ class OverrideMTLLoader extends Loader {
this.loadRootFolder = loadRootFolder
}
setSubfolder(subfolder) {
this.subfolder = subfolder
}
/**
* Starts loading from the given URL and passes the loaded MTL asset
* to the `onLoad()` callback.
@@ -139,8 +135,7 @@ class OverrideMTLLoader extends Loader {
const materialCreator = new OverrideMaterialCreator(
this.resourcePath || path,
this.materialOptions,
this.loadRootFolder,
this.subfolder
this.loadRootFolder
)
materialCreator.setCrossOrigin(this.crossOrigin)
materialCreator.setManager(this.manager)
@@ -160,7 +155,7 @@ class OverrideMTLLoader extends Loader {
*/
class OverrideMaterialCreator {
constructor(baseUrl = '', options = {}, loadRootFolder, subfolder) {
constructor(baseUrl = '', options = {}, loadRootFolder) {
this.baseUrl = baseUrl
this.options = options
this.materialsInfo = {}
@@ -169,7 +164,6 @@ class OverrideMaterialCreator {
this.nameLookup = {}
this.loadRootFolder = loadRootFolder
this.subfolder = subfolder
this.crossOrigin = 'anonymous'
@@ -289,25 +283,16 @@ class OverrideMaterialCreator {
/**
* Override for ComfyUI api url
*/
function resolveURL(baseUrl, url, loadRootFolder, subfolder) {
function resolveURL(baseUrl, url, loadRootFolder) {
if (typeof url !== 'string' || url === '') return ''
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1)
}
if (!baseUrl.endsWith('api')) {
baseUrl = '/api'
}
baseUrl =
baseUrl +
'/view?filename=' +
url +
'&type=' +
loadRootFolder +
'&subfolder=' +
subfolder
'&subfolder=3d'
return baseUrl
}
@@ -317,12 +302,7 @@ class OverrideMaterialCreator {
const texParams = scope.getTextureParams(value, params)
const map = scope.loadTexture(
resolveURL(
scope.baseUrl,
texParams.url,
scope.loadRootFolder,
scope.subfolder
)
resolveURL(scope.baseUrl, texParams.url, scope.loadRootFolder)
)
map.repeat.copy(texParams.scale)

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Fit view to selected nodes"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Move Selected Nodes Down"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Move Selected Nodes Left"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Move Selected Nodes Right"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Move Selected Nodes Up"
},
"Comfy_Canvas_ResetView": {
"label": "Reset View"
},

View File

@@ -30,7 +30,6 @@
"icon": "Icon",
"color": "Color",
"error": "Error",
"help": "Help",
"loading": "Loading",
"findIssues": "Find Issues",
"reportIssue": "Send Report",
@@ -122,8 +121,7 @@
"edit": "Edit",
"copy": "Copy",
"imageUrl": "Image URL",
"clear": "Clear",
"copyURL": "Copy URL"
"clear": "Clear"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -417,23 +415,7 @@
"openWorkflow": "Open workflow in local file system",
"newBlankWorkflow": "Create a new blank workflow",
"nodeLibraryTab": {
"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"
}
"sortOrder": "Sort Order"
},
"modelLibrary": "Model Library",
"downloads": "Downloads",
@@ -514,8 +496,7 @@
"3D": "3D",
"Audio": "Audio",
"Image API": "Image API",
"Video API": "Video API",
"All": "All Templates"
"Video API": "Video API"
},
"templateDescription": {
"Basics": {
@@ -794,10 +775,6 @@
"Browse Templates": "Browse Templates",
"Delete Selected Items": "Delete Selected Items",
"Fit view to selected nodes": "Fit view to selected nodes",
"Move Selected Nodes Down": "Move Selected Nodes Down",
"Move Selected Nodes Left": "Move Selected Nodes Left",
"Move Selected Nodes Right": "Move Selected Nodes Right",
"Move Selected Nodes Up": "Move Selected Nodes Up",
"Reset View": "Reset View",
"Resize Selected Nodes": "Resize Selected Nodes",
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
@@ -1429,13 +1406,5 @@
"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

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Ajustar vista a los nodos seleccionados"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Mover nodos seleccionados hacia abajo"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Mover nodos seleccionados a la izquierda"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Mover nodos seleccionados a la derecha"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Mover nodos seleccionados hacia arriba"
},
"Comfy_Canvas_ResetView": {
"label": "Restablecer vista"
},

View File

@@ -268,7 +268,6 @@
"control_before_generate": "control antes de generar",
"copy": "Copiar",
"copyToClipboard": "Copiar al portapapeles",
"copyURL": "Copiar URL",
"currentUser": "Usuario actual",
"customBackground": "Fondo personalizado",
"customize": "Personalizar",
@@ -294,7 +293,6 @@
"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",
@@ -709,10 +707,6 @@
"Interrupt": "Interrumpir",
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
"Manage group nodes": "Gestionar nodos de grupo",
"Move Selected Nodes Down": "Mover nodos seleccionados hacia abajo",
"Move Selected Nodes Left": "Mover nodos seleccionados hacia la izquierda",
"Move Selected Nodes Right": "Mover nodos seleccionados hacia la derecha",
"Move Selected Nodes Up": "Mover nodos seleccionados hacia arriba",
"Mute/Unmute Selected Nodes": "Silenciar/Activar sonido de nodos seleccionados",
"New": "Nuevo",
"Next Opened Workflow": "Siguiente flujo de trabajo abierto",
@@ -841,14 +835,6 @@
"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"
@@ -1078,23 +1064,7 @@
"newBlankWorkflow": "Crear un nuevo flujo de trabajo en blanco",
"nodeLibrary": "Biblioteca de nodos",
"nodeLibraryTab": {
"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"
"sortOrder": "Orden de clasificación"
},
"openWorkflow": "Abrir flujo de trabajo en el sistema de archivos local",
"queue": "Cola",
@@ -1142,7 +1112,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Todas las plantillas",
"Area Composition": "Composición de Área",
"Audio": "Audio",
"Basics": "Básicos",

View File

@@ -3403,7 +3403,7 @@
"clear": {
},
"height": {
"name": "alto"
"name": "altura"
},
"image": {
"name": "imagen"
@@ -3417,26 +3417,20 @@
"name": "ancho"
}
},
"outputs": {
"0": {
"name": "imagen"
},
"1": {
"name": "mask"
},
"2": {
"name": "ruta_malla"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "normal"
},
"4": {
{
"name": "lineart"
},
"5": {
"name": "info_cámara"
{
"name": "camera_info"
}
}
]
},
"Load3DAnimation": {
"display_name": "Cargar 3D - Animación",
@@ -3444,7 +3438,7 @@
"clear": {
},
"height": {
"name": "alto"
"name": "altura"
},
"image": {
"name": "imagen"
@@ -3458,23 +3452,17 @@
"name": "ancho"
}
},
"outputs": {
"0": {
"name": "imagen"
},
"1": {
"name": "mask"
},
"2": {
"name": "ruta_malla"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "normal"
},
"4": {
"name": "info_cámara"
{
"name": "camera_info"
}
}
]
},
"LoadAudio": {
"display_name": "CargarAudio",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Ajuster la vue aux nœuds sélectionnés"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Déplacer les nœuds sélectionnés vers le bas"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Déplacer les nœuds sélectionnés vers la gauche"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Déplacer les nœuds sélectionnés vers la droite"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Déplacer les nœuds sélectionnés vers le haut"
},
"Comfy_Canvas_ResetView": {
"label": "Réinitialiser la vue"
},

View File

@@ -268,7 +268,6 @@
"control_before_generate": "contrôle avant génération",
"copy": "Copier",
"copyToClipboard": "Copier dans le presse-papiers",
"copyURL": "Copier lURL",
"currentUser": "Utilisateur actuel",
"customBackground": "Arrière-plan personnalisé",
"customize": "Personnaliser",
@@ -294,7 +293,6 @@
"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",
@@ -709,10 +707,6 @@
"Interrupt": "Interrompre",
"Load Default Workflow": "Charger le flux de travail par défaut",
"Manage group nodes": "Gérer les nœuds de groupe",
"Move Selected Nodes Down": "Déplacer les nœuds sélectionnés vers le bas",
"Move Selected Nodes Left": "Déplacer les nœuds sélectionnés vers la gauche",
"Move Selected Nodes Right": "Déplacer les nœuds sélectionnés vers la droite",
"Move Selected Nodes Up": "Déplacer les nœuds sélectionnés vers le haut",
"Mute/Unmute Selected Nodes": "Mettre en sourdine/Activer le son des nœuds sélectionnés",
"New": "Nouveau",
"Next Opened Workflow": "Prochain flux de travail ouvert",
@@ -841,14 +835,6 @@
"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"
@@ -1078,23 +1064,7 @@
"newBlankWorkflow": "Créer un nouveau flux de travail vierge",
"nodeLibrary": "Bibliothèque de nœuds",
"nodeLibraryTab": {
"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"
"sortOrder": "Ordre de tri"
},
"openWorkflow": "Ouvrir le flux de travail dans le système de fichiers local",
"queue": "File d'attente",
@@ -1142,7 +1112,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Tous les modèles",
"Area Composition": "Composition de zone",
"Audio": "Audio",
"Basics": "Basiques",

View File

@@ -3417,26 +3417,20 @@
"name": "largeur"
}
},
"outputs": {
"0": {
"name": "image"
},
"1": {
"name": "masque"
},
"2": {
"name": "chemin_maillage"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "normale"
},
"4": {
"name": "lineart"
{
"name": "ligne artistique"
},
"5": {
"name": "info_caméra"
{
"name": "informations caméra"
}
}
]
},
"Load3DAnimation": {
"display_name": "Charger 3D - Animation",
@@ -3458,23 +3452,17 @@
"name": "largeur"
}
},
"outputs": {
"0": {
"name": "image"
"outputs": [
null,
null,
null,
{
"name": "normal"
},
"1": {
"name": "masque"
},
"2": {
"name": "chemin_maillage"
},
"3": {
"name": "normale"
},
"4": {
"name": "info_caméra"
{
"name": "camera_info"
}
}
]
},
"LoadAudio": {
"display_name": "ChargerAudio",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "選択したノードにビューを合わせる"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "選択したノードを下に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "選択したノードを左に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "選択したノードを右に移動"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "選択したノードを上に移動"
},
"Comfy_Canvas_ResetView": {
"label": "ビューをリセット"
},

View File

@@ -268,7 +268,6 @@
"control_before_generate": "生成前の制御",
"copy": "コピー",
"copyToClipboard": "クリップボードにコピー",
"copyURL": "URLをコピー",
"currentUser": "現在のユーザー",
"customBackground": "カスタム背景",
"customize": "カスタマイズ",
@@ -294,7 +293,6 @@
"findIssues": "問題を見つける",
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
"goToNode": "ノードに移動",
"help": "ヘルプ",
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imageUrl": "画像URL",
@@ -709,10 +707,6 @@
"Interrupt": "中断",
"Load Default Workflow": "デフォルトワークフローを読み込む",
"Manage group nodes": "グループノードを管理",
"Move Selected Nodes Down": "選択したノードを下へ移動",
"Move Selected Nodes Left": "選択したノードを左へ移動",
"Move Selected Nodes Right": "選択したノードを右へ移動",
"Move Selected Nodes Up": "選択したノードを上へ移動",
"Mute/Unmute Selected Nodes": "選択したノードのミュート/ミュート解除",
"New": "新規",
"Next Opened Workflow": "次に開いたワークフロー",
@@ -841,14 +835,6 @@
"video": "ビデオ",
"video_models": "ビデオモデル"
},
"nodeHelpPage": {
"documentationPage": "ドキュメントページ",
"inputs": "入力",
"loadError": "ヘルプの読み込みに失敗しました: {error}",
"moreHelp": "さらに詳しい情報は、",
"outputs": "出力",
"type": "タイプ"
},
"nodeTemplates": {
"enterName": "名前を入力",
"saveAsTemplate": "テンプレートとして保存"
@@ -1078,23 +1064,7 @@
"newBlankWorkflow": "新しい空のワークフローを作成",
"nodeLibrary": "ノードライブラリ",
"nodeLibraryTab": {
"groupBy": "グループ化",
"groupStrategies": {
"category": "カテゴリ",
"categoryDesc": "ノードカテゴリでグループ化",
"module": "モジュール",
"moduleDesc": "モジュールソースでグループ化",
"source": "ソース",
"sourceDesc": "ソースタイプCore、Custom、APIでグループ化"
},
"resetView": "ビューをデフォルトにリセット",
"sortBy": {
"alphabetical": "アルファベット順",
"alphabeticalDesc": "グループ内でアルファベット順に並び替え",
"original": "元の順序",
"originalDesc": "元の順序を維持"
},
"sortMode": "並び替えモード"
"sortOrder": "並び順"
},
"openWorkflow": "ローカルでワークフローを開く",
"queue": "キュー",
@@ -1142,7 +1112,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "すべてのテンプレート",
"Area Composition": "エリア構成",
"Audio": "オーディオ",
"Basics": "基本",

View File

@@ -3417,29 +3417,23 @@
"name": "幅"
}
},
"outputs": {
"0": {
"name": "画像"
},
"1": {
"name": "マスク"
},
"2": {
"name": "メッシュパス"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "法線"
},
"4": {
{
"name": "線画"
},
"5": {
{
"name": "カメラ情報"
}
}
]
},
"Load3DAnimation": {
"display_name": "3D読み込 - アニメーション",
"display_name": "3D読み込 - アニメーション",
"inputs": {
"clear": {
},
@@ -3458,23 +3452,17 @@
"name": "幅"
}
},
"outputs": {
"0": {
"name": "画像"
},
"1": {
"name": "マスク"
},
"2": {
"name": "メッシュパス"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "法線"
},
"4": {
{
"name": "カメラ情報"
}
}
]
},
"LoadAudio": {
"display_name": "音声を読み込む",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "선택한 노드에 뷰 맞추기"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "선택한 노드 아래로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "선택한 노드 왼쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "선택한 노드 오른쪽으로 이동"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "선택한 노드 위로 이동"
},
"Comfy_Canvas_ResetView": {
"label": "뷰 재설정"
},

View File

@@ -268,7 +268,6 @@
"control_before_generate": "생성 전 제어",
"copy": "복사",
"copyToClipboard": "클립보드에 복사",
"copyURL": "URL 복사",
"currentUser": "현재 사용자",
"customBackground": "맞춤 배경",
"customize": "사용자 정의",
@@ -294,7 +293,6 @@
"findIssues": "문제 찾기",
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
"goToNode": "노드로 이동",
"help": "도움말",
"icon": "아이콘",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"imageUrl": "이미지 URL",
@@ -709,10 +707,6 @@
"Interrupt": "중단",
"Load Default Workflow": "기본 워크플로 불러오기",
"Manage group nodes": "그룹 노드 관리",
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
"Move Selected Nodes Up": "선택한 노드 위로 이동",
"Mute/Unmute Selected Nodes": "선택한 노드 활성화/비활성화",
"New": "새로 만들기",
"Next Opened Workflow": "다음 열린 워크플로",
@@ -841,14 +835,6 @@
"video": "비디오",
"video_models": "비디오 모델"
},
"nodeHelpPage": {
"documentationPage": "문서 페이지",
"inputs": "입력",
"loadError": "도움말을 불러오지 못했습니다: {error}",
"moreHelp": "더 많은 도움말은",
"outputs": "출력",
"type": "유형"
},
"nodeTemplates": {
"enterName": "이름 입력",
"saveAsTemplate": "템플릿으로 저장"
@@ -1078,23 +1064,7 @@
"newBlankWorkflow": "새 빈 워크플로 만들기",
"nodeLibrary": "노드 라이브러리",
"nodeLibraryTab": {
"groupBy": "그룹 기준",
"groupStrategies": {
"category": "카테고리",
"categoryDesc": "노드 카테고리별로 그룹화",
"module": "모듈",
"moduleDesc": "모듈 소스별로 그룹화",
"source": "소스",
"sourceDesc": "소스 유형(Core, Custom, API)별로 그룹화"
},
"resetView": "기본 보기로 재설정",
"sortBy": {
"alphabetical": "알파벳순",
"alphabeticalDesc": "그룹 내에서 알파벳순으로 정렬",
"original": "원본 순서",
"originalDesc": "원래 순서를 유지"
},
"sortMode": "정렬 방식"
"sortOrder": "정렬 순서"
},
"openWorkflow": "로컬 파일 시스템에서 워크플로 열기",
"queue": "실행 대기열",
@@ -1142,7 +1112,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "모든 템플릿",
"Area Composition": "영역 구성",
"Audio": "오디오",
"Basics": "기본",

View File

@@ -3398,7 +3398,7 @@
}
},
"Load3D": {
"display_name": "3D 불러오기",
"display_name": "3D 로드",
"inputs": {
"clear": {
},
@@ -3417,29 +3417,23 @@
"name": "너비"
}
},
"outputs": {
"0": {
"name": "이미지"
},
"1": {
"name": "마스크"
},
"2": {
"name": "메시 경로"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "노멀"
},
"4": {
{
"name": "라인아트"
},
"5": {
{
"name": "카메라 정보"
}
}
]
},
"Load3DAnimation": {
"display_name": "3D 불러오기 - 애니메이션",
"display_name": "3D 로드 - 애니메이션",
"inputs": {
"clear": {
},
@@ -3458,23 +3452,17 @@
"name": "너비"
}
},
"outputs": {
"0": {
"name": "이미지"
},
"1": {
"name": "마스크"
},
"2": {
"name": "메시 경로"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "노멀"
},
"4": {
{
"name": "카메라 정보"
}
}
]
},
"LoadAudio": {
"display_name": "오디오 로드",

View File

@@ -44,18 +44,6 @@
"Comfy_Canvas_FitView": {
"label": "Подогнать вид к выбранным нодам"
},
"Comfy_Canvas_MoveSelectedNodes_Down": {
"label": "Переместить выбранные узлы вниз"
},
"Comfy_Canvas_MoveSelectedNodes_Left": {
"label": "Переместить выбранные узлы влево"
},
"Comfy_Canvas_MoveSelectedNodes_Right": {
"label": "Переместить выбранные узлы вправо"
},
"Comfy_Canvas_MoveSelectedNodes_Up": {
"label": "Переместить выбранные узлы вверх"
},
"Comfy_Canvas_ResetView": {
"label": "Сбросить вид"
},

View File

@@ -268,7 +268,6 @@
"control_before_generate": "управление до генерации",
"copy": "Копировать",
"copyToClipboard": "Скопировать в буфер обмена",
"copyURL": "Скопировать URL",
"currentUser": "Текущий пользователь",
"customBackground": "Пользовательский фон",
"customize": "Настроить",
@@ -294,7 +293,6 @@
"findIssues": "Найти проблемы",
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
"goToNode": "Перейти к ноде",
"help": "Помощь",
"icon": "Иконка",
"imageFailedToLoad": "Не удалось загрузить изображение",
"imageUrl": "URL изображения",
@@ -709,10 +707,6 @@
"Interrupt": "Прервать",
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
"Manage group nodes": "Управление групповыми нодами",
"Move Selected Nodes Down": "Переместить выбранные узлы вниз",
"Move Selected Nodes Left": "Переместить выбранные узлы влево",
"Move Selected Nodes Right": "Переместить выбранные узлы вправо",
"Move Selected Nodes Up": "Переместить выбранные узлы вверх",
"Mute/Unmute Selected Nodes": "Отключить/включить звук для выбранных нод",
"New": "Новый",
"Next Opened Workflow": "Следующий открытый рабочий процесс",
@@ -841,14 +835,6 @@
"video": "видео",
"video_models": "видеомодели"
},
"nodeHelpPage": {
"documentationPage": "страницу документации",
"inputs": "Входы",
"loadError": "Не удалось загрузить справку: {error}",
"moreHelp": "Для получения дополнительной помощи посетите",
"outputs": "Выходы",
"type": "Тип"
},
"nodeTemplates": {
"enterName": "Введите название",
"saveAsTemplate": "Сохранить как шаблон"
@@ -1078,23 +1064,7 @@
"newBlankWorkflow": "Создайте новый пустой рабочий процесс",
"nodeLibrary": "Библиотека нод",
"nodeLibraryTab": {
"groupBy": "Группировать по",
"groupStrategies": {
"category": "Категория",
"categoryDesc": "Группировать по категории узла",
"module": "Модуль",
"moduleDesc": "Группировать по источнику модуля",
"source": "Источник",
"sourceDesc": "Группировать по типу источника (Core, Custom, API)"
},
"resetView": "Сбросить вид по умолчанию",
"sortBy": {
"alphabetical": "По алфавиту",
"alphabeticalDesc": "Сортировать по алфавиту внутри групп",
"original": "Оригинальный порядок",
"originalDesc": "Сохранять исходный порядок"
},
"sortMode": "Режим сортировки"
"sortOrder": "Порядок сортировки"
},
"openWorkflow": "Открыть рабочий процесс в локальной файловой системе",
"queue": "Очередь",
@@ -1142,7 +1112,6 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Все шаблоны",
"Area Composition": "Композиция области",
"Audio": "Аудио",
"Basics": "Основы",

View File

@@ -3409,7 +3409,7 @@
"name": "изображение"
},
"model_file": {
"name": "файл модели"
"name": "файл_модели"
},
"upload 3d model": {
},
@@ -3417,29 +3417,23 @@
"name": "ширина"
}
},
"outputs": {
"0": {
"name": "изображение"
},
"1": {
"name": "mask"
},
"2": {
"name": "путь к mesh"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "нормаль"
},
"4": {
"name": "линейный рисунок"
{
"name": "линеарт"
},
"5": {
"name": "информация о камере"
{
"name": "информация_камеры"
}
}
]
},
"Load3DAnimation": {
"display_name": "Загрузить 3D - Анимация",
"display_name": "Загрузить 3D Анимация",
"inputs": {
"clear": {
},
@@ -3458,23 +3452,17 @@
"name": "ширина"
}
},
"outputs": {
"0": {
"name": "изображение"
},
"1": {
"name": "mask"
},
"2": {
"name": "путь_к_модели"
},
"3": {
"outputs": [
null,
null,
null,
{
"name": "нормаль"
},
"4": {
"name": "информация_о_камере"
{
"name": "информация_камеры"
}
}
]
},
"LoadAudio": {
"display_name": "Загрузить аудио",

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