Compare commits

...

29 Commits

Author SHA1 Message Date
github-actions
a0548e68b6 Update locales [skip ci] 2025-06-13 00:03:10 +00:00
Yiqun Xu
588f59c6ae Revert "Merge branch 'feat/check-template-version' of https://github.com/Comfy-Org/ComfyUI_frontend into feat/check-template-version"
This reverts commit 316755b5db, reversing
changes made to e00886e538.
2025-06-12 16:58:42 -07:00
Yiqun Xu
316755b5db Merge branch 'feat/check-template-version' of https://github.com/Comfy-Org/ComfyUI_frontend into feat/check-template-version 2025-06-12 16:54:50 -07:00
Yiqun Xu
e00886e538 fix: fix unit test 2025-06-12 16:50:03 -07:00
github-actions
72949d93e6 Update locales [skip ci] 2025-06-12 23:03:03 +00:00
Yiqun Xu
9a3547a24b check template version 2025-06-12 06:33:02 -07:00
Christian Byrne
a937ac59ad Revert Algolia proxy changes (#4133) 2025-06-11 06:41:35 -07:00
duckcomfy
995979a4e1 feat: add keyboard shortcut to move selected nodes (unbound by default) (#4066)
Co-authored-by: duckcomfy <a@a.a>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-06-11 06:35:49 -07:00
Comfy Org PR Bot
c02ac95815 [chore] Update Comfy Registry API types from comfy-api@34a03c4 (#4123)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-06-10 05:01:45 -07:00
Christian Byrne
d01926b043 [Dev] Add note to Claude memory about common mistake with url prefixes (#4118) 2025-06-09 07:14:26 -07:00
Christian Byrne
344c6f6244 Reland Playwright MCP for Local Development (#4070) 2025-06-08 01:21:22 -07:00
Terry Jia
b2918a4cf6 Improve bg color image logic (#4095) 2025-06-08 01:20:56 -07:00
Hayden
6d4eafb07a Fix primevue overlay component z-index might be incorrect (#4074) 2025-06-08 01:20:41 -07:00
Comfy Org PR Bot
97edaade63 1.22.1 (#4104)
Co-authored-by: webfiltered <176114999+webfiltered@users.noreply.github.com>
2025-06-08 08:17:34 +00:00
Christian Byrne
83af274339 [fix] resolve @ symbol parsing errors in extension tooltips (#4100) 2025-06-08 01:02:36 -07:00
filtered
f251af25cc Revert "[refactor] Refactor file handling" (#4103) 2025-06-08 07:20:15 +00:00
filtered
e2024c1e79 Revert "[fix] Remove dynamic import timing issue causing Playwright test flakiness" (#4102) 2025-06-07 23:57:29 -07:00
filtered
e8236e1a85 [chore] Pin third-party GitHub Actions to commit SHAs (#4076) 2025-06-07 21:06:34 -07:00
Christian Byrne
79a63de70e [docs] Remove deprecated comment from registerExtension (#4098) 2025-06-07 20:32:36 -07:00
Christian Byrne
3eee7cde0b [docs] Convert .cursorrules to standard markdown format (#4099) 2025-06-07 19:45:03 -07:00
Christian Byrne
6bbe46009b [docs] Add PrimeVue deprecated component guidelines (#4097) 2025-06-07 18:27:35 -07:00
Terry Jia
1ca71caf45 [3d] performance improvement by using threejs setViewport (#4079) 2025-06-06 17:35:16 -07:00
Benjamin Lu
65289b1927 Update to new card design (#4065)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-06-06 04:19:05 -07:00
filtered
9e2180dcd8 [CodeHealth] Lint script files (#4081) 2025-06-05 03:23:56 -07:00
Benjamin Lu
defea56ba5 [docs] update env example (#4078) 2025-06-05 10:39:48 +10:00
Comfy Org PR Bot
e6bca95a5f [chore] Update ComfyUI-Manager API types from ComfyUI-Manager@4cceb46 (#4077)
Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-06-04 10:07:16 -07:00
Christian Byrne
841e3f743a [feat] Add workflow to generate ComfyUI-Manager types from OpenAPI (#4072) 2025-06-04 04:31:26 -07:00
Christian Byrne
73be826956 [Feature] Add "All" category to template workflows (#3931)
Co-authored-by: github-actions <github-actions@github.com>
2025-06-04 02:58:00 -07:00
Christian Byrne
398dc6d8a6 [feat] Add dynamic pricing for API nodes with real-time updates (#3963)
Co-authored-by: Claude <noreply@anthropic.com>
2025-06-04 02:04:24 -07:00
67 changed files with 18569 additions and 2192 deletions

View File

@@ -1,26 +1,25 @@
// Vue 3 Composition API .cursorrules
# Vue 3 Composition API Project Rules
// 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:
## 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:
```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
// Folder structure
const folderStructure = `
## Project Structure
```
src/
components/
constants/
@@ -30,16 +29,25 @@ src/
services/
App.vue
main.ts
`;
```
// Tailwind CSS best practices
const tailwindCssBestPractices = [
"Use Tailwind CSS for styling",
"Implement responsive design with Tailwind CSS",
]
## Styling Guidelines
- Use Tailwind CSS for styling
- Implement responsive design with Tailwind CSS
// Additional instructions
const additionalInstructions = `
## 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
1. Leverage VueUse functions for performance-enhancing styles
2. Use lodash for utility functions
3. Use TypeScript for type safety
@@ -49,6 +57,5 @@ const additionalInstructions = `
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.
`;
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

View File

@@ -29,3 +29,7 @@ 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

@@ -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@release/v1
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
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@v2
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
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@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
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@v2
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
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@release/v1
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
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@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
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@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[chore] Update litegraph to ${{ steps.get-version.outputs.NEW_VERSION }}'

View File

@@ -0,0 +1,92 @@
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@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
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@v7
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
with:
token: ${{ secrets.PR_GH_TOKEN }}
commit-message: '[release] Bump version to ${{ steps.bump-version.outputs.NEW_VERSION }}'

8
.mcp.json Normal file
View File

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

View File

@@ -4,7 +4,7 @@
- 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 speicifc 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 specific 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 +12,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 refrence to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
- 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
- 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.
@@ -36,3 +36,21 @@
- 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

View File

@@ -609,6 +609,68 @@ 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

View File

@@ -762,7 +762,7 @@ export class ComfyPage {
y: 625
}
})
this.page.mouse.move(10, 10)
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -774,7 +774,7 @@ export class ComfyPage {
},
button: 'right'
})
this.page.mouse.move(10, 10)
await this.page.mouse.move(10, 10)
await this.nextFrame()
}
@@ -1046,6 +1046,8 @@ export class ComfyPage {
}
}
export const testComfySnapToGridGridSize = 50
export const comfyPageFixture = base.extend<{
comfyPage: ComfyPage
comfyMouse: ComfyMouse
@@ -1072,7 +1074,8 @@ 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.TutorialCompleted': true,
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize
})
} catch (e) {
console.error(e)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 99 KiB

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -1,6 +1,12 @@
import { expect } from '@playwright/test'
import { Position } from '@vueuse/core'
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
import {
type ComfyPage,
comfyPageFixture as test,
testComfySnapToGridGridSize
} from '../fixtures/ComfyPage'
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
test.describe('Item Interaction', () => {
test('Can select/delete all items', async ({ comfyPage }) => {
@@ -57,8 +63,10 @@ test.describe('Node Interaction', () => {
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
})
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
const dragSelectNodes = async (
comfyPage: ComfyPage,
clipNodes: NodeReference[]
) => {
const clipNode1Pos = await clipNodes[0].getPosition()
const clipNode2Pos = await clipNodes[1].getPosition()
const offset = 64
@@ -74,10 +82,67 @@ 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: 67 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 101 KiB

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 191 KiB

After

Width:  |  Height:  |  Size: 190 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 117 KiB

After

Width:  |  Height:  |  Size: 116 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 238 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 79 KiB

After

Width:  |  Height:  |  Size: 78 KiB

1848
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.0",
"version": "1.22.1",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",
@@ -29,10 +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.44.1",
"@playwright/test": "^1.52.0",
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
"@types/dompurify": "^3.0.5",
"@types/fs-extra": "^11.0.4",

View File

@@ -1,14 +1,12 @@
<!-- The main global dialog to show various things -->
<template>
<Dialog
v-for="(item, index) in dialogStore.dialogStack"
v-for="item 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>
@@ -35,25 +33,11 @@
</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,19 +408,30 @@ 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
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)]
// 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)
}
}
})

View File

@@ -0,0 +1,41 @@
<template>
<img
:src="isImageError ? DEFAULT_BANNER : imgSrc"
:alt="nodePack.name + ' banner'"
class="object-cover"
:style="{ width: cssWidth, height: cssHeight }"
@error="isImageError = true"
/>
</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 shouldShowFallback = computed(
() => !nodePack.banner || nodePack.banner.trim() === '' || isImageError.value
)
const imgSrc = computed(() =>
shouldShowFallback.value ? DEFAULT_BANNER : nodePack.banner
)
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,19 +7,15 @@
}"
: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' },
title: {
class:
'self-stretch w-full px-4 py-3 inline-flex justify-start items-center gap-6'
},
content: { class: 'flex-1 flex flex-col rounded-2xl min-h-0' },
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
footer: { class: 'p-0 m-0' }
}"
>
<template #title>
<PackCardHeader :node-pack="nodePack" />
<PackBanner :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"
@@ -34,46 +30,63 @@
</template>
<template v-else>
<div
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
class="self-stretch inline-flex flex-col justify-start items-start"
>
<PackIcon :node-pack="nodePack" />
<div
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer 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"
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
>
<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 leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
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"
>
{{ nodePack.description }}
</p>
</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="flex flex-col gap-y-2">
<div
v-if="isUpdateAvailable"
class="w-4 h-4 relative overflow-hidden"
class="self-stretch inline-flex justify-start items-center gap-1"
>
<i class="pi pi-arrow-circle-up text-blue-600" />
<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>
</div>
<PackVersionBadge :node-pack="nodePack" />
</div>
</div>
</div>
@@ -92,11 +105,12 @@ 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'
@@ -107,6 +121,8 @@ const { nodePack, isSelected = false } = defineProps<{
isSelected?: boolean
}>()
const { d } = useI18n()
const isInstalling = ref(false)
provide(IsInstallingKey, isInstalling)
@@ -122,4 +138,19 @@ 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,39 +1,29 @@
<template>
<div
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
>
<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 v-if="nodePack.downloads" class="flex items-center gap-1.5">
<i class="pi pi-download text-muted"></i>
<span>{{ formattedDownloads }}</span>
</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 publisherName = computed(() => {
if (!nodePack) return null
const { n } = useI18n()
const { publisher, author } = nodePack
return publisher?.name ?? publisher?.id ?? author
})
const formattedDownloads = computed(() =>
nodePack.downloads ? n(nodePack.downloads) : ''
)
</script>

View File

@@ -54,4 +54,21 @@ 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,6 +28,7 @@ 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'
@@ -64,7 +65,7 @@ const formItem = computed(() => {
...props.setting,
name: t(`settings.${normalizedId}.name`, props.setting.name),
tooltip: props.setting.tooltip
? t(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
? st(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
: undefined,
options: props.setting.options
? translateOptions(props.setting.options)

View File

@@ -1,5 +1,6 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
@@ -46,11 +47,73 @@ vi.mock('@vueuse/core', () => ({
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => `/fileURL${path}`,
apiURL: (path: string) => `/apiURL${path}`
apiURL: (path: string) => `/apiURL${path}`,
addEventListener: vi.fn(),
removeEventListener: vi.fn()
}
}))
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', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
const createTemplate = (overrides = {}): TemplateInfo => ({
name: 'test-template',
mediaType: 'image',

View File

@@ -2,14 +2,18 @@
<Card
ref="cardRef"
:data-testid="`template-workflow-${template.name}`"
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
class="w-64 template-card rounded-2xl overflow-hidden shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:class="{
'cursor-pointer': isCompatible,
'cursor-not-allowed opacity-60 grayscale': !isCompatible
}"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
@click="$emit('loadWorkflow', template.name)"
@click="handleCardClick"
>
<template #header>
<div class="flex items-center justify-center">
<div class="flex items-center justify-center relative">
<div class="relative overflow-hidden rounded-t-lg">
<template v-if="template.mediaType === 'audio'">
<AudioThumbnail :src="baseThumbnailSrc" />
@@ -19,7 +23,7 @@
:base-image-src="baseThumbnailSrc"
:overlay-image-src="overlayThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-hovered="isHovered && isCompatible"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
@@ -31,7 +35,7 @@
:base-image-src="baseThumbnailSrc"
:overlay-image-src="overlayThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-hovered="isHovered && isCompatible"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
@@ -42,7 +46,7 @@
<DefaultThumbnail
:src="baseThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-hovered="isHovered && isCompatible"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
@@ -59,6 +63,21 @@
class="absolute inset-0 z-1 w-3/12 h-full"
/>
</div>
<!-- Version incompatibility indicator -->
<div
v-if="!isCompatible"
class="absolute top-2 right-2 bg-yellow-500 text-white text-xs px-2 py-1 rounded-full flex items-center gap-1 shadow-lg z-10"
:title="
$t('templateWorkflows.requiresUpgrade', {
version: template.versionRequired
})
"
>
<i class="pi pi-exclamation-triangle text-xs"></i>
<span class="text-xs font-medium"
>v{{ template.versionRequired }}+</span
>
</div>
</div>
</template>
<template #content>
@@ -70,6 +89,21 @@
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
{{ description }}
</p>
<!-- Upgrade prompt for incompatible templates -->
<div
v-if="!isCompatible"
class="mt-2 text-xs text-yellow-600 dark:text-yellow-400 font-medium"
>
{{ $t('templateWorkflows.upgradeToUse') }}
<br />
<span class="text-muted">
{{
$t('templateWorkflows.currentVersion', {
version: currentVersion
})
}}
</span>
</div>
</div>
</div>
</template>
@@ -86,8 +120,10 @@ 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 { api } from '@/scripts/api'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import { TemplateInfo } from '@/types/workflowTemplateTypes'
import { compareVersions } from '@/utils/formatUtil'
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
const DEFAULT_ZOOM_SCALE = 5
@@ -101,39 +137,64 @@ const { sourceModule, loading, template } = defineProps<{
const cardRef = ref<HTMLElement | null>(null)
const isHovered = useElementHover(cardRef)
const systemStatsStore = useSystemStatsStore()
const getThumbnailUrl = (index = '') => {
const basePath =
sourceModule === 'default'
? api.fileURL(`/templates/${template.name}`)
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
useTemplateWorkflows()
// For templates from custom nodes, multiple images is not yet supported
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
}
// Determine the effective source module to use (from template or prop)
const effectiveSourceModule = computed(
() => template.sourceModule || sourceModule
)
const baseThumbnailSrc = computed(() =>
getThumbnailUrl(sourceModule === 'default' ? '1' : '')
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '1' : ''
)
)
const overlayThumbnailSrc = computed(() =>
getThumbnailUrl(sourceModule === 'default' ? '2' : '')
getTemplateThumbnailUrl(
template,
effectiveSourceModule.value,
effectiveSourceModule.value === 'default' ? '2' : ''
)
)
const description = computed(() => {
return sourceModule === 'default'
? template.localizedDescription ?? ''
: template.description.replace(/[-_]/g, ' ').trim()
const description = computed(() =>
getTemplateDescription(template, effectiveSourceModule.value)
)
const title = computed(() =>
getTemplateTitle(template, effectiveSourceModule.value)
)
// Version compatibility check
const currentVersion = computed(() => {
return systemStatsStore.systemStats?.system?.comfyui_version || ''
})
const title = computed(() => {
return sourceModule === 'default'
? template.localizedTitle ?? ''
: template.name
const isCompatible = computed(() => {
if (!template.versionRequired) {
return true // No version requirement, always compatible
}
if (!currentVersion.value) {
return false // No current version available, assume incompatible
}
// Return true if current version is >= required version
return compareVersions(currentVersion.value, template.versionRequired) >= 0
})
defineEmits<{
const emit = defineEmits<{
loadWorkflow: [name: string]
}>()
const handleCardClick = () => {
if (isCompatible.value) {
emit('loadWorkflow', template.name)
}
}
</script>

View File

@@ -1,21 +1,19 @@
<template>
<DataTable
v-model:selection="selectedTemplate"
:value="templates"
:value="enrichedTemplates"
striped-rows
selection-mode="single"
>
<Column field="title" :header="$t('g.title')">
<template #body="slotProps">
<span :title="getTemplateTitle(slotProps.data)">{{
getTemplateTitle(slotProps.data)
}}</span>
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
</template>
</Column>
<Column field="description" :header="$t('g.description')">
<template #body="slotProps">
<span :title="getTemplateDescription(slotProps.data)">
{{ getTemplateDescription(slotProps.data) }}
<span :title="slotProps.data.description">
{{ slotProps.data.description }}
</span>
</template>
</Column>
@@ -38,8 +36,9 @@
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
const { sourceModule, loading, templates } = defineProps<{
@@ -50,21 +49,20 @@ 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="!workflowTemplatesStore.isLoaded || !isReady"
v-if="!isTemplatesLoaded || !isReady"
class="absolute w-8 h-full inset-0"
/>
<TemplateWorkflowsSideNav
:tabs="tabs"
:selected-tab="selectedTab"
:tabs="allTemplateGroups"
:selected-tab="selectedTemplate"
@update:selected-tab="handleTabSelection"
/>
</aside>
@@ -37,14 +37,14 @@
}"
>
<TemplateWorkflowView
v-if="isReady && selectedTab"
v-if="isReady && selectedTemplate"
class="px-12 py-4"
:title="selectedTab.title"
:source-module="selectedTab.moduleName"
:templates="selectedTab.templates"
:loading="workflowLoading"
:category-title="selectedTab.title"
@load-workflow="loadWorkflow"
:title="selectedTemplate.title"
:source-module="selectedTemplate.moduleName"
:templates="selectedTemplate.templates"
:loading="loadingTemplateId"
:category-title="selectedTemplate.title"
@load-workflow="handleLoadWorkflow"
/>
</div>
</div>
@@ -56,47 +56,55 @@ import { useAsyncState } from '@vueuse/core'
import Button from 'primevue/button'
import Divider from 'primevue/divider'
import ProgressSpinner from 'primevue/progressspinner'
import { computed, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { onMounted, watch } from 'vue'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useDialogStore } from '@/stores/dialogStore'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { useSystemStatsStore } from '@/stores/systemStatsStore'
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
const { t } = useI18n()
const {
isSmallScreen,
isOpen: isSideNavOpen,
toggle: toggleSideNav
} = useResponsiveCollapse()
const workflowTemplatesStore = useWorkflowTemplatesStore()
const { isReady } = useAsyncState(
workflowTemplatesStore.loadWorkflowTemplates,
null
const {
selectedTemplate,
loadingTemplateId,
isTemplatesLoaded,
allTemplateGroups,
loadTemplates,
selectFirstTemplateCategory,
selectTemplateCategory,
loadWorkflowTemplate
} = useTemplateWorkflows()
const { isReady } = useAsyncState(loadTemplates, null)
const systemStatsStore = useSystemStatsStore()
// Initialize system stats when component mounts
onMounted(async () => {
if (!systemStatsStore.systemStats) {
await systemStatsStore.fetchSystemStats()
}
})
watch(
isReady,
() => {
if (isReady.value) {
selectFirstTemplateCategory()
}
},
{ once: true }
)
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) => {
//Listbox allows deselecting so this special case is ignored here
if (selection !== selectedTab.value && selection !== null) {
selectedTab.value = selection
if (selection !== null) {
selectTemplateCategory(selection)
// On small screens, close the sidebar when a category is selected
if (isSmallScreen.value) {
@@ -105,30 +113,9 @@ const handleTabSelection = (selection: WorkflowTemplates | null) => {
}
}
const loadWorkflow = async (id: string) => {
if (!isReady.value) return
const handleLoadWorkflow = async (id: string) => {
if (!isReady.value || !selectedTemplate.value) return false
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
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
}
</script>

View File

@@ -101,6 +101,7 @@ 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,6 +7,7 @@ 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'
@@ -111,10 +112,15 @@ export const useNodeBadge = () => {
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
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
// 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 isLightTheme =
colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
@@ -137,7 +143,24 @@ 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

@@ -0,0 +1,85 @@
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,6 +4,7 @@ import {
LGraphNode,
LiteGraph
} from '@comfyorg/litegraph'
import { Point } from '@comfyorg/litegraph'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import {
@@ -27,6 +28,8 @@ 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()
@@ -58,6 +61,20 @@ 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',
@@ -673,6 +690,34 @@ 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

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

@@ -160,24 +160,45 @@ class Load3d {
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
}
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
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.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
this.renderer.setViewport(0, 0, width, height)
this.renderer.setScissor(0, 0, width, height)
this.renderer.setScissorTest(false)
}
private getActiveCamera(): THREE.Camera {
return this.cameraManager.activeCamera
}
@@ -198,20 +219,17 @@ class Load3d {
return
}
if (this.previewManager.showPreview) {
this.previewManager.updatePreviewRender()
}
const delta = this.clock.getDelta()
this.viewHelperManager.update(delta)
this.controlsManager.update()
this.renderer.clear()
this.sceneManager.renderBackground()
this.renderer.render(
this.sceneManager.scene,
this.cameraManager.activeCamera
)
this.renderMainScene()
if (this.previewManager.showPreview) {
this.previewManager.renderPreview()
}
this.resetViewport()
if (this.viewHelperManager.viewHelper.render) {
this.viewHelperManager.viewHelper.render(this.renderer)
@@ -298,17 +316,18 @@ 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)
if (this.previewManager.previewRenderer) {
this.previewManager.updateBackgroundTexture(
this.sceneManager.backgroundTexture
)
}
this.previewManager.updateBackgroundTexture(
this.sceneManager.backgroundTexture
)
this.forceRender()
}
@@ -316,12 +335,9 @@ class Load3d {
removeBackgroundImage(): void {
this.sceneManager.removeBackgroundImage()
if (
this.previewManager.previewRenderer &&
this.previewManager.previewCamera
) {
this.previewManager.updateBackgroundTexture(null)
}
this.previewManager.setPreviewBackgroundColor(
this.sceneManager.currentBackgroundColor
)
this.forceRender()
}
@@ -348,10 +364,6 @@ class Load3d {
setCameraState(state: CameraState): void {
this.cameraManager.setCameraState(state)
if (this.previewManager.showPreview) {
this.previewManager.syncWithMainCamera()
}
this.forceRender()
}

View File

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

View File

@@ -4,7 +4,6 @@ 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
@@ -17,7 +16,6 @@ export class PreviewManager implements PreviewManagerInterface {
private getControls: () => OrbitControls
private eventManager: EventManagerInterface
// @ts-expect-error unused variable
private getRenderer: () => THREE.WebGLRenderer
private previewBackgroundScene: THREE.Scene
@@ -25,6 +23,9 @@ 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,
@@ -45,15 +46,24 @@ 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)
const planeMaterial = new THREE.MeshBasicMaterial({
transparent: true,
this.previewBackgroundColorMaterial = new THREE.MeshBasicMaterial({
color: this.currentBackgroundColor.clone(),
transparent: false,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.previewBackgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
this.previewBackgroundMesh = new THREE.Mesh(
planeGeometry,
this.previewBackgroundColorMaterial
)
this.previewBackgroundMesh.position.set(0, 0, 0)
this.previewBackgroundScene.add(this.previewBackgroundMesh)
}
@@ -61,40 +71,23 @@ 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()
;(this.previewBackgroundMesh.material as THREE.Material).dispose()
if (this.previewBackgroundMesh.material instanceof THREE.Material) {
this.previewBackgroundMesh.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;
@@ -104,7 +97,6 @@ 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
@@ -131,7 +123,6 @@ export class PreviewManager implements PreviewManagerInterface {
}
this.updatePreviewSize()
this.updatePreviewRender()
})
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
@@ -159,57 +150,54 @@ export class PreviewManager implements PreviewManagerInterface {
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewRenderer?.setSize(this.previewWidth, previewHeight, false)
this.previewContainer.style.width = `${this.previewWidth}px`
this.previewContainer.style.height = `${previewHeight}px`
}
syncWithMainCamera(): void {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview) {
return
getPreviewViewport(): {
left: number
bottom: number
width: number
height: number
} | null {
if (!this.showPreview || !this.previewContainer) {
return null
}
this.previewCamera = this.getActiveCamera().clone()
const renderer = this.getRenderer()
const canvas = renderer.domElement
this.previewCamera.position.copy(this.getActiveCamera().position)
this.previewCamera.rotation.copy(this.getActiveCamera().rotation)
const containerRect = this.previewContainer.getBoundingClientRect()
const canvasRect = canvas.getBoundingClientRect()
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()
if (
containerRect.bottom < canvasRect.top ||
containerRect.top > canvasRect.bottom ||
containerRect.right < canvasRect.left ||
containerRect.left > canvasRect.right
) {
return null
}
this.previewCamera.lookAt(this.getControls().target)
const width = parseFloat(this.previewContainer.style.width)
const height = parseFloat(this.previewContainer.style.height)
this.updatePreviewRender()
const left = this.getRenderer().domElement.clientWidth - width
const bottom = 0
return { left, bottom, width, height }
}
updatePreviewRender(): void {
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
return
renderPreview(): void {
const viewport = this.getPreviewViewport()
if (!viewport) return
const renderer = this.getRenderer()
const originalClearColor = renderer.getClearColor(new THREE.Color())
const originalClearAlpha = renderer.getClearAlpha()
if (
!this.previewCamera ||
@@ -243,45 +231,77 @@ export class PreviewManager implements PreviewManagerInterface {
previewOrtho.updateProjectionMatrix()
} else {
;(this.previewCamera as THREE.PerspectiveCamera).aspect = aspect
;(this.previewCamera as THREE.PerspectiveCamera).fov = (
const activePerspective =
this.getActiveCamera() as THREE.PerspectiveCamera
).fov
;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix()
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
previewPerspective.fov = activePerspective.fov
previewPerspective.zoom = activePerspective.zoom
previewPerspective.aspect = aspect
previewPerspective.updateProjectionMatrix()
}
this.previewCamera.lookAt(this.getControls().target)
const previewHeight =
(this.previewWidth * this.targetHeight) / this.targetWidth
this.previewRenderer.setSize(this.previewWidth, previewHeight, false)
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
this.previewRenderer.clear()
renderer.setViewport(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
renderer.setScissor(
viewport.left,
viewport.bottom,
viewport.width,
viewport.height
)
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
renderer.setClearColor(0x000000, 0)
renderer.clear()
this.previewRenderer.toneMapping = THREE.NoToneMapping
this.previewRenderer.render(
this.previewBackgroundScene,
this.previewBackgroundCamera
)
this.renderPreviewBackground(renderer)
this.previewRenderer.toneMapping = currentToneMapping
this.previewRenderer.toneMappingExposure = currentExposure
}
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.render(this.scene, this.previewCamera)
this.previewBackgroundColorMaterial!.color.copy(this.currentBackgroundColor)
if (this.previewBackgroundMesh) {
this.previewBackgroundMesh.material = this.previewBackgroundColorMaterial!
}
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
this.previewBackgroundTexture = null
}
}
togglePreview(showPreview: boolean): void {
if (this.previewRenderer) {
this.showPreview = showPreview
this.showPreview = showPreview
if (this.previewContainer) {
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
}
@@ -306,7 +326,7 @@ export class PreviewManager implements PreviewManagerInterface {
)
}
if (this.previewRenderer && this.previewCamera) {
if (this.previewCamera) {
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
this.previewCamera.aspect = width / height
this.previewCamera.updateProjectionMatrix()
@@ -322,30 +342,45 @@ export class PreviewManager implements PreviewManagerInterface {
handleResize(): void {
this.updatePreviewSize()
this.updatePreviewRender()
}
updateBackgroundTexture(texture: THREE.Texture | null): void {
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
if (texture) {
if (this.previewBackgroundTexture) {
this.previewBackgroundTexture.dispose()
}
this.previewBackgroundTexture = texture
this.previewBackgroundTexture = texture
if (texture && this.previewBackgroundMesh) {
const material2 = this.previewBackgroundMesh
.material as THREE.MeshBasicMaterial
material2.map = texture
material2.needsUpdate = true
if (this.previewBackgroundMesh) {
const imageMaterial = new THREE.MeshBasicMaterial({
map: texture,
transparent: true,
depthWrite: false,
depthTest: false,
side: THREE.DoubleSide
})
this.previewBackgroundMesh.position.set(0, 0, 0)
if (
this.previewBackgroundMesh.material instanceof THREE.Material &&
this.previewBackgroundMesh.material !==
this.previewBackgroundColorMaterial
) {
this.previewBackgroundMesh.material.dispose()
}
this.updateBackgroundSize(
this.previewBackgroundTexture,
this.previewBackgroundMesh,
this.targetWidth,
this.targetHeight
)
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)
}
}

View File

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

View File

@@ -100,18 +100,23 @@ 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

@@ -501,6 +501,9 @@
},
"templateWorkflows": {
"title": "Get Started with a Template",
"requiresUpgrade": "Requires ComfyUI v{version} or higher",
"upgradeToUse": "Upgrade to use this template",
"currentVersion": "Current version: v{version}",
"category": {
"ComfyUI Examples": "ComfyUI Examples",
"Custom Nodes": "Custom Nodes",
@@ -514,7 +517,8 @@
"3D": "3D",
"Audio": "Audio",
"Image API": "Image API",
"Video API": "Video API"
"Video API": "Video API",
"All": "All Templates"
},
"templateDescription": {
"Basics": {

View File

@@ -1138,6 +1138,7 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Todas las plantillas",
"Area Composition": "Composición de Área",
"Audio": "Audio",
"Basics": "Básicos",
@@ -1151,6 +1152,8 @@
"Video": "Video",
"Video API": "API de Video"
},
"currentVersion": "Versión actual: v{version}",
"requiresUpgrade": "Requiere ComfyUI v{version} o superior",
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D 2.0 MV",
@@ -1353,7 +1356,8 @@
"api_veo2_i2v": "Usa la API Google Veo2 para generar videos a partir de imágenes."
}
},
"title": "Comienza con una Plantilla"
"title": "Comienza con una Plantilla",
"upgradeToUse": "Actualiza para usar esta plantilla"
},
"toastMessages": {
"couldNotDetermineFileType": "No se pudo determinar el tipo de archivo",

View File

@@ -1138,6 +1138,7 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Tous les modèles",
"Area Composition": "Composition de zone",
"Audio": "Audio",
"Basics": "Basiques",
@@ -1151,6 +1152,8 @@
"Video": "Vidéo",
"Video API": "API vidéo"
},
"currentVersion": "Version actuelle : v{version}",
"requiresUpgrade": "Nécessite ComfyUI v{version} ou supérieur",
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D Multivue",
@@ -1353,7 +1356,8 @@
"api_veo2_i2v": "Utilisez l'API Google Veo2 pour générer des vidéos à partir d'images."
}
},
"title": "Commencez avec un modèle"
"title": "Commencez avec un modèle",
"upgradeToUse": "Mettez à jour pour utiliser ce modèle"
},
"toastMessages": {
"couldNotDetermineFileType": "Impossible de déterminer le type de fichier",

View File

@@ -1138,6 +1138,7 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "すべてのテンプレート",
"Area Composition": "エリア構成",
"Audio": "オーディオ",
"Basics": "基本",
@@ -1151,6 +1152,8 @@
"Video": "ビデオ",
"Video API": "動画API"
},
"currentVersion": "現在のバージョン: v{version}",
"requiresUpgrade": "ComfyUI v{version} 以上が必要です",
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D マルチビュー",
@@ -1353,7 +1356,8 @@
"api_veo2_i2v": "Google Veo2 APIで画像から動画を生成します。"
}
},
"title": "テンプレートを利用して開始"
"title": "テンプレートを利用して開始",
"upgradeToUse": "このテンプレートを使用するにはアップグレードしてください"
},
"toastMessages": {
"couldNotDetermineFileType": "ファイルタイプを判断できませんでした",

View File

@@ -1138,6 +1138,7 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "모든 템플릿",
"Area Composition": "영역 구성",
"Audio": "오디오",
"Basics": "기본",
@@ -1151,6 +1152,8 @@
"Video": "비디오",
"Video API": "비디오 API"
},
"currentVersion": "현재 버전: v{version}",
"requiresUpgrade": "ComfyUI v{version} 이상이 필요합니다",
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D 다중뷰",
@@ -1353,7 +1356,8 @@
"api_veo2_i2v": "Google Veo2 API로 이미지에서 비디오를 생성합니다."
}
},
"title": "템플릿으로 시작하기"
"title": "템플릿으로 시작하기",
"upgradeToUse": "이 템플릿을 사용하려면 업그레이드하세요"
},
"toastMessages": {
"couldNotDetermineFileType": "파일 유형을 결정할 수 없습니다",

View File

@@ -1138,6 +1138,7 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "Все шаблоны",
"Area Composition": "Композиция области",
"Audio": "Аудио",
"Basics": "Основы",
@@ -1151,6 +1152,8 @@
"Video": "Видео",
"Video API": "Video API"
},
"currentVersion": "Текущая версия: v{version}",
"requiresUpgrade": "Требуется ComfyUI версии {version} или выше",
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "Hunyuan3D Многовидовой",
@@ -1353,7 +1356,8 @@
"api_veo2_i2v": "Используйте Google Veo2 API для генерации видео из изображений."
}
},
"title": "Начните с шаблона"
"title": "Начните с шаблона",
"upgradeToUse": "Обновите, чтобы использовать этот шаблон"
},
"toastMessages": {
"couldNotDetermineFileType": "Не удалось определить тип файла",

View File

@@ -1138,6 +1138,7 @@
"templateWorkflows": {
"category": {
"3D": "3D",
"All": "所有模板",
"Area Composition": "区域组成",
"Audio": "音频",
"Basics": "基础",
@@ -1151,6 +1152,8 @@
"Video": "视频",
"Video API": "视频 API"
},
"currentVersion": "当前版本v{version}",
"requiresUpgrade": "需要 ComfyUI v{version} 或更高版本",
"template": {
"3D": {
"hunyuan-3d-multiview-elf": "混元3D多视图",
@@ -1353,7 +1356,8 @@
"api_veo2_i2v": "使用 Google Veo2 API 通过图像生成视频。"
}
},
"title": "从模板开始"
"title": "从模板开始",
"upgradeToUse": "升级以使用此模板"
},
"toastMessages": {
"couldNotDetermineFileType": "无法确定文件类型",

View File

@@ -30,6 +30,12 @@ import {
isComboInputSpecV1,
isComboInputSpecV2
} from '@/schemas/nodeDefSchema'
import { getFromWebmFile } from '@/scripts/metadata/ebml'
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
import { getMp3Metadata } from '@/scripts/metadata/mp3'
import { getOggMetadata } from '@/scripts/metadata/ogg'
import { getSvgMetadata } from '@/scripts/metadata/svg'
import { useDialogService } from '@/services/dialogService'
import { useExtensionService } from '@/services/extensionService'
import { useLitegraphService } from '@/services/litegraphService'
@@ -52,7 +58,6 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
import { ExtensionManager } from '@/types/extensionTypes'
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
import { graphToPrompt } from '@/utils/executionUtil'
import { getFileHandler } from '@/utils/fileHandlers'
import {
executeWidgetsCallback,
fixLinkInputSlots,
@@ -68,7 +73,13 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
import { type ComfyApi, PromptExecutionError, api } from './api'
import { defaultGraph } from './defaultGraph'
import { pruneWidgets } from './domWidget'
import { importA1111 } from './pnginfo'
import {
getFlacMetadata,
getLatentMetadata,
getPngMetadata,
getWebpMetadata,
importA1111
} from './pnginfo'
import { $el, ComfyUI } from './ui'
import { ComfyAppMenu } from './ui/menu/index'
import { clone } from './utils'
@@ -1273,44 +1284,161 @@ export class ComfyApp {
return f.substring(0, p)
}
const fileName = removeExt(file.name)
// Get the appropriate file handler for this file type
const fileHandler = getFileHandler(file)
if (!fileHandler) {
// No handler found for this file type
this.showErrorOnFileLoad(file)
return
}
try {
// Process the file using the handler
const { workflow, prompt, parameters, jsonTemplateData } =
await fileHandler(file)
if (workflow) {
// We have a workflow, load it
await this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
// We have a prompt in API format, load it
this.loadApiJson(prompt, fileName)
} else if (parameters) {
// We have A1111 parameters, import them
if (file.type === 'image/png') {
const pngInfo = await getPngMetadata(file)
if (pngInfo?.workflow) {
await this.loadGraphData(
JSON.parse(pngInfo.workflow),
true,
true,
fileName
)
} else if (pngInfo?.prompt) {
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
} else if (pngInfo?.parameters) {
// Note: Not putting this in `importA1111` as it is mostly not used
// by external callers, and `importA1111` has no access to `app`.
useWorkflowService().beforeLoadNewGraph()
importA1111(this.graph, parameters)
importA1111(this.graph, pngInfo.parameters)
useWorkflowService().afterLoadNewGraph(
fileName,
this.graph.serialize() as unknown as ComfyWorkflowJSON
)
} else if (jsonTemplateData) {
// We have template data from JSON
this.loadTemplateData(jsonTemplateData)
} else {
// No usable data found in the file
this.showErrorOnFileLoad(file)
}
} catch (error) {
console.error('Error processing file:', error)
} else if (file.type === 'image/webp') {
const pngInfo = await getWebpMetadata(file)
// Support loading workflows from that webp custom node.
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'audio/mpeg') {
const { workflow, prompt } = await getMp3Metadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'audio/ogg') {
const { workflow, prompt } = await getOggMetadata(file)
if (workflow) {
this.loadGraphData(workflow, true, true, fileName)
} else if (prompt) {
this.loadApiJson(prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'audio/flac' || file.type === 'audio/x-flac') {
const pngInfo = await getFlacMetadata(file)
const workflow = pngInfo?.workflow || pngInfo?.Workflow
const prompt = pngInfo?.prompt || pngInfo?.Prompt
if (workflow) {
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
} else if (prompt) {
this.loadApiJson(JSON.parse(prompt), fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (file.type === 'video/webm') {
const webmInfo = await getFromWebmFile(file)
if (webmInfo.workflow) {
this.loadGraphData(webmInfo.workflow, true, true, fileName)
} else if (webmInfo.prompt) {
this.loadApiJson(webmInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'video/mp4' ||
file.name?.endsWith('.mp4') ||
file.name?.endsWith('.mov') ||
file.name?.endsWith('.m4v') ||
file.type === 'video/quicktime' ||
file.type === 'video/x-m4v'
) {
const mp4Info = await getFromIsobmffFile(file)
if (mp4Info.workflow) {
this.loadGraphData(mp4Info.workflow, true, true, fileName)
} else if (mp4Info.prompt) {
this.loadApiJson(mp4Info.prompt, fileName)
}
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
const svgInfo = await getSvgMetadata(file)
if (svgInfo.workflow) {
this.loadGraphData(svgInfo.workflow, true, true, fileName)
} else if (svgInfo.prompt) {
this.loadApiJson(svgInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'model/gltf-binary' ||
file.name?.endsWith('.glb')
) {
const gltfInfo = await getGltfBinaryMetadata(file)
if (gltfInfo.workflow) {
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
} else if (gltfInfo.prompt) {
this.loadApiJson(gltfInfo.prompt, fileName)
} else {
this.showErrorOnFileLoad(file)
}
} else if (
file.type === 'application/json' ||
file.name?.endsWith('.json')
) {
const reader = new FileReader()
reader.onload = async () => {
const readerResult = reader.result as string
const jsonContent = JSON.parse(readerResult)
if (jsonContent?.templates) {
this.loadTemplateData(jsonContent)
} else if (this.isApiJson(jsonContent)) {
this.loadApiJson(jsonContent, fileName)
} else {
await this.loadGraphData(
JSON.parse(readerResult),
true,
true,
fileName
)
}
}
reader.readAsText(file)
} else if (
file.name?.endsWith('.latent') ||
file.name?.endsWith('.safetensors')
) {
const info = await getLatentMetadata(file)
// TODO define schema to LatentMetadata
// @ts-expect-error
if (info.workflow) {
await this.loadGraphData(
// @ts-expect-error
JSON.parse(info.workflow),
true,
true,
fileName
)
// @ts-expect-error
} else if (info.prompt) {
// @ts-expect-error
this.loadApiJson(JSON.parse(info.prompt))
} else {
this.showErrorOnFileLoad(file)
}
} else {
this.showErrorOnFileLoad(file)
}
}
@@ -1427,7 +1555,6 @@ export class ComfyApp {
/**
* Registers a Comfy web extension with the app
* @param {ComfyExtension} extension
* @deprecated Use useExtensionService().registerExtension instead
*/
registerExtension(extension: ComfyExtension) {
useExtensionService().registerExtension(extension)

View File

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

View File

@@ -101,7 +101,51 @@ export const useWorkflowTemplatesStore = defineStore(
)
})
// Create an "All" category that combines all templates
const createAllCategory = () => {
// First, get core templates with source module added
const coreTemplatesWithSourceModule = coreTemplates.value.flatMap(
(category) =>
// For each template in each category, add the sourceModule and pass through any localized fields
category.templates.map((template) => {
// Get localized template with its original category title for i18n lookup
const localizedTemplate = addLocalizedFieldsToTemplate(
template,
category.title
)
return {
...localizedTemplate,
sourceModule: category.moduleName
}
})
)
// Now handle custom templates
const customTemplatesWithSourceModule = Object.entries(
customTemplates.value
).flatMap(([moduleName, templates]) =>
templates.map((name) => ({
name,
mediaType: 'image',
mediaSubtype: 'jpg',
description: name,
sourceModule: moduleName
}))
)
return {
moduleName: 'all',
title: 'All',
localizedTitle: st('templateWorkflows.category.All', 'All Templates'),
templates: [
...coreTemplatesWithSourceModule,
...customTemplatesWithSourceModule
]
}
}
const groupedTemplates = computed<TemplateGroup[]>(() => {
// Get regular categories
const allTemplates = [
...sortCategoryTemplates(coreTemplates.value).map(
localizeTemplateCategory
@@ -124,7 +168,8 @@ export const useWorkflowTemplatesStore = defineStore(
)
]
return Object.entries(
// Group templates by their main category
const groupedByCategory = Object.entries(
groupBy(allTemplates, (template) =>
template.moduleName === 'default'
? st(
@@ -134,6 +179,21 @@ export const useWorkflowTemplatesStore = defineStore(
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
)
).map(([label, modules]) => ({ label, modules }))
// Insert the "All" category at the top of the "ComfyUI Examples" group
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
(group) =>
group.label ===
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
)
if (comfyExamplesGroupIndex !== -1) {
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
createAllCategory()
)
}
return groupedByCategory
})
async function loadWorkflowTemplates() {

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,11 @@ export interface TemplateInfo {
description: string
localizedTitle?: string
localizedDescription?: string
sourceModule?: string
/**
* Minimum version of ComfyUI required to use this template
*/
versionRequired?: string
}
export interface WorkflowTemplates {

View File

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

View File

@@ -0,0 +1,802 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { describe, expect, it } from 'vitest'
import { useNodePricing } from '@/composables/node/useNodePricing'
// Helper function to create a mock node
function createMockNode(
nodeTypeName: string,
widgets: Array<{ name: string; value: any }> = [],
isApiNode = true
): LGraphNode {
const mockWidgets = widgets.map(({ name, value }) => ({
name,
value,
type: 'combo'
})) as IComboWidget[]
return {
id: Math.random().toString(),
widgets: mockWidgets,
constructor: {
nodeData: {
name: nodeTypeName,
api_node: isApiNode
}
}
} as unknown as LGraphNode
}
describe('useNodePricing', () => {
describe('static pricing', () => {
it('should return static price for FluxProCannyNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('FluxProCannyNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.05/Run')
})
it('should return static price for StabilityStableImageUltraNode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('StabilityStableImageUltraNode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
})
it('should return empty string for non-API nodes', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RegularNode', [], false)
const price = getNodeDisplayPrice(node)
expect(price).toBe('')
})
it('should return empty string for unknown node types', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('UnknownAPINode')
const price = getNodeDisplayPrice(node)
expect(price).toBe('')
})
})
describe('dynamic pricing - KlingTextToVideoNode', () => {
it('should return high price for kling-v2-master model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingTextToVideoNode', [
{ name: 'mode', value: 'standard / 5s / v2-master' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.40/Run')
})
it('should return standard price for kling-v1-6 model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingTextToVideoNode', [
{ name: 'mode', value: 'standard / 5s / v1-6' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.28/Run')
})
it('should return range when mode widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingTextToVideoNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
})
})
describe('dynamic pricing - KlingImage2VideoNode', () => {
it('should return high price for kling-v2-master model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingImage2VideoNode', [
{ name: 'model_name', value: 'v2-master' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.40/Run')
})
it('should return standard price for kling-v1-6 model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingImage2VideoNode', [
{ name: 'model_name', value: 'v1-6' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.28/Run')
})
it('should return range when model_name widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingImage2VideoNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
})
})
describe('dynamic pricing - OpenAIDalle3', () => {
it('should return $0.04 for 1024x1024 standard quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle3', [
{ name: 'size', value: '1024x1024' },
{ name: 'quality', value: 'standard' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.04/Run')
})
it('should return $0.08 for 1024x1024 hd quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle3', [
{ name: 'size', value: '1024x1024' },
{ name: 'quality', value: 'hd' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
})
it('should return $0.08 for 1792x1024 standard quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle3', [
{ name: 'size', value: '1792x1024' },
{ name: 'quality', value: 'standard' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
})
it('should return $0.16 for 1792x1024 hd quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle3', [
{ name: 'size', value: '1792x1024' },
{ name: 'quality', value: 'hd' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.12/Run')
})
it('should return $0.08 for 1024x1792 standard quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle3', [
{ name: 'size', value: '1024x1792' },
{ name: 'quality', value: 'standard' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
})
it('should return $0.16 for 1024x1792 hd quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle3', [
{ name: 'size', value: '1024x1792' },
{ name: 'quality', value: 'hd' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.12/Run')
})
it('should return range when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle3', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
})
it('should return range when size widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle3', [
{ name: 'quality', value: 'hd' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
})
it('should return range when quality widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle3', [
{ name: 'size', value: '1024x1024' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
})
})
describe('dynamic pricing - OpenAIDalle2', () => {
it('should return $0.02 for 1024x1024 size', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle2', [
{ name: 'size', value: '1024x1024' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.02/Run')
})
it('should return $0.018 for 512x512 size', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle2', [
{ name: 'size', value: '512x512' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.018/Run')
})
it('should return $0.016 for 256x256 size', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle2', [
{ name: 'size', value: '256x256' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.016/Run')
})
it('should return range when size widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIDalle2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.016-0.02/Run (varies with size)')
})
})
describe('dynamic pricing - OpenAIGPTImage1', () => {
it('should return high price range for high quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIGPTImage1', [
{ name: 'quality', value: 'high' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.167-0.30/Run')
})
it('should return medium price range for medium quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIGPTImage1', [
{ name: 'quality', value: 'medium' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.046-0.07/Run')
})
it('should return low price range for low quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIGPTImage1', [
{ name: 'quality', value: 'low' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.011-0.02/Run')
})
it('should return range when quality widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('OpenAIGPTImage1', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.011-0.30/Run (varies with quality)')
})
})
describe('dynamic pricing - IdeogramV3', () => {
it('should return $0.08 for Quality rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Quality' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.08/Run')
})
it('should return $0.06 for Balanced rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Balanced' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.06/Run')
})
it('should return $0.03 for Turbo rendering speed', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [
{ name: 'rendering_speed', value: 'Turbo' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03/Run')
})
it('should return range when rendering_speed widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('IdeogramV3', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.03-0.08/Run (varies with rendering speed)')
})
})
describe('dynamic pricing - VeoVideoGenerationNode', () => {
it('should return $5.00 for 10s duration', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('VeoVideoGenerationNode', [
{ name: 'duration_seconds', value: '10' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$5.00/Run')
})
it('should return $2.50 for 5s duration', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('VeoVideoGenerationNode', [
{ name: 'duration_seconds', value: '5' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$2.50/Run')
})
it('should return range when duration widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('VeoVideoGenerationNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$2.50-5.0/Run (varies with duration)')
})
})
describe('dynamic pricing - LumaVideoNode', () => {
it('should return $2.19 for ray-flash-2 4K 5s', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('LumaVideoNode', [
{ name: 'model', value: 'ray-flash-2' },
{ name: 'resolution', value: '4K' },
{ name: 'duration', value: '5s' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$2.19/Run')
})
it('should return $6.37 for ray-2 4K 5s', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('LumaVideoNode', [
{ name: 'model', value: 'ray-2' },
{ name: 'resolution', value: '4K' },
{ name: 'duration', value: '5s' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$6.37/Run')
})
it('should return $0.35 for ray-1-6 model', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('LumaVideoNode', [
{ name: 'model', value: 'ray-1-6' },
{ name: 'resolution', value: '1080p' },
{ name: 'duration', value: '5s' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.35/Run')
})
it('should return range when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('LumaVideoNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.14-11.47/Run (varies with model, resolution & duration)'
)
})
})
describe('dynamic pricing - PixverseTextToVideoNode', () => {
it('should return range for 5s 1080p quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PixverseTextToVideoNode', [
{ name: 'duration', value: '5s' },
{ name: 'quality', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.45-1.2/Run (varies with duration, quality & motion mode)'
)
})
it('should return range for 5s 540p normal quality', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PixverseTextToVideoNode', [
{ name: 'duration', value: '5s' },
{ name: 'quality', value: '540p' },
{ name: 'motion_mode', value: 'normal' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.45-1.2/Run (varies with duration, quality & motion mode)'
)
})
it('should return range when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PixverseTextToVideoNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe(
'$0.45-1.2/Run (varies with duration, quality & motion mode)'
)
})
})
describe('dynamic pricing - KlingDualCharacterVideoEffectNode', () => {
it('should return range for v2-master 5s mode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingDualCharacterVideoEffectNode', [
{ name: 'mode', value: 'standard / 5s / v2-master' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
})
it('should return range for v1-6 5s mode', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingDualCharacterVideoEffectNode', [
{ name: 'mode', value: 'standard / 5s / v1-6' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
})
it('should return range when mode widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingDualCharacterVideoEffectNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
})
})
describe('dynamic pricing - KlingSingleImageVideoEffectNode', () => {
it('should return $0.28 for fuzzyfuzzy effect', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingSingleImageVideoEffectNode', [
{ name: 'effect_scene', value: 'fuzzyfuzzy' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.28/Run')
})
it('should return $0.49 for dizzydizzy effect', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingSingleImageVideoEffectNode', [
{ name: 'effect_scene', value: 'dizzydizzy' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.49/Run')
})
it('should return range when effect_scene widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('KlingSingleImageVideoEffectNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.28-0.49/Run (varies with effect scene)')
})
})
describe('dynamic pricing - PikaImageToVideoNode2_2', () => {
it('should return $0.45 for 5s 1080p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaImageToVideoNode2_2', [
{ name: 'duration', value: '5s' },
{ name: 'resolution', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.45/Run')
})
it('should return $0.2 for 5s 720p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaImageToVideoNode2_2', [
{ name: 'duration', value: '5s' },
{ name: 'resolution', value: '720p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run')
})
it('should return $1.0 for 10s 1080p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaImageToVideoNode2_2', [
{ name: 'duration', value: '10s' },
{ name: 'resolution', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.0/Run')
})
it('should return range when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaImageToVideoNode2_2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)')
})
})
describe('dynamic pricing - PikaScenesV2_2', () => {
it('should return $0.3 for 5s 720p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaScenesV2_2', [
{ name: 'duration', value: '5s' },
{ name: 'resolution', value: '720p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.3/Run')
})
it('should return $0.25 for 10s 720p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaScenesV2_2', [
{ name: 'duration', value: '10s' },
{ name: 'resolution', value: '720p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.25/Run')
})
it('should return range when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaScenesV2_2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)')
})
})
describe('dynamic pricing - PikaStartEndFrameNode2_2', () => {
it('should return $0.2 for 5s 720p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaStartEndFrameNode2_2', [
{ name: 'duration', value: '5s' },
{ name: 'resolution', value: '720p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2/Run')
})
it('should return $1.0 for 10s 1080p', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaStartEndFrameNode2_2', [
{ name: 'duration', value: '10s' },
{ name: 'resolution', value: '1080p' }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$1.0/Run')
})
it('should return range when widgets are missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('PikaStartEndFrameNode2_2', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)')
})
})
describe('error handling', () => {
it('should gracefully handle errors in dynamic pricing functions', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Create a node with malformed widget data that could cause errors
const node = {
id: 'test-node',
widgets: null, // This could cause errors when accessing .find()
constructor: {
nodeData: {
name: 'KlingTextToVideoNode',
api_node: true
}
}
} as unknown as LGraphNode
// Should not throw an error and return empty string as fallback
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
})
it('should handle completely broken widget structure', () => {
const { getNodeDisplayPrice } = useNodePricing()
// Create a node with no widgets property at all
const node = {
id: 'test-node',
// No widgets property
constructor: {
nodeData: {
name: 'OpenAIDalle3',
api_node: true
}
}
} as unknown as LGraphNode
// Should gracefully fall back to the default range
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
})
})
describe('helper methods', () => {
describe('getNodePricingConfig', () => {
it('should return pricing config for known API nodes', () => {
const { getNodePricingConfig } = useNodePricing()
const node = createMockNode('KlingTextToVideoNode')
const config = getNodePricingConfig(node)
expect(config).toBeDefined()
expect(typeof config.displayPrice).toBe('function')
})
it('should return undefined for unknown nodes', () => {
const { getNodePricingConfig } = useNodePricing()
const node = createMockNode('UnknownNode')
const config = getNodePricingConfig(node)
expect(config).toBeUndefined()
})
})
describe('getRelevantWidgetNames', () => {
it('should return correct widget names for KlingTextToVideoNode', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('KlingTextToVideoNode')
expect(widgetNames).toEqual(['mode', 'model_name', 'duration'])
})
it('should return correct widget names for KlingImage2VideoNode', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('KlingImage2VideoNode')
expect(widgetNames).toEqual(['mode', 'model_name', 'duration'])
})
it('should return correct widget names for OpenAIDalle3', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('OpenAIDalle3')
expect(widgetNames).toEqual(['size', 'quality'])
})
it('should return correct widget names for VeoVideoGenerationNode', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('VeoVideoGenerationNode')
expect(widgetNames).toEqual(['duration_seconds'])
})
it('should return correct widget names for LumaVideoNode', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('LumaVideoNode')
expect(widgetNames).toEqual(['model', 'resolution', 'duration'])
})
it('should return correct widget names for KlingSingleImageVideoEffectNode', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames(
'KlingSingleImageVideoEffectNode'
)
expect(widgetNames).toEqual(['effect_scene'])
})
it('should return correct widget names for PikaImageToVideoNode2_2', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('PikaImageToVideoNode2_2')
expect(widgetNames).toEqual(['duration', 'resolution'])
})
it('should return empty array for unknown node types', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('UnknownNode')
expect(widgetNames).toEqual([])
})
describe('Recraft nodes with n parameter', () => {
it('should return correct widget names for RecraftTextToImageNode', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('RecraftTextToImageNode')
expect(widgetNames).toEqual(['n'])
})
it('should return correct widget names for RecraftTextToVectorNode', () => {
const { getRelevantWidgetNames } = useNodePricing()
const widgetNames = getRelevantWidgetNames('RecraftTextToVectorNode')
expect(widgetNames).toEqual(['n'])
})
})
})
describe('Recraft nodes dynamic pricing', () => {
it('should calculate dynamic pricing for RecraftTextToImageNode based on n value', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RecraftTextToImageNode', [
{ name: 'n', value: 3 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.12/Run') // 0.04 * 3
})
it('should calculate dynamic pricing for RecraftTextToVectorNode based on n value', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RecraftTextToVectorNode', [
{ name: 'n', value: 2 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.16/Run') // 0.08 * 2
})
it('should fall back to static display when n widget is missing', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RecraftTextToImageNode', [])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.04 x n/Run')
})
it('should handle edge case when n value is 1', () => {
const { getNodeDisplayPrice } = useNodePricing()
const node = createMockNode('RecraftImageInpaintingNode', [
{ name: 'n', value: 1 }
])
const price = getNodeDisplayPrice(node)
expect(price).toBe('$0.04/Run') // 0.04 * 1
})
})
})
})

View File

@@ -0,0 +1,301 @@
import { flushPromises } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
// Mock the store
vi.mock('@/stores/workflowTemplatesStore', () => ({
useWorkflowTemplatesStore: vi.fn()
}))
// Mock the API
vi.mock('@/scripts/api', () => ({
api: {
fileURL: vi.fn((path) => `mock-file-url${path}`),
apiURL: vi.fn((path) => `mock-api-url${path}`)
}
}))
// Mock the app
vi.mock('@/scripts/app', () => ({
app: {
loadGraphData: vi.fn()
}
}))
// Mock Vue I18n
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: vi.fn((key, fallback) => fallback || key)
})
}))
// Mock the dialog store
vi.mock('@/stores/dialogStore', () => ({
useDialogStore: vi.fn(() => ({
closeDialog: vi.fn()
}))
}))
// Mock fetch
global.fetch = vi.fn()
describe('useTemplateWorkflows', () => {
let mockWorkflowTemplatesStore: any
beforeEach(() => {
mockWorkflowTemplatesStore = {
isLoaded: false,
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
groupedTemplates: [
{
label: 'ComfyUI Examples',
modules: [
{
moduleName: 'all',
title: 'All',
localizedTitle: 'All Templates',
templates: [
{
name: 'template1',
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: 'default',
localizedTitle: 'Template 1'
},
{
name: 'template2',
mediaType: 'image',
mediaSubtype: 'jpg',
sourceModule: 'custom-module',
description: 'A custom template'
}
]
},
{
moduleName: 'default',
title: 'Default',
localizedTitle: 'Default Templates',
templates: [
{
name: 'template1',
mediaType: 'image',
mediaSubtype: 'jpg',
localizedTitle: 'Template 1',
localizedDescription: 'A default template'
}
]
}
]
}
]
}
vi.mocked(useWorkflowTemplatesStore).mockReturnValue(
mockWorkflowTemplatesStore
)
// Mock fetch response
vi.mocked(fetch).mockResolvedValue({
json: vi.fn().mockResolvedValue({ workflow: 'data' })
} as unknown as Response)
})
it('should load templates from store', async () => {
const { loadTemplates, isTemplatesLoaded } = useTemplateWorkflows()
expect(isTemplatesLoaded.value).toBe(false)
await loadTemplates()
expect(mockWorkflowTemplatesStore.loadWorkflowTemplates).toHaveBeenCalled()
})
it('should select the first template category', () => {
const { selectFirstTemplateCategory, selectedTemplate } =
useTemplateWorkflows()
selectFirstTemplateCategory()
expect(selectedTemplate.value).toEqual(
mockWorkflowTemplatesStore.groupedTemplates[0].modules[0]
)
})
it('should select a template category', () => {
const { selectTemplateCategory, selectedTemplate } = useTemplateWorkflows()
const category = mockWorkflowTemplatesStore.groupedTemplates[0].modules[1] // Default category
const result = selectTemplateCategory(category)
expect(result).toBe(true)
expect(selectedTemplate.value).toEqual(category)
})
it('should format template thumbnails correctly for default templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'default', '1')
expect(url).toBe('mock-file-url/templates/test-template-1.jpg')
})
it('should format template thumbnails correctly for custom templates', () => {
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
const template = {
name: 'test-template',
mediaSubtype: 'jpg',
mediaType: 'image',
description: 'Test template'
}
const url = getTemplateThumbnailUrl(template, 'custom-module')
expect(url).toBe(
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
)
})
it('should format template titles correctly', () => {
const { getTemplateTitle } = useTemplateWorkflows()
// Default template with localized title
const titleWithLocalized = getTemplateTitle(
{
name: 'test',
localizedTitle: 'Localized Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithLocalized).toBe('Localized Title')
// Default template without localized title
const titleWithFallback = getTemplateTitle(
{
name: 'test',
title: 'Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(titleWithFallback).toBe('Title')
// Custom template
const customTitle = getTemplateTitle(
{
name: 'test-template',
title: 'Custom Title',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(customTitle).toBe('Custom Title')
// Fallback to name
const nameOnly = getTemplateTitle(
{
name: 'name-only',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'custom-module'
)
expect(nameOnly).toBe('name-only')
})
it('should format template descriptions correctly', () => {
const { getTemplateDescription } = useTemplateWorkflows()
// Default template with localized description
const descWithLocalized = getTemplateDescription(
{
name: 'test',
localizedDescription: 'Localized Description',
mediaType: 'image',
mediaSubtype: 'jpg',
description: 'Test'
},
'default'
)
expect(descWithLocalized).toBe('Localized Description')
// Custom template with description
const customDesc = getTemplateDescription(
{
name: 'test',
description: 'custom-template_description',
mediaType: 'image',
mediaSubtype: 'jpg'
},
'custom-module'
)
expect(customDesc).toBe('custom template description')
})
it('should load a template from the "All" category', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Load a template from the "All" category
const result = await loadWorkflowTemplate('template1', 'all')
await flushPromises()
expect(result).toBe(true)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
expect(loadingTemplateId.value).toBe(null) // Should reset after loading
})
it('should load a template from a regular category', async () => {
const { loadWorkflowTemplate } = useTemplateWorkflows()
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Load a template from the default category
const result = await loadWorkflowTemplate('template1', 'default')
await flushPromises()
expect(result).toBe(true)
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
})
it('should handle errors when loading templates', async () => {
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
// Set the store as loaded
mockWorkflowTemplatesStore.isLoaded = true
// Mock fetch to throw an error
vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch'))
// Spy on console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
// Load a template that will fail
const result = await loadWorkflowTemplate('error-template', 'default')
expect(result).toBe(false)
expect(consoleSpy).toHaveBeenCalled()
expect(loadingTemplateId.value).toBe(null) // Should reset even after error
// Restore console.error
consoleSpy.mockRestore()
})
})

View File

@@ -0,0 +1,183 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
// Mock useChainCallback
vi.mock('@/composables/functional/useChainCallback', () => ({
useChainCallback: vi.fn((original, newCallback) => {
return function (this: any, ...args: any[]) {
original?.call(this, ...args)
newCallback.call(this, ...args)
}
})
}))
describe('useComputedWithWidgetWatch', () => {
const createMockNode = (
widgets: Array<{
name: string
value: any
callback?: (...args: any[]) => void
}> = []
) => {
const mockNode = {
widgets: widgets.map((widget) => ({
name: widget.name,
value: widget.value,
callback: widget.callback
})),
graph: {
setDirtyCanvas: vi.fn()
}
} as unknown as LGraphNode
return mockNode
}
it('should create a reactive computed that responds to widget changes', async () => {
const mockNode = createMockNode([
{ name: 'width', value: 100 },
{ name: 'height', value: 200 }
])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
const computedValue = computedWithWidgetWatch(() => {
const width =
(mockNode.widgets?.find((w) => w.name === 'width')?.value as number) ||
0
const height =
(mockNode.widgets?.find((w) => w.name === 'height')?.value as number) ||
0
return width * height
})
// Initial value should be computed correctly
expect(computedValue.value).toBe(20000)
// Change widget value and trigger callback
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
if (widthWidget) {
widthWidget.value = 150
;(widthWidget.callback as any)?.()
}
await nextTick()
// Computed should update
expect(computedValue.value).toBe(30000)
})
it('should only observe specific widgets when widgetNames is provided', async () => {
const mockNode = createMockNode([
{ name: 'width', value: 100 },
{ name: 'height', value: 200 },
{ name: 'depth', value: 50 }
])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
widgetNames: ['width', 'height']
})
const computedValue = computedWithWidgetWatch(() => {
return mockNode.widgets?.map((w) => w.value).join('-') || ''
})
expect(computedValue.value).toBe('100-200-50')
// Change observed widget
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
if (widthWidget) {
widthWidget.value = 150
;(widthWidget.callback as any)?.()
}
await nextTick()
expect(computedValue.value).toBe('150-200-50')
// Change non-observed widget - should not trigger update
const depthWidget = mockNode.widgets?.find((w) => w.name === 'depth')
if (depthWidget) {
depthWidget.value = 75
// Depth widget callback should not have been modified
expect(depthWidget.callback).toBeUndefined()
}
})
it('should trigger canvas redraw when triggerCanvasRedraw is true', async () => {
const mockNode = createMockNode([{ name: 'value', value: 10 }])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
triggerCanvasRedraw: true
})
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
// Change widget value
const widget = mockNode.widgets?.[0]
if (widget) {
widget.value = 20
;(widget.callback as any)?.()
}
await nextTick()
// Canvas redraw should have been triggered
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
})
it('should not trigger canvas redraw when triggerCanvasRedraw is false', async () => {
const mockNode = createMockNode([{ name: 'value', value: 10 }])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
triggerCanvasRedraw: false
})
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
// Change widget value
const widget = mockNode.widgets?.[0]
if (widget) {
widget.value = 20
;(widget.callback as any)?.()
}
await nextTick()
// Canvas redraw should not have been triggered
expect(mockNode.graph?.setDirtyCanvas).not.toHaveBeenCalled()
})
it('should handle nodes without widgets gracefully', () => {
const mockNode = createMockNode([])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
const computedValue = computedWithWidgetWatch(() => 'no widgets')
expect(computedValue.value).toBe('no widgets')
})
it('should chain with existing widget callbacks', async () => {
const existingCallback = vi.fn()
const mockNode = createMockNode([
{ name: 'value', value: 10, callback: existingCallback }
])
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
// Trigger widget callback
const widget = mockNode.widgets?.[0]
if (widget) {
;(widget.callback as any)?.()
}
await nextTick()
// Both existing callback and our callback should have been called
expect(existingCallback).toHaveBeenCalled()
})
})

View File

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

View File

@@ -13,6 +13,8 @@
"*.mts",
"*.config.js",
"browser_tests/**/*.ts",
"scripts/**/*.js",
"scripts/**/*.ts",
"tests-ui/**/*.ts"
]
}