Compare commits

...

32 Commits

Author SHA1 Message Date
Christian Byrne
90f6c7a6b8 1.20.7 (#4005)
Co-authored-by: Robin Huang <robin.j.huang@gmail.com>
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-05-27 22:30:16 -07:00
Robin Huang
0913e9891b [fix] Disable serialization for text preview widget (#4004) 2025-05-27 22:23:08 -07:00
filtered
08a046907d [Test] Disable flaky test (#3994) 2025-05-27 22:23:03 -07:00
filtered
3865e5493a 1.20.6 (#3986) 2025-05-27 14:42:37 +10:00
Christian Byrne
7ea8c340a7 Trigger browser test expectations update (#3959)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-26 21:22:25 -07:00
Terry Jia
5ecb82568e [3d] improve mtl support logic (#3965) 2025-05-26 20:28:53 -07:00
Christian Byrne
1d9d5e5061 v1.20.5 (#3973) 2025-05-23 19:02:51 -07:00
Terry Jia
be1d6d776a [3d] fix wrong generated language translation for 3d node output (#3967)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-23 18:25:18 -07:00
Terry Jia
ada7dde51c [3d] performance improve (#3961) 2025-05-23 18:25:18 -07:00
Comfy Org PR Bot
32605eeb8f 1.20.4 (#3951)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-20 23:03:12 -04:00
Christian Byrne
f08ec0a981 [API Node] Add cost indicators on API nodes (#3924)
Co-authored-by: github-actions <github-actions@github.com>
Co-authored-by: Chenlei Hu <huchenlei@proton.me>
2025-05-20 21:59:11 -04:00
Christian Byrne
356886dc29 [Feature] Add MP4 workflow file open support (#3950) 2025-05-20 17:57:20 -07:00
Christian Byrne
f96de24a66 Migrate user.css file to upstream (#3949) 2025-05-20 20:11:06 -04:00
Christian Byrne
9d48487af8 [Dev Tools] Add more claude directives (#3948)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-20 13:45:31 -07:00
Christian Byrne
69b534bf14 [UI] Improve template card spacing and responsive image display (#3930)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-20 14:14:05 -04:00
Christian Byrne
2acb2ac181 [Style] Increase template card elevation in dark mode (#3946) 2025-05-20 14:13:12 -04:00
Christian Byrne
37a583e39c [fix] Adjust API node badge colors for light theme (#3945) 2025-05-20 14:12:51 -04:00
gfejer
d8821db2be PWA support (#3639)
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-05-20 02:31:59 -07:00
Yiximail
f2c4e567e4 Add a button to selection toolbox to open mask editor (#3603)
Co-authored-by: bymyself <cbyrne@comfy.org>
2025-05-20 01:23:39 -07:00
Christian Byrne
fec4c4e928 [docs] Add comprehensive documentation for browser tests (#3942)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-05-19 23:08:03 -07:00
Terry Jia
49d32f4809 [3d] support mtl for obj file (#3933)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-19 20:48:57 -07:00
Christian Byrne
07f0b88e30 Require description field to be non-empty when reporting issue from the UI (#3939)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-20 13:18:19 +10:00
Christian Byrne
d92ed22908 Remove leftover asset (#3938) 2025-05-20 12:46:35 +10:00
Christian Byrne
24c0c2c499 [Dev Tools] Add claude config (#3937) 2025-05-20 12:34:51 +10:00
Christian Byrne
774bff2ed6 [Refactor] Move component test next to component (#3940) 2025-05-20 11:52:26 +10:00
Comfy Org PR Bot
6d87f2b2ff 1.20.3 (#3932)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-18 21:26:18 -04:00
Christian Byrne
20911aa892 docs: improve README development section organization (#3929)
Co-authored-by: filtered <176114999+webfiltered@users.noreply.github.com>
2025-05-19 09:38:59 +10:00
Christian Byrne
3a6018589e Add testing documentation guides for frontend tests (#3927) 2025-05-19 08:41:51 +10:00
Christian Byrne
4c92a7142e Fix: Close user popover on button clicks (#3928) 2025-05-19 08:41:33 +10:00
Christian Byrne
293993e7de Hide manager button in missing nodes dialog when manager is not installed (#3925) 2025-05-18 12:16:24 -04:00
Christian Byrne
a7ee3fae05 Add tests for ChatHistoryWidget and related features (#3921) 2025-05-18 12:16:06 -04:00
Christian Byrne
22dc84324e [docs] add READMEs for major folders (#3923) 2025-05-18 12:11:56 -04:00
101 changed files with 6006 additions and 364 deletions

34
CLAUDE.md Normal file
View File

@@ -0,0 +1,34 @@
- Be sure to run unit tests, component tests, browser tests then typecheck, lint, format (with prettier) when you're done making a series of code changes. You can find the scripts for all these things in the package.json.
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
- 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
- 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.
- Prefer running single tests, and not the whole test suite, for performance
- 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
- 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.
- 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. Do not define a `props` variable; instead, destructure props. Since vue 3.5, destructuring props does not strip them of reactivity.
- Use Tailwind CSS for styling
- Leverage VueUse functions for performance-enhancing styles
- Use lodash for utility functions
- Use TypeScript for type safety
- Implement proper props and emits definitions
- Utilize Vue 3's Teleport component when needed
- Use Suspense for async components
- Implement proper error handling
- Follow Vue 3 style guide and naming conventions
- Use Vite for fast development and building
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.

View File

@@ -526,11 +526,20 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
## Development
### Prerequisites
### Prerequisites & Technology Stack
- Node.js (v16 or later) and npm must be installed
- Git for version control
- A running ComfyUI backend instance
- **Required Software**:
- Node.js (v16 or later) and npm
- Git for version control
- A running ComfyUI backend instance
- **Tech Stack**:
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
- [zod](https://zod.dev/) for schema validation
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
### Initial Setup
@@ -558,15 +567,6 @@ To launch ComfyUI and have it connect to your development server:
python main.py --port 8188
```
### Tech Stack
- [Vue 3](https://vuejs.org/) with [TypeScript](https://www.typescriptlang.org/)
- [Pinia](https://pinia.vuejs.org/) for state management
- [PrimeVue](https://primevue.org/) with [TailwindCSS](https://tailwindcss.com/) for UI
- [litegraph.js](https://github.com/Comfy-Org/litegraph.js) for node editor
- [zod](https://zod.dev/) for schema validation
- [vue-i18n](https://github.com/intlify/vue-i18n) for internationalization
### Git pre-commit hooks
Run `npm run prepare` to install Git pre-commit hooks. Currently, the pre-commit
@@ -579,6 +579,7 @@ core extensions will be loaded.
- Start local ComfyUI backend at `localhost:8188`
- Run `npm run dev` to start the dev server
- Run `npm run dev:electron` to start the dev server with electron API mocked
#### Access dev server on touch devices

View File

@@ -1,6 +1,6 @@
# Playwright Testing for ComfyUI_frontend
This document outlines the setup and usage of Playwright for testing the ComfyUI_frontend project.
This document outlines the setup, usage, and common patterns for Playwright browser tests in the ComfyUI_frontend project.
## WARNING
@@ -31,7 +31,7 @@ If you are running Playwright tests in parallel or running the same test multipl
## Running Tests
There are two ways to run the tests:
There are multiple ways to run the tests:
1. **Headless mode with report generation:**
```bash
@@ -47,14 +47,239 @@ There are two ways to run the tests:
![Playwright UI Mode](https://github.com/user-attachments/assets/6a1ebef0-90eb-4157-8694-f5ee94d03755)
3. **Running specific tests:**
```bash
npx playwright test widget.spec.ts
```
## Test Structure
Browser tests in this project follow a specific organization pattern:
- **Fixtures**: Located in `fixtures/` - These provide test setup and utilities
- `ComfyPage.ts` - The main fixture for interacting with ComfyUI
- `ComfyMouse.ts` - Utility for mouse interactions with the canvas
- Components fixtures in `fixtures/components/` - Page object models for UI components
- **Tests**: Located in `tests/` - The actual test specifications
- Organized by functionality (e.g., `widget.spec.ts`, `interaction.spec.ts`)
- Snapshot directories (e.g., `widget.spec.ts-snapshots/`) contain reference screenshots
- **Utilities**: Located in `utils/` - Common utility functions
- `litegraphUtils.ts` - Utilities for working with LiteGraph nodes
## Writing Effective Tests
When writing new tests, follow these patterns:
### Test Structure
```typescript
// Import the test fixture
import { comfyPageFixture as test } from '../fixtures/ComfyPage';
test.describe('Feature Name', () => {
// Set up test environment if needed
test.beforeEach(async ({ comfyPage }) => {
// Common setup
});
test('should do something specific', async ({ comfyPage }) => {
// Test implementation
});
});
```
### Leverage Existing Fixtures and Helpers
Always check for existing helpers and fixtures before implementing new ones:
- **ComfyPage**: Main fixture with methods for canvas interaction and node management
- **ComfyMouse**: Helper for precise mouse operations on the canvas
- **Helpers**: Check `browser_tests/helpers/` for specialized helpers like:
- `actionbar.ts`: Interact with the action bar
- `manageGroupNode.ts`: Group node management operations
- `templates.ts`: Template workflows operations
- **Component Fixtures**: Check `browser_tests/fixtures/components/` for UI component helpers
- **Utility Functions**: Check `browser_tests/utils/` and `browser_tests/fixtures/utils/` for shared utilities
Most common testing needs are already addressed by these helpers, which will make your tests more consistent and reliable.
### Key Testing Patterns
1. **Focus elements explicitly**:
Canvas-based elements often need explicit focus before interaction:
```typescript
// Click the canvas first to focus it before pressing keys
await comfyPage.canvas.click();
await comfyPage.page.keyboard.press('a');
```
2. **Mark canvas as dirty if needed**:
Some interactions need explicit canvas updates:
```typescript
// After programmatically changing node state, mark canvas dirty
await comfyPage.page.evaluate(() => {
window['app'].graph.setDirtyCanvas(true, true);
});
```
3. **Use node references over coordinates**:
Node references from `fixtures/utils/litegraphUtils.ts` provide stable ways to interact with nodes:
```typescript
// Prefer this:
const node = await comfyPage.getNodeRefsByType('LoadImage')[0];
await node.click('title');
// Over this:
await comfyPage.canvas.click({ position: { x: 100, y: 100 } });
```
4. **Wait for canvas to render after UI interactions**:
```typescript
await comfyPage.nextFrame();
```
5. **Clean up persistent server state**:
While most state is reset between tests, anything stored on the server persists:
```typescript
// Reset settings that affect other tests (these are stored on server)
await comfyPage.setSetting('Comfy.ColorPalette', 'dark');
await comfyPage.setSetting('Comfy.NodeBadge.NodeIdBadgeMode', 'None');
// Clean up uploaded files if needed
await comfyPage.request.delete(`${comfyPage.url}/api/delete/image.png`);
```
6. **Prefer functional assertions over screenshots**:
Use screenshots only when visual verification is necessary:
```typescript
// Prefer this:
expect(await node.isPinned()).toBe(true);
expect(await node.getProperty('title')).toBe('Expected Title');
// Over this - only use when needed:
await expect(comfyPage.canvas).toHaveScreenshot('state.png');
```
7. **Use minimal test workflows**:
When creating test workflows, keep them as minimal as possible:
```typescript
// Include only the components needed for the test
await comfyPage.loadWorkflow('single_ksampler');
```
## Common Patterns and Utilities
### Page Object Pattern
Tests use the Page Object pattern to create abstractions over the UI:
```typescript
// Using the ComfyPage fixture
test('Can toggle boolean widget', async ({ comfyPage }) => {
await comfyPage.loadWorkflow('widgets/boolean_widget')
const node = (await comfyPage.getFirstNodeRef())!
const widget = await node.getWidget(0)
await widget.click()
});
```
### Node References
The `NodeReference` class provides helpers for interacting with LiteGraph nodes:
```typescript
// Getting node by type and interacting with it
const nodes = await comfyPage.getNodeRefsByType('LoadImage')
const loadImageNode = nodes[0]
const widget = await loadImageNode.getWidget(0)
await widget.click()
```
### Visual Regression Testing
Tests use screenshot comparisons to verify UI state:
```typescript
// Take a screenshot and compare to reference
await expect(comfyPage.canvas).toHaveScreenshot('boolean_widget_toggled.png')
```
### Waiting for Animations
Always call `nextFrame()` after actions that trigger animations:
```typescript
await comfyPage.canvas.click({ position: { x: 100, y: 100 } })
await comfyPage.nextFrame() // Wait for canvas to redraw
```
### Mouse Interactions
Canvas operations use special helpers to ensure proper timing:
```typescript
// Using ComfyMouse for drag and drop
await comfyMouse.dragAndDrop(
{ x: 100, y: 100 }, // From
{ x: 200, y: 200 } // To
)
// Standard ComfyPage helpers
await comfyPage.drag({ x: 100, y: 100 }, { x: 200, y: 200 })
await comfyPage.pan({ x: 200, y: 200 })
await comfyPage.zoom(-100) // Zoom in
```
### Workflow Management
Tests use workflows stored in `assets/` for consistent starting points:
```typescript
// Load a test workflow
await comfyPage.loadWorkflow('single_ksampler')
// Wait for workflow to load and stabilize
await comfyPage.nextFrame()
```
### Custom Assertions
The project includes custom Playwright assertions through `comfyExpect`:
```typescript
// Check if a node is in a specific state
await expect(node).toBePinned()
await expect(node).toBeBypassed()
await expect(node).toBeCollapsed()
```
## Troubleshooting Common Issues
### Flaky Tests
- **Timing Issues**: Always wait for animations to complete with `nextFrame()`
- **Coordinate Sensitivity**: Canvas coordinates are viewport-relative; use node references when possible
- **Test Isolation**: Tests run in parallel; avoid dependencies between tests
- **Screenshots vary**: Ensure your OS and browser match the reference environment (Linux)
- **Async / await**: Race conditions are a very common cause of test flakiness
## Screenshot Expectations
Due to variations in system font rendering, screenshot expectations are platform-specific. Please note:
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment.
- To set new test expectations:
1. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch.
2. Add the `New Browser Test Expectation` tag to your pull request.
3. This will trigger a GitHub action to update the screenshot expectations automatically.
- **DO NOT commit local screenshot expectations** to the repository
- We maintain Linux screenshot expectations as our GitHub Action runner operates in a Linux environment
- While developing, you can generate local screenshots for your tests, but these will differ from CI-generated ones
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.
To set new test expectations for PR:
1. Write your test with screenshot assertions using `toHaveScreenshot(filename)`
2. Create a pull request from a `Comfy-Org/ComfyUI_frontend` branch
3. Add the `New Browser Test Expectation` tag to your pull request
4. The GitHub CI will automatically generate and commit the reference screenshots
This approach ensures consistent screenshot expectations across all PRs and avoids issues with platform-specific rendering.
> **Note:** If you're making a pull request from a forked repository, the GitHub action won't be able to commit updated screenshot expectations directly to your PR branch.

View File

@@ -0,0 +1,139 @@
import { Page, expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
interface ChatHistoryEntry {
prompt: string
response: string
response_id: string
}
async function renderChatHistory(page: Page, history: ChatHistoryEntry[]) {
const nodeId = await page.evaluate(() => window['app'].graph.nodes[0]?.id)
// Simulate API sending display_component message
await page.evaluate(
({ nodeId, history }) => {
const event = new CustomEvent('display_component', {
detail: {
node_id: nodeId,
component: 'ChatHistoryWidget',
props: {
history: JSON.stringify(history)
}
}
})
window['app'].api.dispatchEvent(event)
return true
},
{ nodeId, history }
)
return nodeId
}
test.describe('Chat History Widget', () => {
let nodeId: string
test.beforeEach(async ({ comfyPage }) => {
nodeId = await renderChatHistory(comfyPage.page, [
{ prompt: 'Hello', response: 'World', response_id: '123' }
])
// Wait for chat history to be rendered
await comfyPage.page.waitForSelector('.pi-pencil')
})
test('displays chat history when receiving display_component message', async ({
comfyPage
}) => {
// Verify the chat history is displayed correctly
await expect(comfyPage.page.getByText('Hello')).toBeVisible()
await expect(comfyPage.page.getByText('World')).toBeVisible()
})
test('handles message editing interaction', async ({ comfyPage }) => {
// Get first node's ID
nodeId = await comfyPage.page.evaluate(() => {
const node = window['app'].graph.nodes[0]
// Make sure the node has a prompt widget (for editing functionality)
if (!node.widgets) {
node.widgets = []
}
// Add a prompt widget if it doesn't exist
if (!node.widgets.find((w) => w.name === 'prompt')) {
node.widgets.push({
name: 'prompt',
type: 'text',
value: 'Original prompt'
})
}
return node.id
})
await renderChatHistory(comfyPage.page, [
{
prompt: 'Message 1',
response: 'Response 1',
response_id: '123'
},
{
prompt: 'Message 2',
response: 'Response 2',
response_id: '456'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
const originalTextAreaInput = await comfyPage.page
.getByPlaceholder('text')
.nth(1)
.inputValue()
// Click edit button on first message
await comfyPage.page.getByLabel('Edit').first().click()
await comfyPage.nextFrame()
// Verify cancel button appears
await expect(comfyPage.page.getByLabel('Cancel')).toBeVisible()
// Click cancel edit
await comfyPage.page.getByLabel('Cancel').click()
// Verify prompt input is restored
await expect(comfyPage.page.getByPlaceholder('text').nth(1)).toHaveValue(
originalTextAreaInput
)
})
test('handles real-time updates to chat history', async ({ comfyPage }) => {
// Send initial history
await renderChatHistory(comfyPage.page, [
{
prompt: 'Initial message',
response: 'Initial response',
response_id: '123'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
// Update history with additional messages
await renderChatHistory(comfyPage.page, [
{
prompt: 'Follow-up',
response: 'New response',
response_id: '456'
}
])
await comfyPage.page.waitForSelector('.pi-pencil')
// Move mouse over the canvas to force update
await comfyPage.page.mouse.move(100, 100)
await comfyPage.nextFrame()
// Verify new messages appear
await expect(comfyPage.page.getByText('Follow-up')).toBeVisible()
await expect(comfyPage.page.getByText('New response')).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 57 KiB

View File

@@ -32,7 +32,9 @@ test.describe('Templates', () => {
}
})
test('should have all required thumbnail media for each template', async ({
// TODO: Re-enable this test once issue resolved
// https://github.com/Comfy-Org/ComfyUI_frontend/issues/3992
test.skip('should have all required thumbnail media for each template', async ({
comfyPage
}) => {
test.slow()
@@ -142,4 +144,136 @@ test.describe('Templates', () => {
// Expect the title to be used as fallback for the template categories
await expect(comfyPage.page.getByLabel('FALLBACK CATEGORY')).toBeVisible()
})
test('template cards are dynamically sized and responsive', async ({
comfyPage
}) => {
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Wait for at least one template card to appear
await expect(comfyPage.page.locator('.template-card').first()).toBeVisible({
timeout: 5000
})
// Take snapshot of the template grid
const templateGrid = comfyPage.templates.content.locator('.grid').first()
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-desktop.png')
// Check cards at mobile viewport size
await comfyPage.page.setViewportSize({ width: 640, height: 800 })
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-mobile.png')
// Check cards at tablet size
await comfyPage.page.setViewportSize({ width: 1024, height: 800 })
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot('template-grid-tablet.png')
})
test('hover effects work on template cards', async ({ comfyPage }) => {
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Get a template card
const firstCard = comfyPage.page.locator('.template-card').first()
await expect(firstCard).toBeVisible({ timeout: 5000 })
// Take snapshot before hover
await expect(firstCard).toHaveScreenshot('template-card-before-hover.png')
// Hover over the card
await firstCard.hover()
// Take snapshot after hover to verify hover effect
await expect(firstCard).toHaveScreenshot('template-card-after-hover.png')
})
test('template cards descriptions adjust height dynamically', async ({
comfyPage
}) => {
// Setup test by intercepting templates response to inject cards with varying description lengths
await comfyPage.page.route('**/templates/index.json', async (route, _) => {
const response = [
{
moduleName: 'default',
title: 'Test Templates',
type: 'image',
templates: [
{
name: 'short-description',
title: 'Short Description',
mediaType: 'image',
mediaSubtype: 'webp',
description: 'This is a short description.'
},
{
name: 'medium-description',
title: 'Medium Description',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'This is a medium length description that should take up two lines on most displays.'
},
{
name: 'long-description',
title: 'Long Description',
mediaType: 'image',
mediaSubtype: 'webp',
description:
'This is a much longer description that should definitely wrap to multiple lines. It contains enough text to demonstrate how the cards handle varying amounts of content while maintaining a consistent layout grid.'
}
]
}
]
await route.fulfill({
status: 200,
body: JSON.stringify(response),
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store'
}
})
})
// Mock the thumbnail images to avoid 404s
await comfyPage.page.route('**/templates/**.webp', async (route) => {
const headers = {
'Content-Type': 'image/webp',
'Cache-Control': 'no-store'
}
await route.fulfill({
status: 200,
path: 'browser_tests/assets/example.webp',
headers
})
})
// Open templates dialog
await comfyPage.executeCommand('Comfy.BrowseTemplates')
await expect(comfyPage.templates.content).toBeVisible()
// Verify cards are visible with varying content lengths
await expect(
comfyPage.page.getByText('This is a short description.')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a medium length description')
).toBeVisible({ timeout: 5000 })
await expect(
comfyPage.page.getByText('This is a much longer description')
).toBeVisible({ timeout: 5000 })
// Take snapshot of a grid with specific cards
const templateGrid = comfyPage.templates.content
.locator('.grid:has-text("Short Description")')
.first()
await expect(templateGrid).toBeVisible()
await expect(templateGrid).toHaveScreenshot(
'template-grid-varying-content.png'
)
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 191 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 239 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

View File

@@ -6,7 +6,15 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="materialdesignicons.min.css" />
<!-- Fullscreen mode on iOS -->
<meta name="apple-mobile-web-app-capable" content="yes">
<!-- Status bar style (eg. black or transparent) -->
<meta name="apple-mobile-web-app-status-bar-style" content="black">
<link rel="manifest" href="manifest.json">
</head>
<body class="litegraph grid">
<div id="vue-app"></div>
<script type="module" src="src/main.ts"></script>

9
manifest.json Normal file
View File

@@ -0,0 +1,9 @@
{
"name": "ComfyUI",
"short_name": "ComfyUI",
"description": "ComfyUI: AI image generation platform",
"start_url": "/",
"display": "standalone",
"background_color": "#ffffff",
"theme_color": "#000000"
}

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.2",
"version": "1.20.6",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.2",
"version": "1.20.6",
"license": "GPL-3.0-only",
"dependencies": {
"@alloc/quick-lru": "^5.2.0",

View File

@@ -1,7 +1,7 @@
{
"name": "@comfyorg/comfyui-frontend",
"private": true,
"version": "1.20.2",
"version": "1.20.7",
"type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org",

View File

@@ -1 +0,0 @@
/* Put custom styles here */

Binary file not shown.

Before

Width:  |  Height:  |  Size: 36 KiB

View File

@@ -30,7 +30,7 @@
</div>
</template>
</ListBox>
<div class="flex justify-end py-3">
<div v-if="isManagerInstalled" class="flex justify-end py-3">
<Button label="Open Manager" size="small" outlined @click="openManager" />
</div>
</template>
@@ -42,6 +42,7 @@ import { computed } from 'vue'
import NoResultsPlaceholder from '@/components/common/NoResultsPlaceholder.vue'
import { useDialogService } from '@/services/dialogService'
import { useAboutPanelStore } from '@/stores/aboutPanelStore'
import type { MissingNodeType } from '@/types/comfy'
import { ManagerTab } from '@/types/comfyManagerTypes'
@@ -49,6 +50,19 @@ const props = defineProps<{
missingNodeTypes: MissingNodeType[]
}>()
const aboutPanelStore = useAboutPanelStore()
// Determines if ComfyUI-Manager is installed by checking for its badge in the about panel
// This allows us to conditionally show the Manager button only when the extension is available
// TODO: Remove this check when Manager functionality is fully migrated into core
const isManagerInstalled = computed(() => {
return aboutPanelStore.badges.some(
(badge) =>
badge.label.includes('ComfyUI-Manager') ||
badge.url.includes('ComfyUI-Manager')
)
})
const uniqueNodes = computed(() => {
const seenTypes = new Set()
return props.missingNodeTypes

View File

@@ -124,12 +124,16 @@
:aria-label="$t('issueReport.provideAdditionalDetails')"
/>
<Message
v-if="$field?.error && $field.touched && $field.value"
v-if="$field?.error && $field.touched"
severity="error"
size="small"
variant="simple"
>
{{ t('issueReport.validation.maxLength') }}
{{
$field.value
? t('issueReport.validation.maxLength')
: t('issueReport.validation.descriptionRequired')
}}
</Message>
</FormField>
</div>

View File

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

View File

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

View File

@@ -10,6 +10,7 @@
<ColorPickerButton />
<BypassButton />
<PinButton />
<MaskEditorButton />
<DeleteButton />
<RefreshButton />
<ExtensionCommandButton
@@ -24,18 +25,18 @@
import Panel from 'primevue/panel'
import { computed } from 'vue'
import BypassButton from '@/components/graph/selectionToolbox/BypassButton.vue'
import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerButton.vue'
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'
import { useExtensionService } from '@/services/extensionService'
import { type ComfyCommandImpl, useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import BypassButton from './selectionToolbox/BypassButton.vue'
import DeleteButton from './selectionToolbox/DeleteButton.vue'
import ExtensionCommandButton from './selectionToolbox/ExtensionCommandButton.vue'
import PinButton from './selectionToolbox/PinButton.vue'
import RefreshButton from './selectionToolbox/RefreshButton.vue'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const extensionService = useExtensionService()

View File

@@ -0,0 +1,35 @@
<template>
<Button
v-show="isSingleImageNode"
v-tooltip.top="{
value: t('commands.Comfy_MaskEditor_OpenMaskEditor.label'),
showDelay: 1000
}"
severity="secondary"
text
icon="pi pi-pencil"
@click="openMaskEditor"
/>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import { computed } from 'vue'
import { t } from '@/i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useCanvasStore } from '@/stores/graphStore'
import { isImageNode, isLGraphNode } from '@/utils/litegraphUtil'
const commandStore = useCommandStore()
const canvasStore = useCanvasStore()
const isSingleImageNode = computed(() => {
const nodes = canvasStore.selectedItems.filter(isLGraphNode)
return nodes.length === 1 && nodes.some(isImageNode)
})
const openMaskEditor = () => {
void commandStore.execute('Comfy.MaskEditor.OpenMaskEditor')
}
</script>

View File

@@ -0,0 +1,95 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { createI18n } from 'vue-i18n'
import ChatHistoryWidget from '@/components/graph/widgets/ChatHistoryWidget.vue'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
g: { edit: 'Edit' },
chatHistory: {
cancelEdit: 'Cancel edit',
cancelEditTooltip: 'Cancel edit'
}
}
}
})
vi.mock('@/components/graph/widgets/chatHistory/CopyButton.vue', () => ({
default: {
name: 'CopyButton',
template: '<div class="mock-copy-button"></div>',
props: ['text']
}
}))
vi.mock('@/components/graph/widgets/chatHistory/ResponseBlurb.vue', () => ({
default: {
name: 'ResponseBlurb',
template: '<div class="mock-response-blurb"><slot /></div>',
props: ['text']
}
}))
describe('ChatHistoryWidget.vue', () => {
const mockHistory = JSON.stringify([
{ prompt: 'Test prompt', response: 'Test response', response_id: '123' }
])
const mountWidget = (props: { history: string; widget?: any }) => {
return mount(ChatHistoryWidget, {
props,
global: {
plugins: [i18n],
stubs: {
Button: {
template: '<button><slot /></button>',
props: ['icon', 'aria-label']
},
ScrollPanel: { template: '<div><slot /></div>' }
}
}
})
}
it('renders chat history correctly', () => {
const wrapper = mountWidget({ history: mockHistory })
expect(wrapper.text()).toContain('Test prompt')
expect(wrapper.text()).toContain('Test response')
})
it('handles empty history', () => {
const wrapper = mountWidget({ history: '[]' })
expect(wrapper.find('.mb-4').exists()).toBe(false)
})
it('edits previous prompts', () => {
const mockWidget = {
node: { widgets: [{ name: 'prompt', value: '' }] }
}
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
const vm = wrapper.vm as any
vm.handleEdit(0)
expect(mockWidget.node.widgets[0].value).toContain('Test prompt')
expect(mockWidget.node.widgets[0].value).toContain('starting_point_id')
})
it('cancels editing correctly', () => {
const mockWidget = {
node: { widgets: [{ name: 'prompt', value: 'Original value' }] }
}
const wrapper = mountWidget({ history: mockHistory, widget: mockWidget })
const vm = wrapper.vm as any
vm.handleEdit(0)
vm.handleCancelEdit()
expect(mockWidget.node.widgets[0].value).toBe('Original value')
})
})

View File

@@ -96,8 +96,7 @@ const setPromptInput = (text: string, previousResponseId?: string | null) => {
}
const handleEdit = (index: number) => {
if (!promptInput) return
promptInput ??= widget?.node.widgets?.find((w) => w.name === 'prompt')
editIndex.value = index
const prevResponseId = index === 0 ? 'start' : getPreviousResponseId(index)
const promptText = parsedHistory.value[index]?.prompt ?? ''

View File

@@ -6,7 +6,7 @@
<script setup lang="ts">
import { LGraphNode } from '@comfyorg/litegraph'
import { onMounted, onUnmounted, ref, toRaw, watch, watchEffect } from 'vue'
import { onMounted, onUnmounted, ref, toRaw, watch } from 'vue'
import LoadingOverlay from '@/components/load3d/LoadingOverlay.vue'
import Load3d from '@/extensions/core/load3d/Load3d'
@@ -76,19 +76,82 @@ const eventConfig = {
emit('recordingStatusChange', value)
} as const
watchEffect(async () => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
watch(
() => props.showPreview,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setBackgroundColor(props.backgroundColor)
rawLoad3d.toggleGrid(props.showGrid)
rawLoad3d.setLightIntensity(props.lightIntensity)
rawLoad3d.setFOV(props.fov)
rawLoad3d.toggleCamera(props.cameraType)
rawLoad3d.togglePreview(props.showPreview)
await rawLoad3d.setBackgroundImage(props.backgroundImage)
rawLoad3d.togglePreview(newValue)
}
}
})
)
watch(
() => props.cameraType,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleCamera(newValue)
}
}
)
watch(
() => props.fov,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setFOV(newValue)
}
}
)
watch(
() => props.lightIntensity,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setLightIntensity(newValue)
}
}
)
watch(
() => props.showGrid,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.toggleGrid(newValue)
}
}
)
watch(
() => props.backgroundColor,
(newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
rawLoad3d.setBackgroundColor(newValue)
}
}
)
watch(
() => props.backgroundImage,
async (newValue) => {
if (load3d.value) {
const rawLoad3d = toRaw(load3d.value) as Load3d
await rawLoad3d.setBackgroundImage(newValue)
}
}
)
watch(
() => props.upDirection,
@@ -154,12 +217,13 @@ const handleEvents = (action: 'add' | 'remove') => {
}
onMounted(() => {
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
// @ts-expect-error fixme ts strict error
container.value,
props.inputSpec
)
if (container.value) {
load3d.value = useLoad3dService().registerLoad3d(
node.value as LGraphNode,
container.value,
props.inputSpec
)
}
handleEvents('add')
})

View File

@@ -0,0 +1,205 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import TemplateWorkflowCard from '@/components/templates/TemplateWorkflowCard.vue'
import { TemplateInfo } from '@/types/workflowTemplateTypes'
vi.mock('@/components/templates/thumbnails/AudioThumbnail.vue', () => ({
default: {
name: 'AudioThumbnail',
template: '<div class="mock-audio-thumbnail" :data-src="src"></div>',
props: ['src']
}
}))
vi.mock('@/components/templates/thumbnails/CompareSliderThumbnail.vue', () => ({
default: {
name: 'CompareSliderThumbnail',
template:
'<div class="mock-compare-slider" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
}
}))
vi.mock('@/components/templates/thumbnails/DefaultThumbnail.vue', () => ({
default: {
name: 'DefaultThumbnail',
template: '<div class="mock-default-thumbnail" :data-src="src"></div>',
props: ['src', 'alt', 'isHovered', 'isVideo', 'hoverZoom']
}
}))
vi.mock('@/components/templates/thumbnails/HoverDissolveThumbnail.vue', () => ({
default: {
name: 'HoverDissolveThumbnail',
template:
'<div class="mock-hover-dissolve" :data-base="baseImageSrc" :data-overlay="overlayImageSrc"></div>',
props: ['baseImageSrc', 'overlayImageSrc', 'alt', 'isHovered']
}
}))
vi.mock('@vueuse/core', () => ({
useElementHover: () => ref(false)
}))
vi.mock('@/scripts/api', () => ({
api: {
fileURL: (path: string) => `/fileURL${path}`,
apiURL: (path: string) => `/apiURL${path}`
}
}))
describe('TemplateWorkflowCard', () => {
const createTemplate = (overrides = {}): TemplateInfo => ({
name: 'test-template',
mediaType: 'image',
mediaSubtype: 'png',
thumbnailVariant: 'default',
description: 'Test description',
...overrides
})
const mountCard = (props = {}) => {
return mount(TemplateWorkflowCard, {
props: {
sourceModule: 'default',
categoryTitle: 'Test Category',
loading: false,
template: createTemplate(),
...props
},
global: {
stubs: {
Card: {
template:
'<div class="card" @click="$emit(\'click\')"><slot name="header" /><slot name="content" /></div>',
props: ['dataTestid', 'pt']
},
ProgressSpinner: {
template: '<div class="progress-spinner"></div>'
}
}
}
})
}
it('emits loadWorkflow event when clicked', async () => {
const wrapper = mountCard({
template: createTemplate({ name: 'test-workflow' })
})
await wrapper.find('.card').trigger('click')
expect(wrapper.emitted('loadWorkflow')).toBeTruthy()
expect(wrapper.emitted('loadWorkflow')?.[0]).toEqual(['test-workflow'])
})
it('shows loading spinner when loading is true', () => {
const wrapper = mountCard({ loading: true })
expect(wrapper.find('.progress-spinner').exists()).toBe(true)
})
it('renders audio thumbnail for audio media type', () => {
const wrapper = mountCard({
template: createTemplate({ mediaType: 'audio' })
})
expect(wrapper.find('.mock-audio-thumbnail').exists()).toBe(true)
})
it('renders compare slider thumbnail for compareSlider variant', () => {
const wrapper = mountCard({
template: createTemplate({ thumbnailVariant: 'compareSlider' })
})
expect(wrapper.find('.mock-compare-slider').exists()).toBe(true)
})
it('renders hover dissolve thumbnail for hoverDissolve variant', () => {
const wrapper = mountCard({
template: createTemplate({ thumbnailVariant: 'hoverDissolve' })
})
expect(wrapper.find('.mock-hover-dissolve').exists()).toBe(true)
})
it('renders default thumbnail by default', () => {
const wrapper = mountCard()
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
})
it('passes correct props to default thumbnail for video', () => {
const wrapper = mountCard({
template: createTemplate({ mediaType: 'video' })
})
const thumbnail = wrapper.find('.mock-default-thumbnail')
expect(thumbnail.exists()).toBe(true)
})
it('uses zoomHover scale when variant is zoomHover', () => {
const wrapper = mountCard({
template: createTemplate({ thumbnailVariant: 'zoomHover' })
})
expect(wrapper.find('.mock-default-thumbnail').exists()).toBe(true)
})
it('displays localized title for default source module', () => {
const wrapper = mountCard({
sourceModule: 'default',
template: createTemplate({ localizedTitle: 'My Localized Title' })
})
expect(wrapper.text()).toContain('My Localized Title')
})
it('displays template name as title for non-default source modules', () => {
const wrapper = mountCard({
sourceModule: 'custom',
template: createTemplate({ name: 'custom-template' })
})
expect(wrapper.text()).toContain('custom-template')
})
it('displays localized description for default source module', () => {
const wrapper = mountCard({
sourceModule: 'default',
template: createTemplate({
localizedDescription: 'My Localized Description'
})
})
expect(wrapper.text()).toContain('My Localized Description')
})
it('processes description for non-default source modules', () => {
const wrapper = mountCard({
sourceModule: 'custom',
template: createTemplate({ description: 'custom_module-description' })
})
expect(wrapper.text()).toContain('custom module description')
})
it('generates correct thumbnail URLs for default source module', () => {
const wrapper = mountCard({
sourceModule: 'default',
template: createTemplate({
name: 'my-template',
mediaSubtype: 'jpg'
})
})
const vm = wrapper.vm as any
expect(vm.baseThumbnailSrc).toBe('/fileURL/templates/my-template-1.jpg')
expect(vm.overlayThumbnailSrc).toBe('/fileURL/templates/my-template-2.jpg')
})
it('generates correct thumbnail URLs for custom source module', () => {
const wrapper = mountCard({
sourceModule: 'custom-module',
template: createTemplate({
name: 'my-template',
mediaSubtype: 'png'
})
})
const vm = wrapper.vm as any
expect(vm.baseThumbnailSrc).toBe(
'/apiURL/workflow_templates/custom-module/my-template.png'
)
expect(vm.overlayThumbnailSrc).toBe(
'/apiURL/workflow_templates/custom-module/my-template.png'
)
})
})

View File

@@ -2,7 +2,7 @@
<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 h-full"
class="w-64 template-card rounded-2xl overflow-hidden cursor-pointer shadow-elevation-2 dark-theme:bg-dark-elevation-1.5 h-full"
:pt="{
body: { class: 'p-0 h-full flex flex-col' }
}"
@@ -20,6 +20,10 @@
:overlay-image-src="overlayThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else-if="template.thumbnailVariant === 'hoverDissolve'">
@@ -28,6 +32,10 @@
:overlay-image-src="overlayThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
/>
</template>
<template v-else>
@@ -35,6 +43,10 @@
:src="baseThumbnailSrc"
:alt="title"
:is-hovered="isHovered"
:is-video="
template.mediaType === 'video' ||
template.mediaSubtype === 'webp'
"
:hover-zoom="
template.thumbnailVariant === 'zoomHover'
? UPSCALE_ZOOM_SCALE
@@ -52,18 +64,13 @@
<template #content>
<div class="flex items-center px-4 py-3">
<div class="flex-1 flex flex-col">
<h3 class="line-clamp-2 text-lg font-normal mb-0 h-12" :title="title">
<h3 class="line-clamp-2 text-lg font-normal mb-0" :title="title">
{{ title }}
</h3>
<p class="line-clamp-2 text-sm text-muted grow" :title="description">
{{ description }}
</p>
</div>
<div
class="flex md:hidden xl:flex items-center justify-center ml-4 w-10 h-10 rounded-full"
>
<i class="pi pi-angle-right text-2xl" />
</div>
</div>
</template>
</Card>

View File

@@ -0,0 +1,133 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
import { TemplateInfo } from '@/types/workflowTemplateTypes'
vi.mock('primevue/dataview', () => ({
default: {
name: 'DataView',
template: `
<div class="p-dataview">
<div class="dataview-header"><slot name="header"></slot></div>
<div class="dataview-content">
<slot name="grid" :items="value"></slot>
</div>
</div>
`,
props: ['value', 'layout', 'lazy', 'pt']
}
}))
vi.mock('primevue/selectbutton', () => ({
default: {
name: 'SelectButton',
template:
'<div class="p-selectbutton"><slot name="option" :option="modelValue"></slot></div>',
props: ['modelValue', 'options', 'allowEmpty']
}
}))
vi.mock('@/components/templates/TemplateWorkflowCard.vue', () => ({
default: {
template: `
<div
class="mock-template-card"
:data-name="template.name"
:data-source-module="sourceModule"
:data-category-title="categoryTitle"
:data-loading="loading"
@click="$emit('loadWorkflow', template.name)"
></div>
`,
props: ['sourceModule', 'categoryTitle', 'loading', 'template'],
emits: ['loadWorkflow']
}
}))
vi.mock('@/components/templates/TemplateWorkflowList.vue', () => ({
default: {
template: '<div class="mock-template-list"></div>',
props: ['sourceModule', 'categoryTitle', 'loading', 'templates'],
emits: ['loadWorkflow']
}
}))
vi.mock('@vueuse/core', () => ({
useLocalStorage: () => 'grid'
}))
describe('TemplateWorkflowView', () => {
const createTemplate = (name: string): TemplateInfo => ({
name,
mediaType: 'image',
mediaSubtype: 'png',
thumbnailVariant: 'default',
description: `Description for ${name}`
})
const mountView = (props = {}) => {
return mount(TemplateWorkflowView, {
props: {
title: 'Test Templates',
sourceModule: 'default',
categoryTitle: 'Test Category',
templates: [
createTemplate('template-1'),
createTemplate('template-2'),
createTemplate('template-3')
],
loading: null,
...props
}
})
}
it('renders template cards for each template', () => {
const wrapper = mountView()
const cards = wrapper.findAll('.mock-template-card')
expect(cards.length).toBe(3)
expect(cards[0].attributes('data-name')).toBe('template-1')
expect(cards[1].attributes('data-name')).toBe('template-2')
expect(cards[2].attributes('data-name')).toBe('template-3')
})
it('emits loadWorkflow event when clicked', async () => {
const wrapper = mountView()
const card = wrapper.find('.mock-template-card')
await card.trigger('click')
expect(wrapper.emitted()).toHaveProperty('loadWorkflow')
// Check that the emitted event contains the template name
const emitted = wrapper.emitted('loadWorkflow')
expect(emitted).toBeTruthy()
expect(emitted?.[0][0]).toBe('template-1')
})
it('passes correct props to template cards', () => {
const wrapper = mountView({
sourceModule: 'custom',
categoryTitle: 'Custom Category'
})
const card = wrapper.find('.mock-template-card')
expect(card.exists()).toBe(true)
expect(card.attributes('data-source-module')).toBe('custom')
expect(card.attributes('data-category-title')).toBe('Custom Category')
})
it('applies loading state correctly to cards', () => {
const wrapper = mountView({
loading: 'template-2'
})
const cards = wrapper.findAll('.mock-template-card')
// Only the second card should have loading=true since loading="template-2"
expect(cards[0].attributes('data-loading')).toBe('false')
expect(cards[1].attributes('data-loading')).toBe('true')
expect(cards[2].attributes('data-loading')).toBe('false')
})
})

View File

@@ -34,7 +34,7 @@
<template #grid="{ items }">
<div
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] auto-rows-fr gap-8 justify-items-center"
class="grid grid-cols-[repeat(auto-fill,minmax(16rem,1fr))] gap-x-4 gap-y-8 px-4 justify-items-center"
>
<TemplateWorkflowCard
v-for="template in items"

View File

@@ -0,0 +1,35 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue'
vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
default: {
name: 'BaseThumbnail',
template: '<div class="base-thumbnail"><slot /></div>'
}
}))
describe('AudioThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(AudioThumbnail, {
props: {
src: '/test-audio.mp3',
...props
}
})
}
it('renders an audio element with correct src', () => {
const wrapper = mountThumbnail()
const audio = wrapper.find('audio')
expect(audio.exists()).toBe(true)
expect(audio.attributes('src')).toBe('/test-audio.mp3')
})
it('uses BaseThumbnail as container', () => {
const wrapper = mountThumbnail()
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.exists()).toBe(true)
})
})

View File

@@ -1,6 +1,6 @@
<template>
<BaseThumbnail>
<div class="w-64 h-64 flex items-center justify-center p-4">
<div class="w-full h-full flex items-center justify-center p-4">
<audio controls class="w-full relative" :src="src" @click.stop />
</div>
</BaseThumbnail>

View File

@@ -0,0 +1,66 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
vi.mock('@vueuse/core', () => ({
useEventListener: vi.fn()
}))
describe('BaseThumbnail', () => {
const mountThumbnail = (props = {}, slots = {}) => {
return mount(BaseThumbnail, {
props,
slots: {
default: '<img src="/test.jpg" alt="test" />',
...slots
}
})
}
it('renders slot content', () => {
const wrapper = mountThumbnail()
expect(wrapper.find('img').exists()).toBe(true)
})
it('applies hover zoom with correct style', () => {
const wrapper = mountThumbnail({ isHovered: true })
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.attributes('style')).toContain('transform')
expect(contentDiv.attributes('style')).toContain('scale')
})
it('applies custom hover zoom value', () => {
const wrapper = mountThumbnail({ hoverZoom: 10, isHovered: true })
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.attributes('style')).toContain('scale(1.1)')
})
it('does not apply scale when not hovered', () => {
const wrapper = mountThumbnail({ isHovered: false })
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.attributes('style')).toBeUndefined()
})
it('shows error state when image fails to load', async () => {
const wrapper = mountThumbnail()
const vm = wrapper.vm as any
// Manually set error since useEventListener is mocked
vm.error = true
await nextTick()
expect(wrapper.find('.pi-file').exists()).toBe(true)
expect(wrapper.find('.transform-gpu').exists()).toBe(false)
})
it('applies transition classes to content', () => {
const wrapper = mountThumbnail()
const contentDiv = wrapper.find('.transform-gpu')
expect(contentDiv.classes()).toContain('transform-gpu')
expect(contentDiv.classes()).toContain('transition-transform')
expect(contentDiv.classes()).toContain('duration-1000')
expect(contentDiv.classes()).toContain('ease-out')
})
})

View File

@@ -3,7 +3,7 @@
<div
v-if="!error"
ref="contentRef"
class="w-64 h-64 object-cover transform-gpu transition-transform duration-1000 ease-out"
class="w-full h-full transform-gpu transition-transform duration-1000 ease-out"
:style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
"

View File

@@ -0,0 +1,74 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
default: {
name: 'BaseThumbnail',
template: '<div class="base-thumbnail"><slot /></div>',
props: ['isHovered']
}
}))
vi.mock('@vueuse/core', () => ({
useMouseInElement: () => ({
elementX: ref(50),
elementWidth: ref(100),
isOutside: ref(false)
})
}))
describe('CompareSliderThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(CompareSliderThumbnail, {
props: {
baseImageSrc: '/base-image.jpg',
overlayImageSrc: '/overlay-image.jpg',
alt: 'Comparison Image',
isVideo: false,
...props
}
})
}
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe('/base-image.jpg')
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
})
it('applies clip-path style to overlay image', () => {
const wrapper = mountThumbnail()
const overlay = wrapper.findAll('img')[1]
expect(overlay.attributes('style')).toContain('clip-path')
})
it('renders slider divider', () => {
const wrapper = mountThumbnail()
const divider = wrapper.find('.bg-white\\/30')
expect(divider.exists()).toBe(true)
})
it('positions slider based on default value', () => {
const wrapper = mountThumbnail()
const divider = wrapper.find('.bg-white\\/30')
expect(divider.attributes('style')).toContain('left: 21%')
})
it('passes isHovered prop to BaseThumbnail', () => {
const wrapper = mountThumbnail({ isHovered: true })
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.props('isHovered')).toBe(true)
})
})

View File

@@ -1,11 +1,23 @@
<template>
<BaseThumbnail :is-hovered="isHovered">
<img :src="baseImageSrc" :alt="alt" class="w-full h-full object-cover" />
<img
:src="baseImageSrc"
:alt="alt"
:class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
/>
<div ref="containerRef" class="absolute inset-0">
<img
:src="overlayImageSrc"
:alt="alt"
class="w-full h-full object-cover"
:class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
:style="{
clipPath: `inset(0 ${100 - sliderPosition}% 0 0)`
}"
@@ -28,13 +40,20 @@ import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
const SLIDER_START_POSITION = 21
const { isHovered } = defineProps<{
const { baseImageSrc, overlayImageSrc, isHovered, isVideo } = defineProps<{
baseImageSrc: string
overlayImageSrc: string
alt: string
isHovered?: boolean
isVideo?: boolean
}>()
const isVideoType =
isVideo ||
baseImageSrc?.toLowerCase().endsWith('.webp') ||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
false
const sliderPosition = ref(SLIDER_START_POSITION)
const containerRef = ref<HTMLElement | null>(null)

View File

@@ -0,0 +1,102 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
default: {
name: 'BaseThumbnail',
template: '<div class="base-thumbnail"><slot /></div>',
props: ['hoverZoom', 'isHovered']
}
}))
describe('DefaultThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(DefaultThumbnail, {
props: {
src: '/test-image.jpg',
alt: 'Test Image',
hoverZoom: 5,
...props
}
})
}
it('renders image with correct src and alt', () => {
const wrapper = mountThumbnail()
const img = wrapper.find('img')
expect(img.attributes('src')).toBe('/test-image.jpg')
expect(img.attributes('alt')).toBe('Test Image')
})
it('applies scale transform when hovered', () => {
const wrapper = mountThumbnail({
isHovered: true,
hoverZoom: 10
})
const img = wrapper.find('img')
expect(img.attributes('style')).toContain('scale(1.1)')
})
it('does not apply scale transform when not hovered', () => {
const wrapper = mountThumbnail({
isHovered: false
})
const img = wrapper.find('img')
expect(img.attributes('style')).toBeUndefined()
})
it('applies video styling for video type', () => {
const wrapper = mountThumbnail({
isVideo: true
})
const img = wrapper.find('img')
expect(img.classes()).toContain('w-full')
expect(img.classes()).toContain('h-full')
expect(img.classes()).toContain('object-cover')
})
it('applies image styling for non-video type', () => {
const wrapper = mountThumbnail({
isVideo: false
})
const img = wrapper.find('img')
expect(img.classes()).toContain('max-w-full')
expect(img.classes()).toContain('object-contain')
})
it('applies correct styling for webp images', () => {
const wrapper = mountThumbnail({
src: '/test-video.webp',
isVideo: true
})
const img = wrapper.find('img')
expect(img.classes()).toContain('object-cover')
})
it('image is not draggable', () => {
const wrapper = mountThumbnail()
const img = wrapper.find('img')
expect(img.attributes('draggable')).toBe('false')
})
it('applies transition classes', () => {
const wrapper = mountThumbnail()
const img = wrapper.find('img')
expect(img.classes()).toContain('transform-gpu')
expect(img.classes()).toContain('transition-transform')
expect(img.classes()).toContain('duration-300')
expect(img.classes()).toContain('ease-out')
})
it('passes correct props to BaseThumbnail', () => {
const wrapper = mountThumbnail({
hoverZoom: 20,
isHovered: true
})
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.props('hoverZoom')).toBe(20)
expect(baseThumbnail.props('isHovered')).toBe(true)
})
})

View File

@@ -1,11 +1,16 @@
<template>
<BaseThumbnail :hover-zoom="hoverZoom" :is-hovered="isHovered">
<div class="overflow-hidden">
<div class="overflow-hidden w-full h-full flex items-center justify-center">
<img
:src="src"
:alt="alt"
draggable="false"
class="w-64 h-64 object-cover transform-gpu transition-transform duration-300 ease-out"
:class="[
'transform-gpu transition-transform duration-300 ease-out',
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
]"
:style="
isHovered ? { transform: `scale(${1 + hoverZoom / 100})` } : undefined
"
@@ -17,10 +22,13 @@
<script setup lang="ts">
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
defineProps<{
const { src, isVideo } = defineProps<{
src: string
alt: string
hoverZoom: number
isHovered?: boolean
isVideo?: boolean
}>()
const isVideoType = isVideo ?? (src?.toLowerCase().endsWith('.webp') || false)
</script>

View File

@@ -0,0 +1,82 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
vi.mock('@/components/templates/thumbnails/BaseThumbnail.vue', () => ({
default: {
name: 'BaseThumbnail',
template: '<div class="base-thumbnail"><slot /></div>',
props: ['isHovered']
}
}))
describe('HoverDissolveThumbnail', () => {
const mountThumbnail = (props = {}) => {
return mount(HoverDissolveThumbnail, {
props: {
baseImageSrc: '/base-image.jpg',
overlayImageSrc: '/overlay-image.jpg',
alt: 'Dissolve Image',
isHovered: false,
isVideo: false,
...props
}
})
}
it('renders both base and overlay images', () => {
const wrapper = mountThumbnail()
const images = wrapper.findAll('img')
expect(images.length).toBe(2)
expect(images[0].attributes('src')).toBe('/base-image.jpg')
expect(images[1].attributes('src')).toBe('/overlay-image.jpg')
})
it('applies correct alt text to both images', () => {
const wrapper = mountThumbnail({ alt: 'Custom Alt Text' })
const images = wrapper.findAll('img')
expect(images[0].attributes('alt')).toBe('Custom Alt Text')
expect(images[1].attributes('alt')).toBe('Custom Alt Text')
})
it('makes overlay image visible when hovered', () => {
const wrapper = mountThumbnail({ isHovered: true })
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('opacity-100')
expect(overlayImage.classes()).not.toContain('opacity-0')
})
it('makes overlay image hidden when not hovered', () => {
const wrapper = mountThumbnail({ isHovered: false })
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('opacity-0')
expect(overlayImage.classes()).not.toContain('opacity-100')
})
it('passes isHovered prop to BaseThumbnail', () => {
const wrapper = mountThumbnail({ isHovered: true })
const baseThumbnail = wrapper.findComponent({ name: 'BaseThumbnail' })
expect(baseThumbnail.props('isHovered')).toBe(true)
})
it('applies transition classes to overlay image', () => {
const wrapper = mountThumbnail()
const overlayImage = wrapper.findAll('img')[1]
expect(overlayImage.classes()).toContain('transition-opacity')
expect(overlayImage.classes()).toContain('duration-300')
})
it('applies correct positioning to both images', () => {
const wrapper = mountThumbnail()
const images = wrapper.findAll('img')
// Check base image
expect(images[0].classes()).toContain('absolute')
expect(images[0].classes()).toContain('inset-0')
// Check overlay image
expect(images[1].classes()).toContain('absolute')
expect(images[1].classes()).toContain('inset-0')
})
})

View File

@@ -5,14 +5,24 @@
:src="baseImageSrc"
:alt="alt"
draggable="false"
class="absolute inset-0 w-64 h-64 object-cover"
class="absolute inset-0"
:class="
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain'
"
/>
<img
:src="overlayImageSrc"
:alt="alt"
draggable="false"
class="absolute inset-0 w-64 h-64 object-cover transition-opacity duration-300"
:class="{ 'opacity-100': isHovered, 'opacity-0': !isHovered }"
class="absolute inset-0 transition-opacity duration-300"
:class="[
isVideoType
? 'w-full h-full object-cover'
: 'max-w-full max-h-64 object-contain',
{ 'opacity-100': isHovered, 'opacity-0': !isHovered }
]"
/>
</div>
</BaseThumbnail>
@@ -21,10 +31,17 @@
<script setup lang="ts">
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
defineProps<{
const { baseImageSrc, overlayImageSrc, isVideo } = defineProps<{
baseImageSrc: string
overlayImageSrc: string
alt: string
isHovered: boolean
isVideo?: boolean
}>()
const isVideoType =
isVideo ||
baseImageSrc?.toLowerCase().endsWith('.webp') ||
overlayImageSrc?.toLowerCase().endsWith('.webp') ||
false
</script>

View File

@@ -0,0 +1,122 @@
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import CurrentUserButton from './CurrentUserButton.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock the useCurrentUser composable
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
isLoggedIn: true,
userPhotoUrl: 'https://example.com/avatar.jpg',
userDisplayName: 'Test User',
userEmail: 'test@example.com'
}))
}))
// Mock the UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
render() {
return h('div', 'Avatar')
}
}
}))
// Mock the CurrentUserPopover component
vi.mock('./CurrentUserPopover.vue', () => ({
default: {
name: 'CurrentUserPopoverMock',
render() {
return h('div', 'Popover Content')
},
emits: ['close']
}
}))
describe('CurrentUserButton', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mountComponent = (): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(CurrentUserButton, {
global: {
plugins: [i18n],
stubs: {
// Use shallow mount for popover to make testing easier
Popover: {
template: '<div><slot></slot></div>',
methods: {
toggle: vi.fn(),
hide: vi.fn()
}
},
Button: true
}
}
})
}
it('renders correctly when user is logged in', () => {
const wrapper = mountComponent()
expect(wrapper.findComponent(Button).exists()).toBe(true)
})
it('toggles popover on button click', async () => {
const wrapper = mountComponent()
const popoverToggleSpy = vi.fn()
// Override the ref with a mock implementation
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.popover = { toggle: popoverToggleSpy }
await wrapper.findComponent(Button).trigger('click')
expect(popoverToggleSpy).toHaveBeenCalled()
})
it('hides popover when closePopover is called', async () => {
const wrapper = mountComponent()
// Replace the popover.hide method with a spy
const popoverHideSpy = vi.fn()
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.popover = { hide: popoverHideSpy }
// Directly call the closePopover method through the component instance
// @ts-expect-error - accessing internal Vue component vm
wrapper.vm.closePopover()
// Verify that popover.hide was called
expect(popoverHideSpy).toHaveBeenCalled()
})
})

View File

@@ -19,7 +19,7 @@
</Button>
<Popover ref="popover" :show-arrow="false">
<CurrentUserPopover />
<CurrentUserPopover @close="closePopover" />
</Popover>
</div>
</template>
@@ -40,4 +40,8 @@ const popover = ref<InstanceType<typeof Popover> | null>(null)
const photoURL = computed<string | undefined>(
() => userPhotoUrl.value ?? undefined
)
const closePopover = () => {
popover.value?.hide()
}
</script>

View File

@@ -0,0 +1,173 @@
import { VueWrapper, mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'
import { h } from 'vue'
import { createI18n } from 'vue-i18n'
import enMessages from '@/locales/en/main.json'
import CurrentUserPopover from './CurrentUserPopover.vue'
// Mock all firebase modules
vi.mock('firebase/app', () => ({
initializeApp: vi.fn(),
getApp: vi.fn()
}))
vi.mock('firebase/auth', () => ({
getAuth: vi.fn(),
setPersistence: vi.fn(),
browserLocalPersistence: {},
onAuthStateChanged: vi.fn(),
signInWithEmailAndPassword: vi.fn(),
signOut: vi.fn()
}))
// Mock pinia
vi.mock('pinia')
// Mock showSettingsDialog and showTopUpCreditsDialog
const mockShowSettingsDialog = vi.fn()
const mockShowTopUpCreditsDialog = vi.fn()
// Mock window.open
const originalWindowOpen = window.open
beforeEach(() => {
window.open = vi.fn()
})
afterAll(() => {
window.open = originalWindowOpen
})
// Mock the useCurrentUser composable
vi.mock('@/composables/auth/useCurrentUser', () => ({
useCurrentUser: vi.fn(() => ({
userPhotoUrl: 'https://example.com/avatar.jpg',
userDisplayName: 'Test User',
userEmail: 'test@example.com'
}))
}))
// Mock the useFirebaseAuthActions composable
vi.mock('@/composables/auth/useFirebaseAuthActions', () => ({
useFirebaseAuthActions: vi.fn(() => ({
fetchBalance: vi.fn().mockResolvedValue(undefined)
}))
}))
// Mock the dialog service
vi.mock('@/services/dialogService', () => ({
useDialogService: vi.fn(() => ({
showSettingsDialog: mockShowSettingsDialog,
showTopUpCreditsDialog: mockShowTopUpCreditsDialog
}))
}))
// Mock UserAvatar component
vi.mock('@/components/common/UserAvatar.vue', () => ({
default: {
name: 'UserAvatarMock',
render() {
return h('div', 'Avatar')
}
}
}))
// Mock UserCredit component
vi.mock('@/components/common/UserCredit.vue', () => ({
default: {
name: 'UserCreditMock',
render() {
return h('div', 'Credit: 100')
}
}
}))
describe('CurrentUserPopover', () => {
beforeEach(() => {
vi.clearAllMocks()
})
const mountComponent = (): VueWrapper => {
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: { en: enMessages }
})
return mount(CurrentUserPopover, {
global: {
plugins: [i18n],
stubs: {
Divider: true,
Button: true
}
}
})
}
it('renders user information correctly', () => {
const wrapper = mountComponent()
expect(wrapper.text()).toContain('Test User')
expect(wrapper.text()).toContain('test@example.com')
})
it('opens user settings and emits close event when settings button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the settings button (first one)
const buttons = wrapper.findAllComponents(Button)
const settingsButton = buttons[0]
// Click the settings button
await settingsButton.trigger('click')
// Verify showSettingsDialog was called with 'user'
expect(mockShowSettingsDialog).toHaveBeenCalledWith('user')
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens API pricing docs and emits close event when API pricing button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the API pricing button (second one)
const buttons = wrapper.findAllComponents(Button)
const apiPricingButton = buttons[1]
// Click the API pricing button
await apiPricingButton.trigger('click')
// Verify window.open was called with the correct URL
expect(window.open).toHaveBeenCalledWith(
'https://docs.comfy.org/tutorials/api-nodes/pricing',
'_blank'
)
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
it('opens top-up dialog and emits close event when top-up button is clicked', async () => {
const wrapper = mountComponent()
// Find all buttons and get the top-up button (last one)
const buttons = wrapper.findAllComponents(Button)
const topUpButton = buttons[buttons.length - 1]
// Click the top-up button
await topUpButton.trigger('click')
// Verify showTopUpCreditsDialog was called
expect(mockShowTopUpCreditsDialog).toHaveBeenCalled()
// Verify close event was emitted
expect(wrapper.emitted('close')).toBeTruthy()
expect(wrapper.emitted('close')!.length).toBe(1)
})
})

View File

@@ -72,20 +72,27 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
import { useDialogService } from '@/services/dialogService'
const emit = defineEmits<{
close: []
}>()
const { userDisplayName, userEmail, userPhotoUrl } = useCurrentUser()
const authActions = useFirebaseAuthActions()
const dialogService = useDialogService()
const handleOpenUserSettings = () => {
dialogService.showSettingsDialog('user')
emit('close')
}
const handleTopUp = () => {
dialogService.showTopUpCreditsDialog()
emit('close')
}
const handleOpenApiPricing = () => {
window.open('https://docs.comfy.org/tutorials/api-nodes/pricing', '_blank')
emit('close')
}
onMounted(() => {

310
src/composables/README.md Normal file
View File

@@ -0,0 +1,310 @@
# Composables
This directory contains Vue composables for the ComfyUI frontend application. Composables are reusable pieces of logic that encapsulate stateful functionality and can be shared across components.
## Table of Contents
- [Overview](#overview)
- [Composable Architecture](#composable-architecture)
- [Composable Categories](#composable-categories)
- [Usage Guidelines](#usage-guidelines)
- [VueUse Library](#vueuse-library)
- [Development Guidelines](#development-guidelines)
- [Common Patterns](#common-patterns)
## Overview
Vue composables are a core part of Vue 3's Composition API and provide a way to extract and reuse stateful logic between multiple components. In ComfyUI, composables are used to encapsulate behaviors like:
- State management
- DOM interactions
- Feature-specific functionality
- UI behaviors
- Data fetching
Composables enable a more modular and functional approach to building components, allowing for better code reuse and separation of concerns. They help keep your component code cleaner by extracting complex logic into separate, reusable functions.
As described in the [Vue.js documentation](https://vuejs.org/guide/reusability/composables.html), composables are:
> Functions that leverage Vue's Composition API to encapsulate and reuse stateful logic.
## Composable Architecture
The composable architecture in ComfyUI follows these principles:
1. **Single Responsibility**: Each composable should focus on a specific concern
2. **Composition**: Composables can use other composables
3. **Reactivity**: Composables leverage Vue's reactivity system
4. **Reusability**: Composables are designed to be used across multiple components
The following diagram shows how composables fit into the application architecture:
```
┌─────────────────────────────────────────────────────────┐
│ Vue Components │
│ │
│ ┌─────────────┐ ┌─────────────┐ │
│ │ Component A │ │ Component B │ │
│ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │
└────────────┼───────────────────┼────────────────────────┘
│ │
▼ ▼
┌────────────┴───────────────────┴────────────────────────┐
│ Composables │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ useFeatureA │ │ useFeatureB │ │ useFeatureC │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
└─────────┼────────────────┼────────────────┼─────────────┘
│ │ │
▼ ▼ ▼
┌─────────┴────────────────┴────────────────┴─────────────┐
│ Services & Stores │
└─────────────────────────────────────────────────────────┘
```
## Composable Categories
ComfyUI's composables are organized into several categories:
### Auth
Composables for authentication and user management:
- `useCurrentUser` - Provides access to the current user information
- `useFirebaseAuthActions` - Handles Firebase authentication operations
### Element
Composables for DOM and element interactions:
- `useAbsolutePosition` - Handles element positioning
- `useDomClipping` - Manages clipping of DOM elements
- `useResponsiveCollapse` - Manages responsive collapsing of elements
### Node
Composables for node-specific functionality:
- `useNodeBadge` - Handles node badge display and interaction
- `useNodeImage` - Manages node image preview
- `useNodeDragAndDrop` - Handles drag and drop for nodes
- `useNodeChatHistory` - Manages chat history for nodes
### Settings
Composables for settings management:
- `useSettingSearch` - Provides search functionality for settings
- `useSettingUI` - Manages settings UI interactions
### Sidebar
Composables for sidebar functionality:
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
- `useQueueSidebarTab` - Manages the queue sidebar tab
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
### Widgets
Composables for widget functionality:
- `useBooleanWidget` - Manages boolean widget interactions
- `useComboWidget` - Manages combo box widget interactions
- `useFloatWidget` - Manages float input widget interactions
- `useImagePreviewWidget` - Manages image preview widget
## Usage Guidelines
When using composables in components, follow these guidelines:
1. **Import and call** composables at the top level of the `setup` function
2. **Destructure returned values** to use in your component
3. **Respect reactivity** by not destructuring reactive objects
4. **Handle cleanup** by using `onUnmounted` when necessary
5. **Use VueUse** for common functionality instead of writing from scratch
Example usage:
```vue
<template>
<div
:class="{ 'dragging': isDragging }"
@mousedown="startDrag"
@mouseup="endDrag"
>
<img v-if="imageUrl" :src="imageUrl" alt="Node preview" />
</div>
</template>
<script setup lang="ts">
import { useNodeDragAndDrop } from '@/composables/node/useNodeDragAndDrop';
import { useNodeImage } from '@/composables/node/useNodeImage';
// Use composables at the top level
const { isDragging, startDrag, endDrag } = useNodeDragAndDrop();
const { imageUrl, loadImage } = useNodeImage();
// Use returned values in your component
</script>
```
## VueUse Library
ComfyUI leverages the [VueUse](https://vueuse.org/) library, which provides a collection of essential Vue Composition API utilities. Instead of implementing common functionality from scratch, prefer using VueUse composables for:
- DOM event handling (`useEventListener`, `useMouseInElement`)
- Element measurements (`useElementBounding`, `useElementSize`)
- Asynchronous operations (`useAsyncState`, `useFetch`)
- Animation and timing (`useTransition`, `useTimeout`, `useInterval`)
- Browser APIs (`useLocalStorage`, `useClipboard`)
- Sensors (`useDeviceMotion`, `useDeviceOrientation`)
- State management (`createGlobalState`, `useStorage`)
- ...and [more](https://vueuse.org/functions.html)
Examples:
```js
// Instead of manually adding/removing event listeners
import { useEventListener } from '@vueuse/core'
useEventListener(window, 'resize', handleResize)
// Instead of manually tracking element measurements
import { useElementBounding } from '@vueuse/core'
const { width, height, top, left } = useElementBounding(elementRef)
// Instead of manual async state management
import { useAsyncState } from '@vueuse/core'
const { state, isReady, isLoading } = useAsyncState(
fetch('https://api.example.com/data').then(r => r.json()),
{ data: [] }
)
```
For a complete list of available functions, see the [VueUse documentation](https://vueuse.org/functions.html).
## Development Guidelines
When creating or modifying composables, follow these best practices:
1. **Name with `use` prefix**: All composables should start with "use"
2. **Return an object**: Composables should return an object with named properties/methods
3. **Handle cleanup**: Use `onUnmounted` to clean up resources
4. **Document parameters and return values**: Add JSDoc comments
5. **Test composables**: Write unit tests for composable functionality
6. **Use VueUse**: Leverage VueUse composables instead of reimplementing common functionality
7. **Implement proper cleanup**: Cancel debounced functions, pending requests, and clear maps
8. **Use watchDebounced/watchThrottled**: For performance-sensitive reactive operations
### Composable Template
Here's a template for creating a new composable:
```typescript
import { ref, computed, onMounted, onUnmounted } from 'vue';
/**
* Composable for [functionality description]
* @param options Configuration options
* @returns Object containing state and methods
*/
export function useExample(options = {}) {
// State
const state = ref({
// Initial state
});
// Computed values
const derivedValue = computed(() => {
// Compute from state
return state.value.someProperty;
});
// Methods
function doSomething() {
// Implementation
}
// Lifecycle hooks
onMounted(() => {
// Setup
});
onUnmounted(() => {
// Cleanup
});
// Return exposed state and methods
return {
state,
derivedValue,
doSomething
};
}
```
## Common Patterns
Composables in ComfyUI frequently use these patterns:
### State Management
```typescript
export function useState() {
const count = ref(0);
function increment() {
count.value++;
}
return {
count,
increment
};
}
```
### Event Handling with VueUse
```typescript
import { useEventListener } from '@vueuse/core';
export function useKeyPress(key) {
const isPressed = ref(false);
useEventListener('keydown', (e) => {
if (e.key === key) {
isPressed.value = true;
}
});
useEventListener('keyup', (e) => {
if (e.key === key) {
isPressed.value = false;
}
});
return { isPressed };
}
```
### Fetch & Load with VueUse
```typescript
import { useAsyncState } from '@vueuse/core';
export function useFetchData(url) {
const { state: data, isLoading, error, execute: refresh } = useAsyncState(
async () => {
const response = await fetch(url);
if (!response.ok) throw new Error('Failed to fetch data');
return response.json();
},
null,
{ immediate: true }
);
return { data, isLoading, error, refresh };
}
```
For more information on Vue composables, refer to the [Vue.js Composition API documentation](https://vuejs.org/guide/reusability/composables.html) and the [VueUse documentation](https://vueuse.org/).

View File

@@ -6,12 +6,14 @@ import {
import _ from 'lodash'
import { computed, onMounted, watch } from 'vue'
import { useNodePricing } from '@/composables/node/useNodePricing'
import { app } from '@/scripts/app'
import { useExtensionStore } from '@/stores/extensionStore'
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
import { useSettingStore } from '@/stores/settingStore'
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
import { NodeBadgeMode } from '@/types/nodeSource'
import { adjustColor } from '@/utils/colorUtil'
/**
* Add LGraphBadge to LGraphNode based on settings.
@@ -41,9 +43,21 @@ export const useNodeBadge = () => {
) as NodeBadgeMode
)
watch([nodeSourceBadgeMode, nodeIdBadgeMode, nodeLifeCycleBadgeMode], () => {
app.graph?.setDirtyCanvas(true, true)
})
const showApiPricingBadge = computed(() =>
settingStore.get('Comfy.NodeBadge.ShowApiPricing')
)
watch(
[
nodeSourceBadgeMode,
nodeIdBadgeMode,
nodeLifeCycleBadgeMode,
showApiPricingBadge
],
() => {
app.graph?.setDirtyCanvas(true, true)
}
)
const nodeDefStore = useNodeDefStore()
function badgeTextVisible(
@@ -57,6 +71,8 @@ export const useNodeBadge = () => {
}
onMounted(() => {
const nodePricing = useNodePricing()
extensionStore.registerExtension({
name: 'Comfy.NodeBadge',
nodeCreated(node: LGraphNode) {
@@ -94,23 +110,32 @@ export const useNodeBadge = () => {
node.badges.push(() => badge.value)
if (node.constructor.nodeData?.api_node) {
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
const isLightTheme =
colorPaletteStore.completedActivePalette.light_theme
return new LGraphBadge({
text: '',
text: price,
iconOptions: {
unicode: '\ue96b',
fontFamily: 'PrimeIcons',
color: '#FABC25',
bgColor: '#353535',
color: isLightTheme
? adjustColor('#FABC25', { lightness: 0.5 })
: '#FABC25',
bgColor: isLightTheme
? adjustColor('#654020', { lightness: 0.5 })
: '#654020',
fontSize: 8
},
fgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_FG_COLOR,
bgColor:
colorPaletteStore.completedActivePalette.colors.litegraph_base
.BADGE_BG_COLOR
bgColor: isLightTheme
? adjustColor('#8D6932', { lightness: 0.5 })
: '#8D6932'
})
})

View File

@@ -16,9 +16,6 @@ export function useNodeChatHistory(
) {
const chatHistoryWidget = useChatHistoryWidget(options)
const findChatHistoryWidget = (node: LGraphNode) =>
node.widgets?.find((w) => w.name === CHAT_HISTORY_WIDGET_NAME)
const addChatHistoryWidget = (node: LGraphNode) =>
chatHistoryWidget(node, {
name: CHAT_HISTORY_WIDGET_NAME,
@@ -30,9 +27,11 @@ export function useNodeChatHistory(
* @param node The graph node to show the chat history for
*/
function showChatHistory(node: LGraphNode) {
if (!findChatHistoryWidget(node)) {
addChatHistoryWidget(node)
}
// First remove any existing widget
removeChatHistory(node)
// Then add the widget with new history
addChatHistoryWidget(node)
node.setDirtyCanvas?.(true)
}

View File

@@ -0,0 +1,426 @@
import type { LGraphNode } from '@comfyorg/litegraph'
import { ApiNodeCostRecord } from '@/types/apiNodeTypes'
const apiNodeCosts: ApiNodeCostRecord = {
FluxProCannyNode: {
vendor: 'BFL',
nodeName: 'Flux 1: Canny Control Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
displayPrice: '$0.05/Run'
},
FluxProDepthNode: {
vendor: 'BFL',
nodeName: 'Flux 1: Depth Control Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
displayPrice: '$0.05/Run'
},
FluxProExpandNode: {
vendor: 'BFL',
nodeName: 'Flux 1: Expand Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
rateDocumentationUrl: 'https://docs.bfl.ml/pricing/',
displayPrice: '$0.05/Run'
},
FluxProFillNode: {
vendor: 'BFL',
nodeName: 'Flux 1: Fill Image',
pricingParams: '-',
pricePerRunRange: '$0.05',
displayPrice: '$0.05/Run'
},
FluxProUltraImageNode: {
vendor: 'BFL',
nodeName: 'Flux 1.1: [pro] Ultra Image',
pricingParams: '-',
pricePerRunRange: '$0.06',
displayPrice: '$0.06/Run'
},
IdeogramV1: {
vendor: 'Ideogram',
nodeName: 'Ideogram V1',
pricingParams: '-',
pricePerRunRange: '$0.06',
rateDocumentationUrl: 'https://about.ideogram.ai/api-pricing',
displayPrice: '$0.06/Run'
},
IdeogramV2: {
vendor: 'Ideogram',
nodeName: 'Ideogram V2',
pricingParams: '-',
pricePerRunRange: '$0.08',
displayPrice: '$0.08/Run'
},
IdeogramV3: {
vendor: 'Ideogram',
nodeName: 'Ideogram V3',
pricingParams: 'rendering_speed',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (low to medium)'
},
KlingCameraControlI2VNode: {
vendor: 'Kling',
nodeName: 'Kling Image to Video (Camera Control)',
pricingParams: '-',
pricePerRunRange: '$0.49',
displayPrice: '$0.49/Run'
},
KlingCameraControlT2VNode: {
vendor: 'Kling',
nodeName: 'Kling Text to Video (Camera Control)',
pricingParams: '-',
pricePerRunRange: '$0.14',
displayPrice: '$0.14/Run'
},
KlingDualCharacterVideoEffectNode: {
vendor: 'Kling',
nodeName: 'Kling Dual Character Video Effects',
pricingParams: 'Priced the same as t2v based on mode, model, and duration.',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium)'
},
KlingImage2VideoNode: {
vendor: 'Kling',
nodeName: 'Kling Image to Video',
pricingParams: 'Same as Text to Video',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium)'
},
KlingImageGenerationNode: {
vendor: 'Kling',
nodeName: 'Kling Image Generation',
pricingParams: 'modality | model',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (low)'
},
KlingLipSyncAudioToVideoNode: {
vendor: 'Kling',
nodeName: 'Kling Lip Sync Video with Audio',
pricingParams: 'duration of input video',
pricePerRunRange: '$0.07',
displayPrice: '$0.07/Run'
},
KlingLipSyncTextToVideoNode: {
vendor: 'Kling',
nodeName: 'Kling Lip Sync Video with Text',
pricingParams: 'duration of input video',
pricePerRunRange: '$0.07',
displayPrice: '$0.07/Run'
},
KlingSingleImageVideoEffectNode: {
vendor: 'Kling',
nodeName: 'Kling Video Effects',
pricingParams: 'effect_scene',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium)'
},
KlingStartEndFrameNode: {
vendor: 'Kling',
nodeName: 'Kling Start-End Frame to Video',
pricingParams: 'Same as text to video',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium)'
},
KlingTextToVideoNode: {
vendor: 'Kling',
nodeName: 'Kling Text to Video',
pricingParams: 'model | duration | mode',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium to high)'
},
KlingVideoExtendNode: {
vendor: 'Kling',
nodeName: 'Kling Video Extend',
pricingParams: '-',
pricePerRunRange: '$0.28',
displayPrice: '$0.28/Run'
},
KlingVirtualTryOnNode: {
vendor: 'Kling',
nodeName: 'Kling Virtual Try On',
pricingParams: '-',
pricePerRunRange: '$0.07',
displayPrice: '$0.07/Run'
},
LumaImageToVideoNode: {
vendor: 'Luma',
nodeName: 'Luma Image to Video',
pricingParams: 'Same as Text to Video',
pricePerRunRange: 'dynamic',
rateDocumentationUrl: 'https://lumalabs.ai/api/pricing',
displayPrice: 'Variable pricing (medium to high)'
},
LumaVideoNode: {
vendor: 'Luma',
nodeName: 'Luma Text to Video',
pricingParams: 'model | resolution | duration',
pricePerRunRange: 'dynamic',
rateDocumentationUrl: 'https://lumalabs.ai/api/pricing',
displayPrice: 'Variable pricing (medium to high)'
},
MinimaxImageToVideoNode: {
vendor: 'Minimax',
nodeName: 'MiniMax Image to Video',
pricingParams: '-',
pricePerRunRange: '$0.43',
rateDocumentationUrl: 'https://www.minimax.io/price',
displayPrice: '$0.43/Run'
},
MinimaxTextToVideoNode: {
vendor: 'Minimax',
nodeName: 'MiniMax Text to Video',
pricingParams: '-',
pricePerRunRange: '$0.43',
rateDocumentationUrl: 'https://www.minimax.io/price',
displayPrice: '$0.43/Run'
},
OpenAIDalle2: {
vendor: 'OpenAI',
nodeName: 'dall-e-2',
pricingParams: 'size',
pricePerRunRange: 'dynamic',
rateDocumentationUrl: 'https://platform.openai.com/docs/pricing',
displayPrice: 'Variable pricing (low)'
},
OpenAIDalle3: {
vendor: 'OpenAI',
nodeName: 'dall-e-3',
pricingParams: 'size | quality',
pricePerRunRange: 'dynamic',
rateDocumentationUrl: 'https://platform.openai.com/docs/pricing',
displayPrice: 'Variable pricing (medium)'
},
OpenAIGPTImage1: {
vendor: 'OpenAI',
nodeName: 'gpt-image-1',
pricingParams: 'quality',
pricePerRunRange: 'dynamic',
rateDocumentationUrl: 'https://platform.openai.com/docs/pricing',
displayPrice: 'Variable pricing (low to high)'
},
PikaImageToVideoNode2_2: {
vendor: 'Pika',
nodeName: 'Pika Image to Video',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium)'
},
PikaScenesV2_2: {
vendor: 'Pika',
nodeName: 'Pika Scenes (Video Image Composition)',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium)'
},
PikaStartEndFrameNode2_2: {
vendor: 'Pika',
nodeName: 'Pika Start and End Frame to Video',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium)'
},
PikaTextToVideoNode2_2: {
vendor: 'Pika',
nodeName: 'Pika Text to Video',
pricingParams: 'duration | resolution',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium)'
},
Pikadditions: {
vendor: 'Pika',
nodeName: 'Pikadditions (Video Object Insertion)',
pricingParams: '-',
pricePerRunRange: '$0.3',
displayPrice: '$0.3/Run'
},
Pikaffects: {
vendor: 'Pika',
nodeName: 'Pikaffects (Video Effects)',
pricingParams: '-',
pricePerRunRange: '$0.45',
displayPrice: '$0.45/Run'
},
Pikaswaps: {
vendor: 'Pika',
nodeName: 'Pika Swaps (Video Object Replacement)',
pricingParams: '-',
pricePerRunRange: '$0.3',
displayPrice: '$0.3/Run'
},
PixverseImageToVideoNode: {
vendor: 'Pixverse',
nodeName: 'PixVerse Image to Video',
pricingParams: 'same as text to video',
pricePerRunRange: '$0.9',
displayPrice: '$0.9/Run'
},
PixverseTextToVideoNode: {
vendor: 'Pixverse',
nodeName: 'PixVerse Text to Video',
pricingParams: 'duration | quality | motion_mode',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (medium to high)'
},
PixverseTransitionVideoNode: {
vendor: 'Pixverse',
nodeName: 'PixVerse Transition Video',
pricingParams: 'same as text to video',
pricePerRunRange: '$0.9',
displayPrice: '$0.9/Run'
},
RecraftCreativeUpscaleNode: {
vendor: 'Recraft',
nodeName: 'Recraft Creative Upscale Image',
pricingParams: '-',
pricePerRunRange: '$0.25',
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.25/Run'
},
RecraftCrispUpscaleNode: {
vendor: 'Recraft',
nodeName: 'Recraft Crisp Upscale Image',
pricingParams: '-',
pricePerRunRange: '$0.004',
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.004/Run'
},
RecraftImageInpaintingNode: {
vendor: 'Recraft',
nodeName: 'Recraft Image Inpainting',
pricingParams: 'n',
pricePerRunRange: '$$0.04 x n',
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04 x n/Run'
},
RecraftImageToImageNode: {
vendor: 'Recraft',
nodeName: 'Recraft Image to Image',
pricingParams: 'n',
pricePerRunRange: '$0.04 x n',
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04 x n/Run'
},
RecraftRemoveBackgroundNode: {
vendor: 'Recraft',
nodeName: 'Recraft Remove Background',
pricingParams: '-',
pricePerRunRange: '$0.01',
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.01/Run'
},
RecraftReplaceBackgroundNode: {
vendor: 'Recraft',
nodeName: 'Recraft Replace Background',
pricingParams: 'n',
pricePerRunRange: '$0.04',
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04/Run'
},
RecraftTextToImageNode: {
vendor: 'Recraft',
nodeName: 'Recraft Text to Image',
pricingParams: 'model | n',
pricePerRunRange: '$0.04 x n',
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.04 x n/Run'
},
RecraftTextToVectorNode: {
vendor: 'Recraft',
nodeName: 'Recraft Text to Vector',
pricingParams: 'model | n',
pricePerRunRange: '$0.08 x n',
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.08 x n/Run'
},
RecraftVectorizeImageNode: {
vendor: 'Recraft',
nodeName: 'Recraft Vectorize Image',
pricingParams: '-',
pricePerRunRange: '$0.01',
rateDocumentationUrl: 'https://www.recraft.ai/docs#pricing',
displayPrice: '$0.01/Run'
},
StabilityStableImageSD_3_5Node: {
vendor: 'Stability',
nodeName: 'Stability AI Stable Diffusion 3.5 Image',
pricingParams: 'model',
pricePerRunRange: 'dynamic',
displayPrice: 'Variable pricing (low)'
},
StabilityStableImageUltraNode: {
vendor: 'Stability',
nodeName: 'Stability AI Stable Image Ultra',
pricingParams: '-',
pricePerRunRange: '$0.08',
displayPrice: '$0.08/Run'
},
StabilityUpscaleConservativeNode: {
vendor: 'Stability',
nodeName: 'Stability AI Upscale Conservative',
pricingParams: '-',
pricePerRunRange: '$0.25',
displayPrice: '$0.25/Run'
},
StabilityUpscaleCreativeNode: {
vendor: 'Stability',
nodeName: 'Stability AI Upscale Creative',
pricingParams: '-',
pricePerRunRange: '$0.25',
displayPrice: '$0.25/Run'
},
StabilityUpscaleFastNode: {
vendor: 'Stability',
nodeName: 'Stability AI Upscale Fast',
pricingParams: '-',
pricePerRunRange: '$0.01',
displayPrice: '$0.01/Run'
},
VeoVideoGenerationNode: {
vendor: 'Veo',
nodeName: 'Google Veo2 Video Generation',
pricingParams: 'duration_seconds',
pricePerRunRange: 'dynamic',
rateDocumentationUrl:
'https://cloud.google.com/vertex-ai/generative-ai/pricing',
displayPrice: 'Variable pricing (high)'
},
LumaTextToImageNode: {
vendor: 'Luma',
nodeName: 'Luma Text to Image',
pricingParams: 'model | aspect_ratio',
pricePerRunRange: 'dynamic',
rateDocumentationUrl: 'https://lumalabs.ai/api/pricing',
displayPrice: 'Variable pricing (low to medium)'
},
LumaImageToImageNode: {
vendor: 'Luma',
nodeName: 'Luma Image to Image',
pricingParams: 'Same as Text to Image',
pricePerRunRange: 'dynamic',
rateDocumentationUrl: 'https://lumalabs.ai/api/pricing',
displayPrice: 'Variable pricing (low to medium)'
}
}
/**
* Composable to get node pricing information for API nodes
*/
export const useNodePricing = () => {
const getNodePrice = (nodeName: string): string =>
apiNodeCosts[nodeName]?.displayPrice || ''
/**
* Get the price display for a node
*/
const getNodeDisplayPrice = (node: LGraphNode): string => {
if (!node.constructor.nodeData?.api_node) return ''
return getNodePrice(node.constructor.nodeData.name)
}
return {
getNodeDisplayPrice
}
}

View File

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

View File

@@ -322,6 +322,14 @@ export const CORE_SETTINGS: SettingParams[] = [
options: [NodeBadgeMode.None, NodeBadgeMode.ShowAll],
defaultValue: NodeBadgeMode.ShowAll
},
{
id: 'Comfy.NodeBadge.ShowApiPricing',
category: ['Comfy', 'API Nodes'],
name: 'Show API node pricing badge',
type: 'boolean',
defaultValue: true,
versionAdded: '1.20.3'
},
{
id: 'Comfy.ConfirmClear',
category: ['Comfy', 'Workflow', 'ConfirmClear'],

View File

@@ -0,0 +1,72 @@
/**
* Supported workflow file formats organized by type category
*/
/**
* All supported image formats that can contain workflow data
*/
export const IMAGE_WORKFLOW_FORMATS = {
extensions: ['.png', '.webp', '.svg'],
mimeTypes: ['image/png', 'image/webp', 'image/svg+xml']
}
/**
* All supported audio formats that can contain workflow data
*/
export const AUDIO_WORKFLOW_FORMATS = {
extensions: ['.mp3', '.ogg', '.flac'],
mimeTypes: ['audio/mpeg', 'audio/ogg', 'audio/flac', 'audio/x-flac']
}
/**
* All supported video formats that can contain workflow data
*/
export const VIDEO_WORKFLOW_FORMATS = {
extensions: ['.mp4', '.mov', '.m4v', '.webm'],
mimeTypes: ['video/mp4', 'video/quicktime', 'video/x-m4v', 'video/webm']
}
/**
* All supported 3D model formats that can contain workflow data
*/
export const MODEL_WORKFLOW_FORMATS = {
extensions: ['.glb'],
mimeTypes: ['model/gltf-binary']
}
/**
* All supported data formats that directly contain workflow data
*/
export const DATA_WORKFLOW_FORMATS = {
extensions: ['.json', '.latent', '.safetensors'],
mimeTypes: ['application/json']
}
/**
* Combines all supported formats into a single object
*/
export const ALL_WORKFLOW_FORMATS = {
extensions: [
...IMAGE_WORKFLOW_FORMATS.extensions,
...AUDIO_WORKFLOW_FORMATS.extensions,
...VIDEO_WORKFLOW_FORMATS.extensions,
...MODEL_WORKFLOW_FORMATS.extensions,
...DATA_WORKFLOW_FORMATS.extensions
],
mimeTypes: [
...IMAGE_WORKFLOW_FORMATS.mimeTypes,
...AUDIO_WORKFLOW_FORMATS.mimeTypes,
...VIDEO_WORKFLOW_FORMATS.mimeTypes,
...MODEL_WORKFLOW_FORMATS.mimeTypes,
...DATA_WORKFLOW_FORMATS.mimeTypes
]
}
/**
* Generate a comma-separated accept string for file inputs
* Combines all extensions and mime types
*/
export const WORKFLOW_ACCEPT_STRING = [
...ALL_WORKFLOW_FORMATS.extensions,
...ALL_WORKFLOW_FORMATS.mimeTypes
].join(',')

View File

@@ -0,0 +1,139 @@
# Core Extensions
This directory contains the core extensions that provide essential functionality to the ComfyUI frontend.
## Table of Contents
- [Overview](#overview)
- [Extension Architecture](#extension-architecture)
- [Core Extensions](#core-extensions)
- [Extension Development](#extension-development)
- [Extension Hooks](#extension-hooks)
- [Further Reading](#further-reading)
## Overview
Extensions in ComfyUI are modular JavaScript modules that extend and enhance the functionality of the frontend. The extensions in this directory are considered "core" as they provide fundamental features that are built into ComfyUI by default.
## Extension Architecture
ComfyUI's extension system follows these key principles:
1. **Registration-based:** Extensions must register themselves with the application using `app.registerExtension()`
2. **Hook-driven:** Extensions interact with the system through predefined hooks
3. **Non-intrusive:** Extensions should avoid directly modifying core objects where possible
## Core Extensions List
The core extensions include:
| Extension | Description |
|-----------|-------------|
| clipspace.ts | Implements the Clipspace feature for temporary image storage |
| dynamicPrompts.ts | Provides dynamic prompt generation capabilities |
| groupNode.ts | Implements the group node functionality to organize workflows |
| load3d.ts | Supports 3D model loading and visualization |
| maskeditor.ts | Implements the mask editor for image masking operations |
| noteNode.ts | Adds note nodes for documentation within workflows |
| rerouteNode.ts | Implements reroute nodes for cleaner workflow connections |
| uploadImage.ts | Handles image upload functionality |
| webcamCapture.ts | Provides webcam capture capabilities |
| widgetInputs.ts | Implements various widget input types |
## Extension Development
When developing or modifying extensions, follow these best practices:
1. **Use provided hooks** rather than directly modifying core application objects
2. **Maintain compatibility** with other extensions
3. **Follow naming conventions** for both extension names and settings
4. **Properly document** extension hooks and functionality
5. **Test with other extensions** to ensure no conflicts
### Extension Registration
Extensions are registered using the `app.registerExtension()` method:
```javascript
app.registerExtension({
name: "MyExtension",
// Hook implementations
async init() {
// Implementation
},
async beforeRegisterNodeDef(nodeType, nodeData, app) {
// Implementation
}
// Other hooks as needed
});
```
## Extension Hooks
ComfyUI extensions can implement various hooks that are called at specific points in the application lifecycle:
### Hook Execution Sequence
#### Web Page Load
```
init
addCustomNodeDefs
getCustomWidgets
beforeRegisterNodeDef [repeated multiple times]
registerCustomNodes
beforeConfigureGraph
nodeCreated
loadedGraphNode
afterConfigureGraph
setup
```
#### Loading Workflow
```
beforeConfigureGraph
beforeRegisterNodeDef [zero, one, or multiple times]
nodeCreated [repeated multiple times]
loadedGraphNode [repeated multiple times]
afterConfigureGraph
```
#### Adding New Node
```
nodeCreated
```
### Key Hooks
| Hook | Description |
|------|-------------|
| `init` | Called after canvas creation but before nodes are added |
| `setup` | Called after the application is fully set up and running |
| `addCustomNodeDefs` | Called before nodes are registered with the graph |
| `getCustomWidgets` | Allows extensions to add custom widgets |
| `beforeRegisterNodeDef` | Allows extensions to modify nodes before registration |
| `registerCustomNodes` | Allows extensions to register additional nodes |
| `loadedGraphNode` | Called when a node is reloaded onto the graph |
| `nodeCreated` | Called after a node's constructor |
| `beforeConfigureGraph` | Called before a graph is configured |
| `afterConfigureGraph` | Called after a graph is configured |
| `getSelectionToolboxCommands` | Allows extensions to add commands to the selection toolbox |
For the complete list of available hooks and detailed descriptions, see the [ComfyExtension interface in comfy.ts](https://github.com/Comfy-Org/ComfyUI_frontend/blob/main/src/types/comfy.ts).
## Further Reading
For more detailed information about ComfyUI's extension system, refer to the official documentation:
- [JavaScript Extension Overview](https://docs.comfy.org/custom-nodes/js/javascript_overview)
- [JavaScript Hooks](https://docs.comfy.org/custom-nodes/js/javascript_hooks)
- [JavaScript Objects and Hijacking](https://docs.comfy.org/custom-nodes/js/javascript_objects_and_hijacking)
- [JavaScript Settings](https://docs.comfy.org/custom-nodes/js/javascript_settings)
- [JavaScript Examples](https://docs.comfy.org/custom-nodes/js/javascript_examples)
Also, check the main [README.md](https://github.com/Comfy-Org/ComfyUI_frontend#developer-apis) section on Developer APIs for the latest information on extension APIs and features.

View File

@@ -112,7 +112,7 @@ useExtensionService().registerExtension({
LOAD_3D(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.gltf,.glb,.obj,.mtl,.fbx,.stl'
fileInput.accept = '.gltf,.glb,.obj,.fbx,.stl'
fileInput.style.display = 'none'
fileInput.onchange = async () => {
@@ -195,9 +195,7 @@ useExtensionService().registerExtension({
await nextTick()
const load3d = useLoad3dService().getLoad3d(node)
if (load3d) {
useLoad3dService().waitForLoad3d(node, (load3d) => {
let cameraState = node.properties['Camera Info']
const config = new Load3DConfiguration(load3d)
@@ -256,7 +254,7 @@ useExtensionService().registerExtension({
return returnVal
}
}
}
})
}
})
@@ -268,7 +266,7 @@ useExtensionService().registerExtension({
LOAD_3D_ANIMATION(node) {
const fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.accept = '.fbx,glb,gltf'
fileInput.accept = '.gltf,.glb,.fbx'
fileInput.style.display = 'none'
fileInput.onchange = async () => {
if (fileInput.files?.length) {
@@ -346,67 +344,65 @@ useExtensionService().registerExtension({
await nextTick()
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
useLoad3dService().waitForLoad3d(node, (load3d) => {
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
let cameraState = node.properties['Camera Info']
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
const load3d = useLoad3dService().getLoad3d(node) as Load3dAnimation
if (modelWidget && width && height && sceneWidget && load3d) {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
config.configure('input', modelWidget, cameraState, width, height)
let cameraState = node.properties['Camera Info']
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = load3d.getCameraState()
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
const load3dAnimation = load3d as Load3dAnimation
load3dAnimation.toggleAnimation(false)
if (modelWidget && width && height && sceneWidget && load3d) {
const config = new Load3DConfiguration(load3d)
if (load3dAnimation.isRecording()) {
load3dAnimation.stopRecording()
}
config.configure('input', modelWidget, cameraState, width, height)
const {
scene: imageData,
mask: maskData,
normal: normalData
} = await load3dAnimation.captureScene(
width.value as number,
height.value as number
)
sceneWidget.serializeValue = async () => {
node.properties['Camera Info'] = load3d.getCameraState()
load3d.toggleAnimation(false)
if (load3d.isRecording()) {
load3d.stopRecording()
}
const {
scene: imageData,
mask: maskData,
normal: normalData
} = await load3d.captureScene(
width.value as number,
height.value as number
)
const [data, dataMask, dataNormal] = await Promise.all([
Load3dUtils.uploadTempImage(imageData, 'scene'),
Load3dUtils.uploadTempImage(maskData, 'scene_mask'),
Load3dUtils.uploadTempImage(normalData, 'scene_normal')
])
load3d.handleResize()
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info: node.properties['Camera Info'],
recording: ''
}
const recordingData = load3d.getRecordingData()
if (recordingData) {
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
const [data, dataMask, dataNormal] = await Promise.all([
Load3dUtils.uploadTempImage(imageData, 'scene'),
Load3dUtils.uploadTempImage(maskData, 'scene_mask'),
Load3dUtils.uploadTempImage(normalData, 'scene_normal')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
}
return returnVal
load3dAnimation.handleResize()
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info: node.properties['Camera Info'],
recording: ''
}
const recordingData = load3dAnimation.getRecordingData()
if (recordingData) {
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
}
return returnVal
}
}
}
})
}
})
@@ -456,31 +452,43 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
const load3d = useLoad3dService().getLoad3d(node)
let cameraState = message.result[1]
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (load3d && modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
const config = new Load3DConfiguration(load3d)
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
config.configure('output', modelWidget, cameraState)
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
config.configure('output', modelWidget, cameraState)
}
}
}
})
}
})
@@ -530,29 +538,42 @@ useExtensionService().registerExtension({
const onExecuted = node.onExecuted
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
const load3d = useLoad3dService().getLoad3d(node)
useLoad3dService().waitForLoad3d(node, (load3d) => {
const config = new Load3DConfiguration(load3d)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (load3d && modelWidget) {
modelWidget.value = filePath.replaceAll('\\', '/')
const config = new Load3DConfiguration(load3d)
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
config.configure('output', modelWidget, cameraState)
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const cameraState = node.properties['Camera Info']
config.configure('output', modelWidget, cameraState)
}
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
let filePath = message.result[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
let cameraState = message.result[1]
modelWidget.value = filePath.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
config.configure('output', modelWidget, cameraState)
}
}
}
})
}
})

View File

@@ -118,7 +118,11 @@ class Load3d {
options
)
this.loaderManager = new LoaderManager(this.modelManager, this.eventManager)
this.loaderManager = new LoaderManager(
this.modelManager,
this.eventManager,
options
)
this.recordingManager = new RecordingManager(
this.sceneManager.scene,
@@ -482,6 +486,14 @@ class Load3d {
}
public remove(): void {
this.renderer.forceContextLoss()
const canvas = this.renderer.domElement
const event = new Event('webglcontextlost', {
bubbles: true,
cancelable: true
})
canvas.dispatchEvent(event)
if (this.animationFrameId !== null) {
cancelAnimationFrame(this.animationFrameId)
}

View File

@@ -1,15 +1,16 @@
import * as THREE from 'three'
import { FBXLoader } from 'three/examples/jsm/loaders/FBXLoader'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader'
import { MTLLoader } from 'three/examples/jsm/loaders/MTLLoader'
import { OBJLoader } from 'three/examples/jsm/loaders/OBJLoader'
import { STLLoader } from 'three/examples/jsm/loaders/STLLoader'
import { OverrideMTLLoader } from '@/extensions/core/load3d/threejsOverride/OverrideMTLLoader'
import { t } from '@/i18n'
import { useToastStore } from '@/stores/toastStore'
import {
EventManagerInterface,
Load3DOptions,
LoaderManagerInterface,
ModelManagerInterface
} from './interfaces'
@@ -17,7 +18,7 @@ import {
export class LoaderManager implements LoaderManagerInterface {
gltfLoader: GLTFLoader
objLoader: OBJLoader
mtlLoader: MTLLoader
mtlLoader: OverrideMTLLoader
fbxLoader: FBXLoader
stlLoader: STLLoader
@@ -26,14 +27,21 @@ export class LoaderManager implements LoaderManagerInterface {
constructor(
modelManager: ModelManagerInterface,
eventManager: EventManagerInterface
eventManager: EventManagerInterface,
options: Load3DOptions
) {
let loadRootFolder = 'input'
if (options && options.inputSpec?.isPreview) {
loadRootFolder = 'output'
}
this.modelManager = modelManager
this.eventManager = eventManager
this.gltfLoader = new GLTFLoader()
this.objLoader = new OBJLoader()
this.mtlLoader = new MTLLoader()
this.mtlLoader = new OverrideMTLLoader(loadRootFolder)
this.fbxLoader = new FBXLoader()
this.stlLoader = new STLLoader()
}
@@ -122,7 +130,16 @@ export class LoaderManager implements LoaderManagerInterface {
case 'obj':
if (this.modelManager.materialMode === 'original') {
const mtlUrl = url.replace(/\.obj([^.]*$)/, '.mtl$1')
const mtlUrl = url.replace(/(filename=.*?)\.obj/, '$1.mtl')
const subfolderMatch = url.match(/[?&]subfolder=([^&]*)/)
const subfolder = subfolderMatch
? decodeURIComponent(subfolderMatch[1])
: '3d'
this.mtlLoader.setSubfolder(subfolder)
try {
const materials = await this.mtlLoader.loadAsync(mtlUrl)
materials.preload()

View File

@@ -62,6 +62,14 @@ export class PreviewManager implements PreviewManagerInterface {
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()
}

View File

@@ -0,0 +1,533 @@
import {
Color,
ColorManagement,
DefaultLoadingManager,
FileLoader,
FrontSide,
Loader,
LoaderUtils,
MeshPhongMaterial,
RepeatWrapping,
SRGBColorSpace,
TextureLoader,
Vector2
} from 'three'
/**
* A loader for the MTL format.
*
* The Material Template Library format (MTL) or .MTL File Format is a companion file format
* to OBJ that describes surface shading (material) properties of objects within one or more
* OBJ files.
*
* ```js
* const loader = new MTLLoader();
* const materials = await loader.loadAsync( 'models/obj/male02/male02.mtl' );
*
* const objLoader = new OBJLoader();
* objLoader.setMaterials( materials );
* ```
*
* @augments Loader
* @three_import import { MTLLoader } from 'three/addons/loaders/MTLLoader.js';
*/
class OverrideMTLLoader extends Loader {
constructor(loadRootFolder, manager) {
super(manager)
this.loadRootFolder = loadRootFolder
}
setSubfolder(subfolder) {
this.subfolder = subfolder
}
/**
* Starts loading from the given URL and passes the loaded MTL asset
* to the `onLoad()` callback.
*
* @param {string} url - The path/URL of the file to be loaded. This can also be a data URI.
* @param {function(MaterialCreator)} onLoad - Executed when the loading process has been finished.
* @param {onProgressCallback} onProgress - Executed while the loading is in progress.
* @param {onErrorCallback} onError - Executed when errors occur.
*/
load(url, onLoad, onProgress, onError) {
const scope = this
const path = this.path === '' ? LoaderUtils.extractUrlBase(url) : this.path
const loader = new FileLoader(this.manager)
loader.setPath(this.path)
loader.setRequestHeader(this.requestHeader)
loader.setWithCredentials(this.withCredentials)
loader.load(
url,
function (text) {
try {
onLoad(scope.parse(text, path))
} catch (e) {
if (onError) {
onError(e)
} else {
console.error(e)
}
scope.manager.itemError(url)
}
},
onProgress,
onError
)
}
/**
* Sets the material options.
*
* @param {MTLLoader~MaterialOptions} value - The material options.
* @return {MTLLoader} A reference to this loader.
*/
setMaterialOptions(value) {
this.materialOptions = value
return this
}
/**
* Parses the given MTL data and returns the resulting material creator.
*
* @param {string} text - The raw MTL data as a string.
* @param {string} path - The URL base path.
* @return {MaterialCreator} The material creator.
*/
parse(text, path) {
const lines = text.split('\n')
let info = {}
const delimiter_pattern = /\s+/
const materialsInfo = {}
for (let i = 0; i < lines.length; i++) {
let line = lines[i]
line = line.trim()
if (line.length === 0 || line.charAt(0) === '#') {
// Blank line or comment ignore
continue
}
const pos = line.indexOf(' ')
let key = pos >= 0 ? line.substring(0, pos) : line
key = key.toLowerCase()
let value = pos >= 0 ? line.substring(pos + 1) : ''
value = value.trim()
if (key === 'newmtl') {
// New material
info = { name: value }
materialsInfo[value] = info
} else {
if (key === 'ka' || key === 'kd' || key === 'ks' || key === 'ke') {
const ss = value.split(delimiter_pattern, 3)
info[key] = [parseFloat(ss[0]), parseFloat(ss[1]), parseFloat(ss[2])]
} else {
info[key] = value
}
}
}
const materialCreator = new OverrideMaterialCreator(
this.resourcePath || path,
this.materialOptions,
this.loadRootFolder,
this.subfolder
)
materialCreator.setCrossOrigin(this.crossOrigin)
materialCreator.setManager(this.manager)
materialCreator.setMaterials(materialsInfo)
return materialCreator
}
}
/**
* Material options of `MTLLoader`.
*
* @typedef {Object} MTLLoader~MaterialOptions
* @property {(FrontSide|BackSide|DoubleSide)} [side=FrontSide] - Which side to apply the material.
* @property {(RepeatWrapping|ClampToEdgeWrapping|MirroredRepeatWrapping)} [wrap=RepeatWrapping] - What type of wrapping to apply for textures.
* @property {boolean} [normalizeRGB=false] - Whether RGB colors should be normalized to `0-1` from `0-255`.
* @property {boolean} [ignoreZeroRGBs=false] - Ignore values of RGBs (Ka,Kd,Ks) that are all 0's.
*/
class OverrideMaterialCreator {
constructor(baseUrl = '', options = {}, loadRootFolder, subfolder) {
this.baseUrl = baseUrl
this.options = options
this.materialsInfo = {}
this.materials = {}
this.materialsArray = []
this.nameLookup = {}
this.loadRootFolder = loadRootFolder
this.subfolder = subfolder
this.crossOrigin = 'anonymous'
this.side = this.options.side !== undefined ? this.options.side : FrontSide
this.wrap =
this.options.wrap !== undefined ? this.options.wrap : RepeatWrapping
}
setCrossOrigin(value) {
this.crossOrigin = value
return this
}
setManager(value) {
this.manager = value
}
setMaterials(materialsInfo) {
this.materialsInfo = this.convert(materialsInfo)
this.materials = {}
this.materialsArray = []
this.nameLookup = {}
}
convert(materialsInfo) {
if (!this.options) return materialsInfo
const converted = {}
for (const mn in materialsInfo) {
// Convert materials info into normalized form based on options
const mat = materialsInfo[mn]
const covmat = {}
converted[mn] = covmat
for (const prop in mat) {
let save = true
let value = mat[prop]
const lprop = prop.toLowerCase()
switch (lprop) {
case 'kd':
case 'ka':
case 'ks':
// Diffuse color (color under white light) using RGB values
if (this.options && this.options.normalizeRGB) {
value = [value[0] / 255, value[1] / 255, value[2] / 255]
}
if (this.options && this.options.ignoreZeroRGBs) {
if (value[0] === 0 && value[1] === 0 && value[2] === 0) {
// ignore
save = false
}
}
break
default:
break
}
if (save) {
covmat[lprop] = value
}
}
}
return converted
}
preload() {
for (const mn in this.materialsInfo) {
this.create(mn)
}
}
getIndex(materialName) {
return this.nameLookup[materialName]
}
getAsArray() {
let index = 0
for (const mn in this.materialsInfo) {
this.materialsArray[index] = this.create(mn)
this.nameLookup[mn] = index
index++
}
return this.materialsArray
}
create(materialName) {
if (this.materials[materialName] === undefined) {
this.createMaterial_(materialName)
}
return this.materials[materialName]
}
createMaterial_(materialName) {
// Create material
const scope = this
const mat = this.materialsInfo[materialName]
const params = {
name: materialName,
side: this.side
}
/**
* Override for ComfyUI api url
*/
function resolveURL(baseUrl, url, loadRootFolder, subfolder) {
if (typeof url !== 'string' || url === '') return ''
if (baseUrl.endsWith('/')) {
baseUrl = baseUrl.slice(0, -1)
}
if (!baseUrl.endsWith('api')) {
baseUrl = '/api'
}
baseUrl =
baseUrl +
'/view?filename=' +
url +
'&type=' +
loadRootFolder +
'&subfolder=' +
subfolder
return baseUrl
}
function setMapForType(mapType, value) {
if (params[mapType]) return // Keep the first encountered texture
const texParams = scope.getTextureParams(value, params)
const map = scope.loadTexture(
resolveURL(
scope.baseUrl,
texParams.url,
scope.loadRootFolder,
scope.subfolder
)
)
map.repeat.copy(texParams.scale)
map.offset.copy(texParams.offset)
map.wrapS = scope.wrap
map.wrapT = scope.wrap
if (mapType === 'map' || mapType === 'emissiveMap') {
map.colorSpace = SRGBColorSpace
}
params[mapType] = map
}
for (const prop in mat) {
const value = mat[prop]
let n
if (value === '') continue
switch (prop.toLowerCase()) {
// Ns is material specular exponent
case 'kd':
// Diffuse color (color under white light) using RGB values
params.color = ColorManagement.toWorkingColorSpace(
new Color().fromArray(value),
SRGBColorSpace
)
break
case 'ks':
// Specular color (color when light is reflected from shiny surface) using RGB values
params.specular = ColorManagement.toWorkingColorSpace(
new Color().fromArray(value),
SRGBColorSpace
)
break
case 'ke':
// Emissive using RGB values
params.emissive = ColorManagement.toWorkingColorSpace(
new Color().fromArray(value),
SRGBColorSpace
)
break
case 'map_kd':
// Diffuse texture map
setMapForType('map', value)
break
case 'map_ks':
// Specular map
setMapForType('specularMap', value)
break
case 'map_ke':
// Emissive map
setMapForType('emissiveMap', value)
break
case 'norm':
setMapForType('normalMap', value)
break
case 'map_bump':
case 'bump':
// Bump texture map
setMapForType('bumpMap', value)
break
case 'disp':
// Displacement texture map
setMapForType('displacementMap', value)
break
case 'map_d':
// Alpha map
setMapForType('alphaMap', value)
params.transparent = true
break
case 'ns':
// The specular exponent (defines the focus of the specular highlight)
// A high exponent results in a tight, concentrated highlight. Ns values normally range from 0 to 1000.
params.shininess = parseFloat(value)
break
case 'd':
n = parseFloat(value)
if (n < 1) {
params.opacity = n
params.transparent = true
}
break
case 'tr':
n = parseFloat(value)
if (this.options && this.options.invertTrProperty) n = 1 - n
if (n > 0) {
params.opacity = 1 - n
params.transparent = true
}
break
default:
break
}
}
this.materials[materialName] = new MeshPhongMaterial(params)
return this.materials[materialName]
}
getTextureParams(value, matParams) {
const texParams = {
scale: new Vector2(1, 1),
offset: new Vector2(0, 0)
}
const items = value.split(/\s+/)
let pos
pos = items.indexOf('-bm')
if (pos >= 0) {
matParams.bumpScale = parseFloat(items[pos + 1])
items.splice(pos, 2)
}
pos = items.indexOf('-mm')
if (pos >= 0) {
matParams.displacementBias = parseFloat(items[pos + 1])
matParams.displacementScale = parseFloat(items[pos + 2])
items.splice(pos, 3)
}
pos = items.indexOf('-s')
if (pos >= 0) {
texParams.scale.set(
parseFloat(items[pos + 1]),
parseFloat(items[pos + 2])
)
items.splice(pos, 4) // we expect 3 parameters here!
}
pos = items.indexOf('-o')
if (pos >= 0) {
texParams.offset.set(
parseFloat(items[pos + 1]),
parseFloat(items[pos + 2])
)
items.splice(pos, 4) // we expect 3 parameters here!
}
texParams.url = items.join(' ').trim()
return texParams
}
loadTexture(url, mapping, onLoad, onProgress, onError) {
const manager =
this.manager !== undefined ? this.manager : DefaultLoadingManager
let loader = manager.getHandler(url)
if (loader === null) {
loader = new TextureLoader(manager)
}
if (loader.setCrossOrigin) loader.setCrossOrigin(this.crossOrigin)
const texture = loader.load(url, onLoad, onProgress, onError)
if (mapping !== undefined) texture.mapping = mapping
return texture
}
}
export { OverrideMTLLoader }

View File

@@ -4912,6 +4912,45 @@ class KeyboardManager {
}
}
// Function to open the mask editor
function openMaskEditor(): void {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
const dlg = MaskEditorDialog.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
} else {
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
}
}
// Check if the dialog is already opened
function isOpened(): boolean {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
return MaskEditorDialog.instance?.isOpened?.() ?? false
} else {
return (MaskEditorDialogOld.instance as any)?.isOpened?.() ?? false
}
}
// Ensure boolean return type for context predicate
const context_predicate = (): boolean => {
return !!(
ComfyApp.clipspace &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.imgs.length > 0
)
}
app.registerExtension({
name: 'Comfy.MaskEditor',
settings: [
@@ -4951,50 +4990,33 @@ app.registerExtension({
experimental: true
}
],
init(app) {
// Create function before assignment
function openMaskEditor(): void {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
const dlg = MaskEditorDialog.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
} else {
const dlg = MaskEditorDialogOld.getInstance() as any
if (dlg?.isOpened && !dlg.isOpened()) {
dlg.show()
}
commands: [
{
id: 'Comfy.MaskEditor.OpenMaskEditor',
icon: 'pi pi-pencil',
label: 'Open Mask Editor for Selected Node',
function: () => {
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes || Object.keys(selectedNodes).length !== 1) return
const selectedNode = selectedNodes[Object.keys(selectedNodes)[0]]
if (
!selectedNode.imgs?.length &&
selectedNode.previewMediaType !== 'image'
)
return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
openMaskEditor()
}
}
// Check if the dialog is already opened
function isOpened(): boolean {
const useNewEditor = app.extensionManager.setting.get(
'Comfy.MaskEditor.UseNewEditor'
)
if (useNewEditor) {
return MaskEditorDialog.instance?.isOpened?.() ?? false
} else {
return (MaskEditorDialogOld.instance as any)?.isOpened?.() ?? false
}
}
// Assign the created function
],
init() {
ComfyApp.open_maskeditor = openMaskEditor
ComfyApp.maskeditor_is_opended = isOpened
// Ensure boolean return type
const context_predicate = (): boolean => {
return !!(
ComfyApp.clipspace &&
ComfyApp.clipspace.imgs &&
ComfyApp.clipspace.imgs.length > 0
)
}
ClipspaceDialog.registerButton(
'MaskEditor',
context_predicate,

View File

@@ -140,6 +140,9 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Toggle Progress Dialog"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Open Mask Editor for Selected Node"
},
"Comfy_NewBlankWorkflow": {
"label": "New Blank Workflow"
},

View File

@@ -203,7 +203,9 @@
"validation": {
"maxLength": "Message too long",
"invalidEmail": "Please enter a valid email address",
"selectIssueType": "Please select an issue type"
"selectIssueType": "Please select an issue type",
"descriptionRequired": "Description is required",
"helpTypeRequired": "Help type is required"
}
},
"color": {
@@ -800,6 +802,7 @@
"Load Default Workflow": "Load Default Workflow",
"Custom Nodes Manager": "Custom Nodes Manager",
"Toggle Progress Dialog": "Toggle Progress Dialog",
"Open Mask Editor for Selected Node": "Open Mask Editor for Selected Node",
"New": "New",
"Clipspace": "Clipspace",
"Open": "Open",
@@ -883,7 +886,8 @@
"3D": "3D",
"Light": "Light",
"User": "User",
"Credits": "Credits"
"Credits": "Credits",
"API Nodes": "API Nodes"
},
"serverConfigItems": {
"listen": {
@@ -1227,7 +1231,8 @@
"stopRecording": "Stop Recording",
"exportRecording": "Export Recording",
"clearRecording": "Clear Recording",
"resizeNodeMatchOutput": "Resize Node to match output"
"resizeNodeMatchOutput": "Resize Node to match output",
"loadingBackgroundImage": "Loading Background Image"
},
"toastMessages": {
"nothingToQueue": "Nothing to queue",

View File

@@ -225,6 +225,9 @@
"Hide built-in": "Hide built-in"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "Show API node pricing badge"
},
"Comfy_NodeSearchBoxImpl": {
"name": "Node search box implementation",
"options": {

View File

@@ -140,6 +140,9 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Alternar diálogo de progreso del administrador"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Abrir editor de máscara para el nodo seleccionado"
},
"Comfy_NewBlankWorkflow": {
"label": "Nuevo flujo de trabajo en blanco"
},

View File

@@ -498,6 +498,8 @@
"submitErrorReport": "Enviar Reporte de Error (Opcional)",
"systemStats": "Estadísticas del Sistema",
"validation": {
"descriptionRequired": "Se requiere una descripción",
"helpTypeRequired": "Se requiere el tipo de ayuda",
"invalidEmail": "Por favor ingresa una dirección de correo electrónico válida",
"maxLength": "Mensaje demasiado largo",
"selectIssueType": "Por favor, seleccione un tipo de problema"
@@ -518,6 +520,7 @@
"fov": "FOV",
"light": "Luz",
"lightIntensity": "Intensidad de luz",
"loadingBackgroundImage": "Cargando imagen de fondo",
"loadingModel": "Cargando modelo 3D...",
"materialMode": "Modo de material",
"materialModes": {
@@ -707,6 +710,7 @@
"Open DevTools": "Abrir DevTools",
"Open Inputs Folder": "Abrir carpeta de entradas",
"Open Logs Folder": "Abrir carpeta de registros",
"Open Mask Editor for Selected Node": "Abrir el editor de mask para el nodo seleccionado",
"Open Models Folder": "Abrir carpeta de modelos",
"Open Outputs Folder": "Abrir carpeta de salidas",
"Open Sign In Dialog": "Abrir diálogo de inicio de sesión",
@@ -997,6 +1001,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "Nodos API",
"About": "Acerca de",
"Appearance": "Apariencia",
"BrushAdjustment": "Ajuste de Pincel",

View File

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

View File

@@ -193,6 +193,9 @@
"Show all": "Mostrar todo"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "Mostrar insignia de precios de nodo API"
},
"Comfy_NodeSearchBoxImpl": {
"name": "Implementación de la caja de búsqueda de nodos",
"options": {

View File

@@ -140,6 +140,9 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Basculer la boîte de dialogue de progression"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Ouvrir l'éditeur de masque pour le nœud sélectionné"
},
"Comfy_NewBlankWorkflow": {
"label": "Nouveau flux de travail vierge"
},

View File

@@ -498,6 +498,8 @@
"submitErrorReport": "Soumettre un rapport d'erreur (Facultatif)",
"systemStats": "Statistiques du système",
"validation": {
"descriptionRequired": "La description est requise",
"helpTypeRequired": "Le type d'aide est requis",
"invalidEmail": "Veuillez entrer une adresse e-mail valide",
"maxLength": "Message trop long",
"selectIssueType": "Veuillez sélectionner un type de problème"
@@ -518,6 +520,7 @@
"fov": "FOV",
"light": "Lumière",
"lightIntensity": "Intensité de la lumière",
"loadingBackgroundImage": "Chargement de limage darrière-plan",
"loadingModel": "Chargement du modèle 3D...",
"materialMode": "Mode Matériel",
"materialModes": {
@@ -707,6 +710,7 @@
"Open DevTools": "Ouvrir DevTools",
"Open Inputs Folder": "Ouvrir le dossier des entrées",
"Open Logs Folder": "Ouvrir le dossier des journaux",
"Open Mask Editor for Selected Node": "Ouvrir léditeur de mask pour le nœud sélectionné",
"Open Models Folder": "Ouvrir le dossier des modèles",
"Open Outputs Folder": "Ouvrir le dossier des sorties",
"Open Sign In Dialog": "Ouvrir la boîte de dialogue de connexion",
@@ -997,6 +1001,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "Nœuds API",
"About": "À Propos",
"Appearance": "Apparence",
"BrushAdjustment": "Ajustement de Brosse",

View File

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

View File

@@ -193,6 +193,9 @@
"Show all": "Afficher tout"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "Afficher linsigne de tarification API du nœud"
},
"Comfy_NodeSearchBoxImpl": {
"name": "Implémentation de la boîte de recherche de nœud",
"options": {

View File

@@ -140,6 +140,9 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "プログレスダイアログの切り替え"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "選択したノードのマスクエディタを開く"
},
"Comfy_NewBlankWorkflow": {
"label": "新しい空のワークフロー"
},

View File

@@ -498,6 +498,8 @@
"submitErrorReport": "エラーレポートを提出する(オプション)",
"systemStats": "システム統計",
"validation": {
"descriptionRequired": "説明は必須です",
"helpTypeRequired": "ヘルプの種類は必須です",
"invalidEmail": "有効なメールアドレスを入力してください",
"maxLength": "メッセージが長すぎます",
"selectIssueType": "問題の種類を選択してください"
@@ -518,6 +520,7 @@
"fov": "FOV",
"light": "ライト",
"lightIntensity": "光の強度",
"loadingBackgroundImage": "背景画像を読み込んでいます",
"loadingModel": "3Dモデルを読み込んでいます...",
"materialMode": "マテリアルモード",
"materialModes": {
@@ -707,6 +710,7 @@
"Open DevTools": "DevToolsを開く",
"Open Inputs Folder": "入力フォルダを開く",
"Open Logs Folder": "ログフォルダを開く",
"Open Mask Editor for Selected Node": "選択したノードのマスクエディタを開く",
"Open Models Folder": "モデルフォルダを開く",
"Open Outputs Folder": "出力フォルダを開く",
"Open Sign In Dialog": "サインインダイアログを開く",
@@ -997,6 +1001,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "APIード",
"About": "情報",
"Appearance": "外観",
"BrushAdjustment": "ブラシ調整",

View File

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

View File

@@ -193,6 +193,9 @@
"Show all": "すべて表示"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "APIードの料金バッジを表示"
},
"Comfy_NodeSearchBoxImpl": {
"name": "ノード検索ボックスの実装",
"options": {

View File

@@ -140,6 +140,9 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "진행 상황 대화 상자 전환"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "선택한 노드 마스크 편집기 열기"
},
"Comfy_NewBlankWorkflow": {
"label": "새로운 빈 워크플로"
},

View File

@@ -498,6 +498,8 @@
"submitErrorReport": "오류 보고서 제출 (선택 사항)",
"systemStats": "시스템 통계",
"validation": {
"descriptionRequired": "설명은 필수입니다",
"helpTypeRequired": "도움 유형은 필수입니다",
"invalidEmail": "유효한 이메일 주소를 입력해 주세요",
"maxLength": "메시지가 너무 깁니다",
"selectIssueType": "문제 유형을 선택해 주세요"
@@ -518,6 +520,7 @@
"fov": "FOV",
"light": "빛",
"lightIntensity": "조명 강도",
"loadingBackgroundImage": "배경 이미지 불러오는 중",
"loadingModel": "3D 모델 로딩 중...",
"materialMode": "재질 모드",
"materialModes": {
@@ -707,6 +710,7 @@
"Open DevTools": "개발자 도구 열기",
"Open Inputs Folder": "입력 폴더 열기",
"Open Logs Folder": "로그 폴더 열기",
"Open Mask Editor for Selected Node": "선택한 노드의 마스크 에디터 열기",
"Open Models Folder": "모델 폴더 열기",
"Open Outputs Folder": "출력 폴더 열기",
"Open Sign In Dialog": "로그인 대화 상자 열기",
@@ -997,6 +1001,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "API 노드",
"About": "정보",
"Appearance": "모양",
"BrushAdjustment": "브러시 조정",

View File

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

View File

@@ -193,6 +193,9 @@
"Show all": "모두 표시"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "API 노드 가격 배지 표시"
},
"Comfy_NodeSearchBoxImpl": {
"name": "노드 검색 상자 구현",
"options": {

View File

@@ -140,6 +140,9 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "Переключить диалоговое окно прогресса"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "Открыть редактор масок для выбранной ноды"
},
"Comfy_NewBlankWorkflow": {
"label": "Новый пустой рабочий процесс"
},

View File

@@ -498,6 +498,8 @@
"submitErrorReport": "Отправить отчёт об ошибке (необязательно)",
"systemStats": "Статистика системы",
"validation": {
"descriptionRequired": "Описание обязательно",
"helpTypeRequired": "Тип помощи обязателен",
"invalidEmail": "Пожалуйста, введите действительный адрес электронной почты",
"maxLength": "Сообщение слишком длинное",
"selectIssueType": "Пожалуйста, выберите тип проблемы"
@@ -518,6 +520,7 @@
"fov": "Угол обзора",
"light": "Свет",
"lightIntensity": "Интенсивность света",
"loadingBackgroundImage": "Загрузка фонового изображения",
"loadingModel": "Загрузка 3D модели...",
"materialMode": "Режим Материала",
"materialModes": {
@@ -707,6 +710,7 @@
"Open DevTools": "Открыть инструменты разработчика",
"Open Inputs Folder": "Открыть папку входных данных",
"Open Logs Folder": "Открыть папку журналов",
"Open Mask Editor for Selected Node": "Открыть редактор масок для выбранного узла",
"Open Models Folder": "Открыть папку моделей",
"Open Outputs Folder": "Открыть папку выходных данных",
"Open Sign In Dialog": "Открыть окно входа",
@@ -997,6 +1001,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "API-узлы",
"About": "О программе",
"Appearance": "Внешний вид",
"BrushAdjustment": "Настройка кисти",

View File

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

View File

@@ -193,6 +193,9 @@
"Show all": "Показать все"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "Показать значок стоимости узла API"
},
"Comfy_NodeSearchBoxImpl": {
"name": "Реализация поискового поля нод",
"options": {

View File

@@ -140,6 +140,9 @@
"Comfy_Manager_ToggleManagerProgressDialog": {
"label": "切换进度对话框"
},
"Comfy_MaskEditor_OpenMaskEditor": {
"label": "打开选中节点的遮罩编辑器"
},
"Comfy_NewBlankWorkflow": {
"label": "新建空白工作流"
},

View File

@@ -498,6 +498,8 @@
"submitErrorReport": "提交错误报告(可选)",
"systemStats": "系统状态",
"validation": {
"descriptionRequired": "描述为必填项",
"helpTypeRequired": "帮助类型为必选项",
"invalidEmail": "请输入有效的电子邮件地址",
"maxLength": "消息过长",
"selectIssueType": "请选择一个问题类型"
@@ -518,6 +520,7 @@
"fov": "视场",
"light": "灯光",
"lightIntensity": "光照强度",
"loadingBackgroundImage": "正在加载背景图像",
"loadingModel": "正在加载3D模型...",
"materialMode": "材质模式",
"materialModes": {
@@ -707,6 +710,7 @@
"Open DevTools": "打开开发者工具",
"Open Inputs Folder": "打开输入文件夹",
"Open Logs Folder": "打开日志文件夹",
"Open Mask Editor for Selected Node": "为选中节点打开 Mask 编辑器",
"Open Models Folder": "打开模型文件夹",
"Open Outputs Folder": "打开输出文件夹",
"Open Sign In Dialog": "打开登录对话框",
@@ -997,6 +1001,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "API 节点",
"About": "关于",
"Appearance": "外观",
"BrushAdjustment": "画笔调整",

View File

@@ -3417,20 +3417,26 @@
"name": "宽度"
}
},
"outputs": [
null,
null,
null,
{
"name": "法线"
"outputs": {
"0": {
"name": "image"
},
{
"name": "线稿"
"1": {
"name": "mask"
},
{
"name": "相机信息"
"2": {
"name": "mesh_path"
},
"3": {
"name": "normal"
},
"4": {
"name": "lineart"
},
"5": {
"name": "camera_info"
}
]
}
},
"Load3DAnimation": {
"display_name": "加载3D动画",
@@ -3452,17 +3458,23 @@
"name": "宽度"
}
},
"outputs": [
null,
null,
null,
{
"outputs": {
"0": {
"name": "图像"
},
"1": {
"name": "遮罩"
},
"2": {
"name": "mesh_path"
},
"3": {
"name": "法线"
},
{
"4": {
"name": "相机信息"
}
]
}
},
"LoadAudio": {
"display_name": "加载音频",

View File

@@ -193,6 +193,9 @@
"Show all": "显示全部"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "显示 API 节点定价徽章"
},
"Comfy_NodeSearchBoxImpl": {
"name": "节点搜索框",
"options": {

View File

@@ -421,6 +421,7 @@ const zSettings = z.object({
'Comfy.NodeBadge.NodeSourceBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.NodeIdBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.NodeLifeCycleBadgeMode': zNodeBadgeMode,
'Comfy.NodeBadge.ShowApiPricing': z.boolean(),
'Comfy.QueueButton.BatchCountLimit': z.number(),
'Comfy.Queue.MaxHistoryItems': z.number(),
'Comfy.Keybinding.UnsetBindings': z.array(zKeybinding),

View File

@@ -1,10 +1,16 @@
import { z } from 'zod'
import { t } from '@/i18n'
const checkboxField = z.boolean().optional()
export const issueReportSchema = z
.object({
contactInfo: z.string().email().max(320).optional().or(z.literal('')),
details: z.string().max(5_000).optional(),
details: z
.string()
.min(1, { message: t('validation.descriptionRequired') })
.max(5_000, { message: t('validation.maxLength', { length: 5_000 }) })
.optional(),
helpType: z.string().optional()
})
.catchall(checkboxField)
@@ -12,7 +18,11 @@ export const issueReportSchema = z
path: ['details', 'helpType']
})
.refine((data) => data.helpType !== undefined && data.helpType !== '', {
message: 'Help type is required',
message: t('issueReport.validation.helpTypeRequired'),
path: ['helpType']
})
.refine((data) => data.details !== undefined && data.details !== '', {
message: t('issueReport.validation.descriptionRequired'),
path: ['details']
})
export type IssueReportFormData = z.infer<typeof issueReportSchema>

View File

@@ -1074,11 +1074,11 @@ export class ComfyApp {
try {
// @ts-expect-error Discrepancies between zod and litegraph - in progress
this.graph.configure(graphData)
if (restore_view) {
if (
useSettingStore().get('Comfy.EnableWorkflowViewRestore') &&
graphData.extra?.ds
) {
if (
restore_view &&
useSettingStore().get('Comfy.EnableWorkflowViewRestore')
) {
if (graphData.extra?.ds) {
this.canvas.ds.offset = graphData.extra.ds.offset
this.canvas.ds.scale = graphData.extra.ds.scale
} else {

View File

@@ -1,3 +1,4 @@
import { WORKFLOW_ACCEPT_STRING } from '@/constants/supportedWorkflowFormats'
import { type StatusWsMessageStatus, TaskItem } from '@/schemas/apiSchema'
import { useDialogService } from '@/services/dialogService'
import { useLitegraphService } from '@/services/litegraphService'
@@ -386,7 +387,7 @@ export class ComfyUI {
const fileInput = $el('input', {
id: 'comfy-file-input',
type: 'file',
accept: '.json,image/png,.latent,.safetensors,image/webp,audio/flac',
accept: WORKFLOW_ACCEPT_STRING,
style: { display: 'none' },
parent: document.body,
onchange: async () => {

257
src/services/README.md Normal file
View File

@@ -0,0 +1,257 @@
# Services
This directory contains the service layer for the ComfyUI frontend application. Services encapsulate application logic and functionality into organized, reusable modules.
## Table of Contents
- [Overview](#overview)
- [Service Architecture](#service-architecture)
- [Core Services](#core-services)
- [Service Development Guidelines](#service-development-guidelines)
- [Common Design Patterns](#common-design-patterns)
## Overview
Services in ComfyUI provide organized modules that implement the application's functionality and logic. They handle operations such as API communication, workflow management, user settings, and other essential features.
The term "business logic" in this context refers to the code that implements the core functionality and behavior of the application - the rules, processes, and operations that make ComfyUI work as expected, separate from the UI display code.
Services help organize related functionality into cohesive units, making the codebase more maintainable and testable. By centralizing related operations in services, the application achieves better separation of concerns, with UI components focusing on presentation and services handling functional operations.
## Service Architecture
The service layer in ComfyUI follows these architectural principles:
1. **Domain-driven**: Each service focuses on a specific domain of the application
2. **Stateless when possible**: Services generally avoid maintaining internal state
3. **Reusable**: Services can be used across multiple components
4. **Testable**: Services are designed for easy unit testing
5. **Isolated**: Services have clear boundaries and dependencies
While services can interact with both UI components and stores (centralized state), they primarily focus on implementing functionality rather than managing state. The following diagram illustrates how services fit into the application architecture:
```
┌─────────────────────────────────────────────────────────┐
│ UI Components │
└────────────────────────────┬────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Composables │
└────────────────────────────┬────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Services │
│ │
│ (Application Functionality) │
└────────────────────────────┬────────────────────────────┘
┌───────────┴───────────┐
▼ ▼
┌───────────────────────────┐ ┌─────────────────────────┐
│ Stores │ │ External APIs │
│ (Centralized State) │ │ │
└───────────────────────────┘ └─────────────────────────┘
```
## Core Services
The core services include:
| Service | Description |
|---------|-------------|
| algoliaSearchService.ts | Implements search functionality using Algolia |
| autoQueueService.ts | Manages automatic queue execution |
| colorPaletteService.ts | Handles color palette management and customization |
| comfyManagerService.ts | Manages ComfyUI application packages and updates |
| comfyRegistryService.ts | Handles registration and discovery of ComfyUI extensions |
| dialogService.ts | Provides dialog and modal management |
| extensionService.ts | Manages extension registration and lifecycle |
| keybindingService.ts | Handles keyboard shortcuts and keybindings |
| litegraphService.ts | Provides utilities for working with the LiteGraph library |
| load3dService.ts | Manages 3D model loading and visualization |
| nodeSearchService.ts | Implements node search functionality |
| workflowService.ts | Handles workflow operations (save, load, execute) |
## Service Development Guidelines
In ComfyUI, services can be implemented using two approaches:
### 1. Class-based Services
For complex services with state management and multiple methods, class-based services are used:
```typescript
export class NodeSearchService {
// Service state
private readonly nodeFuseSearch: FuseSearch<ComfyNodeDefImpl>
private readonly filters: Record<string, FuseFilter<ComfyNodeDefImpl, string>>
constructor(data: ComfyNodeDefImpl[]) {
// Initialize state
this.nodeFuseSearch = new FuseSearch(data, { /* options */ })
// Setup filters
this.filters = {
inputType: new FuseFilter<ComfyNodeDefImpl, string>(/* options */),
category: new FuseFilter<ComfyNodeDefImpl, string>(/* options */)
}
}
public searchNode(query: string, filters: FuseFilterWithValue[] = []): ComfyNodeDefImpl[] {
// Implementation
return results
}
}
```
### 2. Composable-style Services
For simpler services or those that need to integrate with Vue's reactivity system, we prefer using composable-style services:
```typescript
export function useNodeSearchService(initialData: ComfyNodeDefImpl[]) {
// State (reactive if needed)
const data = ref(initialData)
// Search functionality
function searchNodes(query: string) {
// Implementation
return results
}
// Additional methods
function refreshData(newData: ComfyNodeDefImpl[]) {
data.value = newData
}
// Return public API
return {
searchNodes,
refreshData
}
}
```
When deciding between these approaches, consider:
1. **Stateful vs. Stateless**: For stateful services, classes often provide clearer encapsulation
2. **Reactivity needs**: If the service needs to be reactive, composable-style services integrate better with Vue's reactivity system
3. **Complexity**: For complex services with many methods and internal state, classes can provide better organization
4. **Testing**: Both approaches can be tested effectively, but composables may be simpler to test with Vue Test Utils
### Service Template
Here's a template for creating a new composable-style service:
```typescript
/**
* Service for managing [domain/functionality]
*/
export function useExampleService() {
// Private state/functionality
const cache = new Map()
/**
* Description of what this method does
* @param param1 Description of parameter
* @returns Description of return value
*/
async function performOperation(param1: string) {
try {
// Implementation
return result
} catch (error) {
// Error handling
console.error(`Operation failed: ${error.message}`)
throw error
}
}
// Return public API
return {
performOperation
}
}
```
## Common Design Patterns
Services in ComfyUI frequently use the following design patterns:
### Caching and Request Deduplication
```typescript
export function useCachedService() {
const cache = new Map()
const pendingRequests = new Map()
async function fetchData(key: string) {
// Check cache first
if (cache.has(key)) return cache.get(key)
// Check if request is already in progress
if (pendingRequests.has(key)) {
return pendingRequests.get(key)
}
// Perform new request
const requestPromise = fetch(`/api/${key}`)
.then(response => response.json())
.then(data => {
cache.set(key, data)
pendingRequests.delete(key)
return data
})
pendingRequests.set(key, requestPromise)
return requestPromise
}
return { fetchData }
}
```
### Factory Pattern
```typescript
export function useNodeFactory() {
function createNode(type: string, config: Record<string, any>) {
// Create node based on type and configuration
switch (type) {
case 'basic':
return { /* basic node implementation */ }
case 'complex':
return { /* complex node implementation */ }
default:
throw new Error(`Unknown node type: ${type}`)
}
}
return { createNode }
}
```
### Facade Pattern
```typescript
export function useWorkflowService(
apiService,
graphService,
storageService
) {
// Provides a simple interface to complex subsystems
async function saveWorkflow(name: string) {
const graphData = graphService.serializeGraph()
const storagePath = await storageService.getPath(name)
return apiService.saveData(storagePath, graphData)
}
return { saveWorkflow }
}
```
For more detailed information about the service layer pattern and its applications, refer to:
- [Service Layer Pattern](https://en.wikipedia.org/wiki/Service_layer_pattern)
- [Service-Orientation](https://en.wikipedia.org/wiki/Service-orientation)

View File

@@ -5,9 +5,12 @@ import Load3d from '@/extensions/core/load3d/Load3d'
import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
type Load3dReadyCallback = (load3d: Load3d | Load3dAnimation) => void
export class Load3dService {
private static instance: Load3dService
private nodeToLoad3dMap = new Map<LGraphNode, Load3d | Load3dAnimation>()
private pendingCallbacks = new Map<LGraphNode, Load3dReadyCallback[]>()
private constructor() {}
@@ -60,6 +63,13 @@ export class Load3dService {
this.nodeToLoad3dMap.set(rawNode, instance)
const callbacks = this.pendingCallbacks.get(rawNode)
if (callbacks) {
callbacks.forEach((callback) => callback(instance))
this.pendingCallbacks.delete(rawNode)
}
return instance
}
@@ -69,6 +79,24 @@ export class Load3dService {
return this.nodeToLoad3dMap.get(rawNode) || null
}
waitForLoad3d(node: LGraphNode, callback: Load3dReadyCallback): void {
const rawNode = toRaw(node)
const existingInstance = this.nodeToLoad3dMap.get(rawNode)
if (existingInstance) {
callback(existingInstance)
return
}
if (!this.pendingCallbacks.has(rawNode)) {
this.pendingCallbacks.set(rawNode, [])
}
this.pendingCallbacks.get(rawNode)!.push(callback)
}
getNodeByLoad3d(load3d: Load3d | Load3dAnimation): LGraphNode | null {
for (const [node, instance] of this.nodeToLoad3dMap) {
if (instance === load3d) {
@@ -88,12 +116,15 @@ export class Load3dService {
this.nodeToLoad3dMap.delete(rawNode)
}
this.pendingCallbacks.delete(rawNode)
}
clear() {
for (const [node] of this.nodeToLoad3dMap) {
this.removeLoad3d(node)
}
this.pendingCallbacks.clear()
}
}

356
src/stores/README.md Normal file
View File

@@ -0,0 +1,356 @@
# Stores
This directory contains Pinia stores for the ComfyUI frontend application. Stores provide centralized state management for the application.
## Table of Contents
- [Overview](#overview)
- [Store Architecture](#store-architecture)
- [Core Stores](#core-stores)
- [Store Development Guidelines](#store-development-guidelines)
- [Common Patterns](#common-patterns)
- [Testing Stores](#testing-stores)
## Overview
Stores in ComfyUI use [Pinia](https://pinia.vuejs.org/), Vue's official state management library. Each store is responsible for managing a specific domain of the application state, such as user data, workflow information, graph state, and UI configuration.
Stores provide a way to maintain global application state that can be accessed from any component, regardless of where those components are in the component hierarchy. This solves the problem of "prop drilling" (passing data down through multiple levels of components) and allows components that aren't directly related to share and modify the same state.
For example, without global state:
```
App
┌──────────┴──────────┐
│ │
HeaderBar Canvas
│ │
│ │
UserMenu NodeProperties
```
In this structure, if the `UserMenu` component needs to update something that affects `NodeProperties`, the data would need to be passed up to `App` and then down again, through all intermediate components.
With Pinia stores, components can directly access and update the shared state:
```
┌─────────────────┐
│ │
│ Pinia Stores │
│ │
└───────┬─────────┘
│ Accessed by
┌──────────────────────────┐
│ │
│ Components │
│ │
└──────────────────────────┘
```
## Store Architecture
The store architecture in ComfyUI follows these principles:
1. **Domain-driven**: Each store focuses on a specific domain of the application
2. **Single source of truth**: Stores serve as the definitive source for specific data
3. **Composition**: Stores can interact with other stores when needed
4. **Actions for logic**: Business logic is encapsulated in store actions
5. **Getters for derived state**: Computed values are exposed via getters
The following diagram illustrates the store architecture and data flow:
```
┌─────────────────────────────────────────────────────────┐
│ Vue Components │
│ │
│ ┌───────────────┐ ┌───────────────┐ │
│ │ Component A │ │ Component B │ │
│ └───────┬───────┘ └───────┬───────┘ │
│ │ │ │
└───────────┼────────────────────────────┼────────────────┘
│ │
│ ┌───────────────┐ │
└────►│ Composables │◄─────┘
└───────┬───────┘
┌─────────────────────────┼─────────────────────────────┐
│ Pinia Stores │ │
│ │ │
│ ┌───────────────────▼───────────────────────┐ │
│ │ Actions │ │
│ └───────────────────┬───────────────────────┘ │
│ │ │
│ ┌───────────────────▼───────────────────────┐ │
│ │ State │ │
│ └───────────────────┬───────────────────────┘ │
│ │ │
│ ┌───────────────────▼───────────────────────┐ │
│ │ Getters │ │
│ └───────────────────┬───────────────────────┘ │
│ │ │
└─────────────────────────┼─────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ External Services │
│ (API, localStorage, WebSocket, etc.) │
└─────────────────────────────────────────────────────────┘
```
## Core Stores
The core stores include:
| Store | Description |
|-------|-------------|
| aboutPanelStore.ts | Manages the About panel state and badges |
| apiKeyAuthStore.ts | Handles API key authentication |
| comfyManagerStore.ts | Manages ComfyUI application state |
| comfyRegistryStore.ts | Handles extensions registry |
| commandStore.ts | Manages commands and command execution |
| dialogStore.ts | Controls dialog/modal display and state |
| domWidgetStore.ts | Manages DOM widget state |
| executionStore.ts | Tracks workflow execution state |
| extensionStore.ts | Manages extension registration and state |
| firebaseAuthStore.ts | Handles Firebase authentication |
| graphStore.ts | Manages the graph canvas state |
| imagePreviewStore.ts | Controls image preview functionality |
| keybindingStore.ts | Manages keyboard shortcuts |
| menuItemStore.ts | Handles menu items and their state |
| modelStore.ts | Manages AI models information |
| nodeDefStore.ts | Manages node definitions |
| queueStore.ts | Handles the execution queue |
| settingStore.ts | Manages application settings |
| userStore.ts | Manages user data and preferences |
| workflowStore.ts | Handles workflow data and operations |
| workspace/* | Stores related to the workspace UI |
## Store Development Guidelines
When developing or modifying stores, follow these best practices:
1. **Define clear purpose**: Each store should have a specific responsibility
2. **Use actions for async operations**: Encapsulate asynchronous logic in actions
3. **Keep stores focused**: Each store should manage related state
4. **Document public API**: Add comments for state properties, actions, and getters
5. **Use getters for derived state**: Compute derived values using getters
6. **Test store functionality**: Write unit tests for stores
### Store Template
Here's a template for creating a new Pinia store, following the setup style used in ComfyUI:
```typescript
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useExampleStore = defineStore('example', () => {
// State
const items = ref([])
const isLoading = ref(false)
const error = ref(null)
// Getters
const itemCount = computed(() => items.value.length)
const hasError = computed(() => error.value !== null)
// Actions
function addItem(item) {
items.value.push(item)
}
async function fetchItems() {
isLoading.value = true
error.value = null
try {
const response = await fetch('/api/items')
const data = await response.json()
items.value = data
} catch (err) {
error.value = err.message
} finally {
isLoading.value = false
}
}
// Expose state, getters, and actions
return {
// State
items,
isLoading,
error,
// Getters
itemCount,
hasError,
// Actions
addItem,
fetchItems
}
})
```
## Common Patterns
Stores in ComfyUI frequently use these patterns:
### API Integration
```typescript
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { api } from '@/scripts/api'
export const useDataStore = defineStore('data', () => {
const data = ref([])
const loading = ref(false)
const error = ref(null)
async function fetchData() {
loading.value = true
try {
const result = await api.getData()
data.value = result
} catch (err) {
error.value = err.message
} finally {
loading.value = false
}
}
return {
data,
loading,
error,
fetchData
}
})
```
### Store Composition
```typescript
import { defineStore, storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useOtherStore } from './otherStore'
export const useComposedStore = defineStore('composed', () => {
const otherStore = useOtherStore()
const { someData } = storeToRefs(otherStore)
// Local state
const localState = ref(0)
// Computed value based on other store
const derivedValue = computed(() => {
return computeFromOtherData(someData.value, localState.value)
})
// Action that uses another store
async function complexAction() {
await otherStore.someAction()
localState.value += 1
}
return {
localState,
derivedValue,
complexAction
}
})
```
### Persistent State
```typescript
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
export const usePreferencesStore = defineStore('preferences', () => {
// Load from localStorage if available
const theme = ref(localStorage.getItem('theme') || 'light')
const fontSize = ref(parseInt(localStorage.getItem('fontSize') || '14'))
// Save to localStorage when changed
watch(theme, (newTheme) => {
localStorage.setItem('theme', newTheme)
})
watch(fontSize, (newSize) => {
localStorage.setItem('fontSize', newSize.toString())
})
function setTheme(newTheme) {
theme.value = newTheme
}
return {
theme,
fontSize,
setTheme
}
})
```
## Testing Stores
Stores should be tested to ensure they behave as expected. Here's an example of how to test a store:
```typescript
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { api } from '@/scripts/api'
import { useExampleStore } from '@/stores/exampleStore'
// Mock API dependencies
vi.mock('@/scripts/api', () => ({
api: {
getData: vi.fn()
}
}))
describe('useExampleStore', () => {
let store: ReturnType<typeof useExampleStore>
beforeEach(() => {
// Create a fresh pinia instance and make it active
setActivePinia(createPinia())
store = useExampleStore()
// Clear all mocks
vi.clearAllMocks()
})
it('should initialize with default state', () => {
expect(store.items).toEqual([])
expect(store.isLoading).toBe(false)
expect(store.error).toBeNull()
})
it('should add an item', () => {
store.addItem('test')
expect(store.items).toEqual(['test'])
expect(store.itemCount).toBe(1)
})
it('should fetch items', async () => {
// Setup mock response
vi.mocked(api.getData).mockResolvedValue(['item1', 'item2'])
// Call the action
await store.fetchItems()
// Verify state changes
expect(store.isLoading).toBe(false)
expect(store.items).toEqual(['item1', 'item2'])
expect(store.error).toBeNull()
})
})
```
For more information on Pinia, refer to the [Pinia documentation](https://pinia.vuejs.org/introduction.html).

View File

@@ -2,3 +2,23 @@ export interface ApiNodeCost {
name: string
cost: number
}
/**
* Information about an API node's cost and pricing details
*/
export interface ApiNodeCostData {
/** The vendor/company providing the API service (e.g., 'OpenAI', 'Stability') */
vendor: string
/** The human-readable name of the node as displayed in the UI */
nodeName: string
/** Parameters that affect pricing (e.g., 'size | quality', 'duration', '-' if none) */
pricingParams: string
/** The price range per run (e.g., '$0.05', '$0.04 x n', 'dynamic') */
pricePerRunRange: string
/** Formatted price string for display in the UI */
displayPrice: string
/** URL to the vendor's pricing documentation page */
rateDocumentationUrl?: string
}
export type ApiNodeCostRecord = Record<string, ApiNodeCostData>

View File

@@ -189,6 +189,8 @@ export default {
'elevation-0': 'none',
'elevation-1':
'0 0 2px 0px rgb(0 0 0 / 0.01), 0 1px 2px -1px rgb(0 0 0 / 0.03), 0 1px 1px -1px rgb(0 0 0 / 0.01)',
'elevation-1.5':
'0 0 2px 0px rgb(0 0 0 / 0.025), 0 1px 2px -1px rgb(0 0 0 / 0.03), 0 1px 1px -1px rgb(0 0 0 / 0.01)',
'elevation-2':
'0 0 10px 0px rgb(0 0 0 / 0.06), 0 6px 8px -2px rgb(0 0 0 / 0.07), 0 2px 4px -2px rgb(0 0 0 / 0.04)',
'elevation-3':
@@ -206,10 +208,11 @@ export default {
backgroundColor: {
'dark-elevation-0': 'rgba(255, 255, 255, 0)',
'dark-elevation-1': 'rgba(255, 255, 255, 0.01)',
'dark-elevation-1.5': 'rgba(255, 255, 255, 0.015)',
'dark-elevation-2': 'rgba(255, 255, 255, 0.03)',
'dark-elevation-3': 'rgba(255, 255, 255, 0.04)',
'dark-elevation-4': 'rgba(255, 255, 255, 0.08)',
'dark-elevation-5': 'rgba(255, 255, 255, 0.12)'
'dark-elevation-5': 'rgba(2 55, 255, 255, 0.12)'
}
}
},

45
tests-ui/README.md Normal file
View File

@@ -0,0 +1,45 @@
# ComfyUI Frontend Testing Guide
This guide provides an overview of testing approaches used in the ComfyUI Frontend codebase. These guides are meant to document any particularities or nuances of writing tests in this codebase, rather than being a comprehensive guide to testing in general. By reading these guides first, you may save yourself some time when encountering issues.
## Testing Documentation
Documentation for unit tests is organized into three guides:
- [Component Testing](./component-testing.md) - How to test Vue components
- [Unit Testing](./unit-testing.md) - How to test utility functions, composables, and other non-component code
- [Store Testing](./store-testing.md) - How to test Pinia stores specifically
## Testing Structure
The ComfyUI Frontend project uses a mixed approach to unit test organization:
- **Component Tests**: Located directly alongside their components with a `.spec.ts` extension
- **Unit Tests**: Located in the `tests-ui/tests/` directory
- **Store Tests**: Located in the `tests-ui/tests/store/` directory
- **Browser Tests**: These are located in the `browser_tests/` directory. There is a dedicated README in the `browser_tests/` directory, so it will not be covered here.
## Test Frameworks and Libraries
Our tests use the following frameworks and libraries:
- [Vitest](https://vitest.dev/) - Test runner and assertion library
- [@vue/test-utils](https://test-utils.vuejs.org/) - Vue component testing utilities
- [Pinia](https://pinia.vuejs.org/cookbook/testing.html) - For store testing
## Getting Started
To run the tests locally:
```bash
# Run unit tests
npm run test:unit
# Run unit tests in watch mode
npm run test:unit:dev
# Run component tests with browser-native environment
npm run test:component
```
Refer to the specific guides for more detailed information on each testing type.

View File

@@ -0,0 +1,370 @@
# Component Testing Guide
This guide covers patterns and examples for testing Vue components in the ComfyUI Frontend codebase.
## Table of Contents
1. [Basic Component Testing](#basic-component-testing)
2. [PrimeVue Components Testing](#primevue-components-testing)
3. [Tooltip Directives](#tooltip-directives)
4. [Component Events Testing](#component-events-testing)
5. [User Interaction Testing](#user-interaction-testing)
6. [Asynchronous Component Testing](#asynchronous-component-testing)
7. [Working with Vue Reactivity](#working-with-vue-reactivity)
## Basic Component Testing
Basic approach to testing a component's rendering and structure:
```typescript
// Example from: src/components/sidebar/SidebarIcon.spec.ts
import { mount } from '@vue/test-utils'
import SidebarIcon from './SidebarIcon.vue'
describe('SidebarIcon', () => {
const exampleProps = {
icon: 'pi pi-cog',
selected: false
}
const mountSidebarIcon = (props, options = {}) => {
return mount(SidebarIcon, {
props: { ...exampleProps, ...props },
...options
})
}
it('renders label', () => {
const wrapper = mountSidebarIcon({})
expect(wrapper.find('.p-button.p-component').exists()).toBe(true)
expect(wrapper.find('.p-button-label').exists()).toBe(true)
})
})
```
## PrimeVue Components Testing
Setting up and testing PrimeVue components:
```typescript
// Example from: src/components/common/ColorCustomizationSelector.spec.ts
import { mount } from '@vue/test-utils'
import ColorPicker from 'primevue/colorpicker'
import PrimeVue from 'primevue/config'
import SelectButton from 'primevue/selectbutton'
import { createApp } from 'vue'
import ColorCustomizationSelector from './ColorCustomizationSelector.vue'
describe('ColorCustomizationSelector', () => {
beforeEach(() => {
// Setup PrimeVue
const app = createApp({})
app.use(PrimeVue)
})
const mountComponent = (props = {}) => {
return mount(ColorCustomizationSelector, {
global: {
plugins: [PrimeVue],
components: { SelectButton, ColorPicker }
},
props: {
modelValue: null,
colorOptions: [
{ name: 'Blue', value: '#0d6efd' },
{ name: 'Green', value: '#28a745' }
],
...props
}
})
}
it('initializes with predefined color when provided', async () => {
const wrapper = mountComponent({
modelValue: '#0d6efd'
})
await nextTick()
const selectButton = wrapper.findComponent(SelectButton)
expect(selectButton.props('modelValue')).toEqual({
name: 'Blue',
value: '#0d6efd'
})
})
})
```
## Tooltip Directives
Testing components with tooltip directives:
```typescript
// Example from: src/components/sidebar/SidebarIcon.spec.ts
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Tooltip from 'primevue/tooltip'
describe('SidebarIcon with tooltip', () => {
it('shows tooltip on hover', async () => {
const tooltipShowDelay = 300
const tooltipText = 'Settings'
const wrapper = mount(SidebarIcon, {
global: {
plugins: [PrimeVue],
directives: { tooltip: Tooltip }
},
props: {
icon: 'pi pi-cog',
selected: false,
tooltip: tooltipText
}
})
// Hover over the icon
await wrapper.trigger('mouseenter')
await new Promise((resolve) => setTimeout(resolve, tooltipShowDelay + 16))
const tooltipElAfterHover = document.querySelector('[role="tooltip"]')
expect(tooltipElAfterHover).not.toBeNull()
})
it('sets aria-label attribute when tooltip is provided', () => {
const tooltipText = 'Settings'
const wrapper = mount(SidebarIcon, {
global: {
plugins: [PrimeVue],
directives: { tooltip: Tooltip }
},
props: {
icon: 'pi pi-cog',
selected: false,
tooltip: tooltipText
}
})
expect(wrapper.attributes('aria-label')).toEqual(tooltipText)
})
})
```
## Component Events Testing
Testing component events:
```typescript
// Example from: src/components/common/ColorCustomizationSelector.spec.ts
it('emits update when predefined color is selected', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
await selectButton.setValue(colorOptions[0])
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#0d6efd'])
})
it('emits update when custom color is changed', async () => {
const wrapper = mountComponent()
const selectButton = wrapper.findComponent(SelectButton)
// Select custom option
await selectButton.setValue({ name: '_custom', value: '' })
// Change custom color
const colorPicker = wrapper.findComponent(ColorPicker)
await colorPicker.setValue('ff0000')
expect(wrapper.emitted('update:modelValue')?.[0]).toEqual(['#ff0000'])
})
```
## User Interaction Testing
Testing user interactions:
```typescript
// Example from: src/components/common/EditableText.spec.ts
describe('EditableText', () => {
it('switches to edit mode on click', async () => {
const wrapper = mount(EditableText, {
props: {
modelValue: 'Initial Text',
editable: true
}
})
// Initially in view mode
expect(wrapper.find('input').exists()).toBe(false)
// Click to edit
await wrapper.find('.editable-text').trigger('click')
// Should switch to edit mode
expect(wrapper.find('input').exists()).toBe(true)
expect(wrapper.find('input').element.value).toBe('Initial Text')
})
it('saves changes on enter key press', async () => {
const wrapper = mount(EditableText, {
props: {
modelValue: 'Initial Text',
editable: true
}
})
// Switch to edit mode
await wrapper.find('.editable-text').trigger('click')
// Change input value
const input = wrapper.find('input')
await input.setValue('New Text')
// Press enter to save
await input.trigger('keydown.enter')
// Check if event was emitted with new value
expect(wrapper.emitted('update:modelValue')[0]).toEqual(['New Text'])
// Should switch back to view mode
expect(wrapper.find('input').exists()).toBe(false)
})
})
```
## Asynchronous Component Testing
Testing components with async behavior:
```typescript
// Example from: src/components/dialog/content/manager/PackVersionSelectorPopover.test.ts
import { nextTick } from 'vue'
it('shows dropdown options when clicked', async () => {
const wrapper = mount(PackVersionSelectorPopover, {
props: {
versions: ['1.0.0', '1.1.0', '2.0.0'],
selectedVersion: '1.1.0'
}
})
// Initially dropdown should be hidden
expect(wrapper.find('.p-dropdown-panel').isVisible()).toBe(false)
// Click dropdown
await wrapper.find('.p-dropdown').trigger('click')
await nextTick() // Wait for Vue to update the DOM
// Dropdown should be visible now
expect(wrapper.find('.p-dropdown-panel').isVisible()).toBe(true)
// Options should match the provided versions
const options = wrapper.findAll('.p-dropdown-item')
expect(options.length).toBe(3)
expect(options[0].text()).toBe('1.0.0')
expect(options[1].text()).toBe('1.1.0')
expect(options[2].text()).toBe('2.0.0')
})
```
## Working with Vue Reactivity
Testing components with complex reactive behavior can be challenging. Here are patterns to help manage reactivity issues in tests:
### Helper Function for Waiting on Reactivity
Use a helper function to wait for both promises and the Vue reactivity cycle:
```typescript
// Example from: src/components/dialog/content/manager/PackVersionSelectorPopover.test.ts
const waitForPromises = async () => {
// Wait for any promises in the microtask queue
await new Promise((resolve) => setTimeout(resolve, 16))
// Wait for Vue to update the DOM
await nextTick()
}
it('fetches versions on mount', async () => {
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
mountComponent()
await waitForPromises() // Wait for async operations and reactivity
expect(mockGetPackVersions).toHaveBeenCalledWith(mockNodePack.id)
})
```
### Testing Components with Async Lifecycle Hooks
When components use `onMounted` or other lifecycle hooks with async operations:
```typescript
it('shows loading state while fetching versions', async () => {
// Delay the promise resolution
mockGetPackVersions.mockImplementationOnce(
() => new Promise((resolve) =>
setTimeout(() => resolve(defaultMockVersions), 1000)
)
)
const wrapper = mountComponent()
// Check loading state before promises resolve
expect(wrapper.text()).toContain('Loading versions...')
})
```
### Testing Prop Changes
Test components' reactivity to prop changes:
```typescript
// Example from: src/components/dialog/content/manager/PackVersionSelectorPopover.test.ts
it('is reactive to nodePack prop changes', async () => {
// Set up the mock for the initial fetch
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises()
// Set up the mock for the second fetch after prop change
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
// Update the nodePack prop
const newNodePack = { ...mockNodePack, id: 'new-test-pack' }
await wrapper.setProps({ nodePack: newNodePack })
await waitForPromises()
// Should fetch versions for the new nodePack
expect(mockGetPackVersions).toHaveBeenCalledWith(newNodePack.id)
})
```
### Handling Computed Properties
Testing components with computed properties that depend on async data:
```typescript
it('displays special options and version options in the listbox', async () => {
mockGetPackVersions.mockResolvedValueOnce(defaultMockVersions)
const wrapper = mountComponent()
await waitForPromises() // Wait for data fetching and computed property updates
const listbox = wrapper.findComponent(Listbox)
const options = listbox.props('options')!
// Now options should be populated through computed properties
expect(options.length).toBe(defaultMockVersions.length + 2)
})
```
### Common Reactivity Pitfalls
1. **Not waiting for all promises**: Ensure you wait for both component promises and Vue's reactivity system
2. **Timing issues with component mounting**: Components might not be fully mounted when assertions run
3. **Async lifecycle hooks**: Components using async `onMounted` require careful handling
4. **PrimeVue components**: PrimeVue components often have their own internal state and reactivity that needs time to update
5. **Computed properties depending on async data**: Always ensure async data is loaded before testing computed properties
By using the `waitForPromises` helper and being mindful of these patterns, you can write more robust tests for components with complex reactivity.

280
tests-ui/store-testing.md Normal file
View File

@@ -0,0 +1,280 @@
# Pinia Store Testing Guide
This guide covers patterns and examples for testing Pinia stores in the ComfyUI Frontend codebase.
## Table of Contents
1. [Setting Up Store Tests](#setting-up-store-tests)
2. [Testing Store State](#testing-store-state)
3. [Testing Store Actions](#testing-store-actions)
4. [Testing Store Getters](#testing-store-getters)
5. [Mocking Dependencies in Stores](#mocking-dependencies-in-stores)
6. [Testing Store Watchers](#testing-store-watchers)
7. [Testing Store Integration](#testing-store-integration)
## Setting Up Store Tests
Basic setup for testing Pinia stores:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkflowStore } from '@/stores/workflowStore'
describe('useWorkflowStore', () => {
let store: ReturnType<typeof useWorkflowStore>
beforeEach(() => {
// Create a fresh pinia and activate it for each test
setActivePinia(createPinia())
// Initialize the store
store = useWorkflowStore()
// Clear any mocks
vi.clearAllMocks()
})
it('should initialize with default state', () => {
expect(store.workflows).toEqual([])
expect(store.activeWorkflow).toBeUndefined()
expect(store.openWorkflows).toEqual([])
})
})
```
## Testing Store State
Testing store state changes:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
it('should create a temporary workflow with a unique path', () => {
const workflow = store.createTemporary()
expect(workflow.path).toBe('workflows/Unsaved Workflow.json')
const workflow2 = store.createTemporary()
expect(workflow2.path).toBe('workflows/Unsaved Workflow (2).json')
})
it('should create a temporary workflow not clashing with persisted workflows', async () => {
await syncRemoteWorkflows(['a.json'])
const workflow = store.createTemporary('a.json')
expect(workflow.path).toBe('workflows/a (2).json')
})
```
## Testing Store Actions
Testing store actions:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('openWorkflow', () => {
it('should load and open a temporary workflow', async () => {
// Create a test workflow
const workflow = store.createTemporary('test.json')
const mockWorkflowData = { nodes: [], links: [] }
// Mock the load response
vi.spyOn(workflow, 'load').mockImplementation(async () => {
workflow.changeTracker = { activeState: mockWorkflowData } as any
return workflow as LoadedComfyWorkflow
})
// Open the workflow
await store.openWorkflow(workflow)
// Verify the workflow is now active
expect(store.activeWorkflow?.path).toBe(workflow.path)
// Verify the workflow is in the open workflows list
expect(store.isOpen(workflow)).toBe(true)
})
it('should not reload an already active workflow', async () => {
const workflow = await store.createTemporary('test.json').load()
vi.spyOn(workflow, 'load')
// Set as active workflow
store.activeWorkflow = workflow
await store.openWorkflow(workflow)
// Verify load was not called
expect(workflow.load).not.toHaveBeenCalled()
})
})
```
## Testing Store Getters
Testing store getters:
```typescript
// Example from: tests-ui/tests/store/modelStore.test.ts
describe('getters', () => {
beforeEach(() => {
setActivePinia(createPinia())
store = useModelStore()
// Set up test data
store.models = {
checkpoints: [
{ name: 'model1.safetensors', path: 'models/checkpoints/model1.safetensors' },
{ name: 'model2.ckpt', path: 'models/checkpoints/model2.ckpt' }
],
loras: [
{ name: 'lora1.safetensors', path: 'models/loras/lora1.safetensors' }
]
}
// Mock API
vi.mocked(api.getModelInfo).mockImplementation(async (modelName) => {
if (modelName.includes('model1')) {
return { info: { resolution: 768 } }
}
return { info: { resolution: 512 } }
})
})
it('should return models grouped by type', () => {
expect(store.modelsByType.checkpoints.length).toBe(2)
expect(store.modelsByType.loras.length).toBe(1)
})
it('should filter models by name', () => {
store.searchTerm = 'model1'
expect(store.filteredModels.checkpoints.length).toBe(1)
expect(store.filteredModels.checkpoints[0].name).toBe('model1.safetensors')
})
})
```
## Mocking Dependencies in Stores
Mocking API and other dependencies:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
// Add mock for api at the top of the file
vi.mock('@/scripts/api', () => ({
api: {
getUserData: vi.fn(),
storeUserData: vi.fn(),
listUserDataFullInfo: vi.fn(),
apiURL: vi.fn(),
addEventListener: vi.fn()
}
}))
// Mock comfyApp globally for the store setup
vi.mock('@/scripts/app', () => ({
app: {
canvas: null // Start with canvas potentially undefined or null
}
}))
describe('syncWorkflows', () => {
const syncRemoteWorkflows = async (filenames: string[]) => {
vi.mocked(api.listUserDataFullInfo).mockResolvedValue(
filenames.map((filename) => ({
path: filename,
modified: new Date().getTime(),
size: 1 // size !== -1 for remote workflows
}))
)
return await store.syncWorkflows()
}
it('should sync workflows', async () => {
await syncRemoteWorkflows(['a.json', 'b.json'])
expect(store.workflows.length).toBe(2)
})
})
```
## Testing Store Watchers
Testing store watchers and reactive behavior:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
import { nextTick } from 'vue'
describe('Subgraphs', () => {
it('should update automatically when activeWorkflow changes', async () => {
// Arrange: Set initial canvas state
const initialSubgraph = {
name: 'Initial Subgraph',
pathToRootGraph: [{ name: 'Root' }, { name: 'Initial Subgraph' }],
isRootGraph: false
}
vi.mocked(comfyApp.canvas).subgraph = initialSubgraph as any
// Trigger initial update
store.updateActiveGraph()
await nextTick()
// Verify initial state
expect(store.isSubgraphActive).toBe(true)
expect(store.subgraphNamePath).toEqual(['Initial Subgraph'])
// Act: Change the active workflow and canvas state
const workflow2 = store.createTemporary('workflow2.json')
vi.spyOn(workflow2, 'load').mockImplementation(async () => {
workflow2.changeTracker = { activeState: {} } as any
workflow2.originalContent = '{}'
workflow2.content = '{}'
return workflow2 as LoadedComfyWorkflow
})
// Change canvas state
vi.mocked(comfyApp.canvas).subgraph = undefined
await store.openWorkflow(workflow2)
await nextTick() // Allow watcher to trigger
// Assert: Check state was updated by the watcher
expect(store.isSubgraphActive).toBe(false)
expect(store.subgraphNamePath).toEqual([])
})
})
```
## Testing Store Integration
Testing store integration with other parts of the application:
```typescript
// Example from: tests-ui/tests/store/workflowStore.test.ts
describe('renameWorkflow', () => {
it('should rename workflow and update bookmarks', async () => {
const workflow = store.createTemporary('dir/test.json')
const bookmarkStore = useWorkflowBookmarkStore()
// Set up initial bookmark
expect(workflow.path).toBe('workflows/dir/test.json')
await bookmarkStore.setBookmarked(workflow.path, true)
expect(bookmarkStore.isBookmarked(workflow.path)).toBe(true)
// Mock super.rename
vi.spyOn(Object.getPrototypeOf(workflow), 'rename').mockImplementation(
async function (this: any, newPath: string) {
this.path = newPath
return this
} as any
)
// Perform rename
const newPath = 'workflows/dir/renamed.json'
await store.renameWorkflow(workflow, newPath)
// Check that bookmark was transferred
expect(bookmarkStore.isBookmarked(newPath)).toBe(true)
expect(bookmarkStore.isBookmarked('workflows/dir/test.json')).toBe(false)
})
})
```

View File

@@ -0,0 +1,63 @@
import { LGraphNode } from '@comfyorg/litegraph'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useNodeChatHistory } from '@/composables/node/useNodeChatHistory'
vi.mock('@/composables/widgets/useChatHistoryWidget', () => ({
useChatHistoryWidget: () => {
return (node: any, inputSpec: any) => {
const widget = {
name: inputSpec.name,
type: inputSpec.type
}
if (!node.widgets) {
node.widgets = []
}
node.widgets.push(widget)
return widget
}
}
}))
// Mock LGraphNode type
type MockNode = {
widgets: Array<{ name: string; type: string }>
setDirtyCanvas: ReturnType<typeof vi.fn>
addCustomWidget: ReturnType<typeof vi.fn>
[key: string]: any
}
describe('useNodeChatHistory', () => {
const mockNode = {
widgets: [],
setDirtyCanvas: vi.fn(),
addCustomWidget: vi.fn()
} as unknown as LGraphNode & MockNode
beforeEach(() => {
mockNode.widgets = []
mockNode.setDirtyCanvas.mockClear()
mockNode.addCustomWidget.mockClear()
})
it('adds chat history widget to node', () => {
const { showChatHistory } = useNodeChatHistory()
showChatHistory(mockNode)
expect(mockNode.widgets.length).toBe(1)
expect(mockNode.widgets[0].name).toBe('$$node-chat-history')
expect(mockNode.setDirtyCanvas).toHaveBeenCalled()
})
it('removes chat history widget from node', () => {
const { showChatHistory, removeChatHistory } = useNodeChatHistory()
showChatHistory(mockNode)
expect(mockNode.widgets.length).toBe(1)
removeChatHistory(mockNode)
expect(mockNode.widgets.length).toBe(0)
})
})

View File

@@ -0,0 +1,82 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useExecutionStore } from '@/stores/executionStore'
// Remove any previous global types
declare global {
// Empty interface to override any previous declarations
interface Window {}
}
const mockShowChatHistory = vi.fn()
vi.mock('@/composables/node/useNodeChatHistory', () => ({
useNodeChatHistory: () => ({
showChatHistory: mockShowChatHistory
})
}))
vi.mock('@/composables/node/useNodeProgressText', () => ({
useNodeProgressText: () => ({
showTextPreview: vi.fn()
})
}))
// Create a local mock instead of using global to avoid conflicts
const mockApp = {
graph: {
getNodeById: vi.fn()
}
}
describe('executionStore - display_component handling', () => {
function createDisplayComponentEvent(
nodeId: string,
component = 'ChatHistoryWidget'
) {
return new CustomEvent('display_component', {
detail: {
node_id: nodeId,
component,
props: {
history: JSON.stringify([{ prompt: 'Test', response: 'Response' }])
}
}
})
}
function handleDisplayComponentMessage(event: CustomEvent) {
const { node_id, component } = event.detail
const node = mockApp.graph.getNodeById(node_id)
if (node && component === 'ChatHistoryWidget') {
mockShowChatHistory(node)
}
}
beforeEach(() => {
setActivePinia(createPinia())
useExecutionStore()
vi.clearAllMocks()
})
it('handles ChatHistoryWidget display_component messages', () => {
const mockNode = { id: '123' }
mockApp.graph.getNodeById.mockReturnValue(mockNode)
const event = createDisplayComponentEvent('123')
handleDisplayComponentMessage(event)
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('123')
expect(mockShowChatHistory).toHaveBeenCalledWith(mockNode)
})
it('does nothing if node is not found', () => {
mockApp.graph.getNodeById.mockReturnValue(null)
const event = createDisplayComponentEvent('non-existent')
handleDisplayComponentMessage(event)
expect(mockApp.graph.getNodeById).toHaveBeenCalledWith('non-existent')
expect(mockShowChatHistory).not.toHaveBeenCalled()
})
})

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