Compare commits

...

37 Commits

Author SHA1 Message Date
github-actions
da7df96a27 Update locales [skip ci] 2025-05-24 13:15:23 +00:00
Terry Jia
2591d39da3 add fetch node 2025-05-24 09:03:10 -04:00
Terry Jia
674d04c9cf Export vue new (#3966)
Co-authored-by: hayden <48267247+hayden-fr@users.noreply.github.com>
2025-05-23 18:24:33 -07:00
Terry Jia
8209765eec [3d] improve mtl support logic (#3965) 2025-05-23 18:22:13 -07:00
Terry Jia
9d48638464 [3d] fix wrong generated language translation for 3d node output (#3967)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-22 16:49:48 -07:00
Comfy Org PR Bot
0095f02f46 1.21.0 (#3962)
Co-authored-by: huchenlei <20929282+huchenlei@users.noreply.github.com>
2025-05-21 21:35:34 -04:00
Christian Byrne
178c79e559 [fix] Make gallery navigation buttons visible on mobile devices (#3953) 2025-05-21 21:34:13 -04:00
Christian Byrne
7c0040bfec Move user.css to user data (#3952) 2025-05-21 21:33:11 -04:00
Christian Byrne
77f91dea10 [Dev Tool] Add claude directives (#3960) 2025-05-21 21:32:18 -04:00
Christian Byrne
4ad6475283 [Feature] Add audio preview support to queue sidebar (#3954)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 21:31:38 -04:00
Terry Jia
b531d34027 [3d] performance improve (#3961) 2025-05-21 21:29:52 -04:00
Christian Byrne
55ad207345 Trigger browser test expectations update (#3959)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 17:20:55 -07:00
Christian Byrne
ccc1039abb [feat] Add file upload support to canvas background image setting (#3958)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 16:06:16 -07:00
Christian Byrne
49400c69b6 Set output as background (#3079)
Co-authored-by: github-actions <github-actions@github.com>
2025-05-21 12:15:15 -04: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
115 changed files with 6697 additions and 476 deletions

38
CLAUDE.md Normal file
View File

@@ -0,0 +1,38 @@
- use npm run to see what commands are available
- After making code changes, follow this general process: (1) Create unit tests, component tests, browser tests (if appropriate for each), (2) run unit tests, component tests, and browser tests until passing, (3) run typecheck, lint, format (with prettier) -- you can use `npm run` command to see the scripts available, (4) check if any READMEs (including nested) or documentation needs to be updated, (5) Decide whether the changes are worth adding new content to the external documentation for (or would requires changes to the external documentation) at https://docs.comfy.org, then present your suggestion
- When referencing PrimeVue, you can get all the docs here: https://primevue.org. Do this instead of making up or inferring names of Components
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
- Never add lines to PR descriptions that say "Generated with Claude Code"
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading 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.
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
- Never write css if you can accomplish the same thing with tailwind utility classes
- 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,251 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
test.describe('Background Image Upload', () => {
test.beforeEach(async ({ comfyPage }) => {
// Reset the background image setting before each test
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test.afterEach(async ({ comfyPage }) => {
// Clean up background image setting after each test
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', '')
})
test('should show background image upload component in settings', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
await expect(backgroundImageSetting).toBeVisible()
// Verify the component has the expected elements using semantic selectors
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toBeVisible()
await expect(urlInput).toHaveAttribute('placeholder')
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
await expect(uploadButton).toBeVisible()
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeVisible()
await expect(clearButton).toBeDisabled() // Should be disabled when no image
})
test('should upload image file and set as background', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Click the upload button to trigger file input
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
// Set up file upload handler
const fileChooserPromise = comfyPage.page.waitForEvent('filechooser')
await uploadButton.click()
const fileChooser = await fileChooserPromise
// Upload the test image
await fileChooser.setFiles(comfyPage.assetPath('image32x32.webp'))
// Wait for upload to complete and verify the setting was updated
await comfyPage.page.waitForTimeout(500) // Give time for file reading
// Verify the URL input now has an API URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const inputValue = await urlInput.inputValue()
expect(inputValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Verify the setting value was actually set
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toMatch(/^\/api\/view\?.*subfolder=backgrounds/)
})
test('should accept URL input for background image', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Enter URL in the input field
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await urlInput.fill(testImageUrl)
// Trigger blur event to ensure the value is set
await urlInput.blur()
// Verify clear button is now enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Verify the setting value was updated
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe(testImageUrl)
})
test('should clear background image when clear button is clicked', async ({
comfyPage
}) => {
const testImageUrl = 'https://example.com/test-image.png'
// First set a background image
await comfyPage.setSetting('Comfy.Canvas.BackgroundImage', testImageUrl)
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Verify the input has the test URL
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await expect(urlInput).toHaveValue(testImageUrl)
// Verify clear button is enabled
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await expect(clearButton).toBeEnabled()
// Click the clear button
await clearButton.click()
// Verify the input is now empty
await expect(urlInput).toHaveValue('')
// Verify clear button is now disabled
await expect(clearButton).toBeDisabled()
// Verify the setting value was cleared
const settingValue = await comfyPage.getSetting(
'Comfy.Canvas.BackgroundImage'
)
expect(settingValue).toBe('')
})
test('should show tooltip on upload and clear buttons', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
// Hover over upload button and verify tooltip appears
const uploadButton = backgroundImageSetting.locator(
'button:has(.pi-upload)'
)
await uploadButton.hover()
// Wait for tooltip to appear and verify it exists
await comfyPage.page.waitForTimeout(700) // Tooltip delay
const uploadTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(uploadTooltip).toBeVisible()
// Move away to hide tooltip
await comfyPage.page.locator('body').hover()
await comfyPage.page.waitForTimeout(100)
// Set a background to enable clear button
const urlInput = backgroundImageSetting.locator('input[type="text"]')
await urlInput.fill('https://example.com/test.png')
await urlInput.blur()
// Hover over clear button and verify tooltip appears
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
await clearButton.hover()
// Wait for tooltip to appear and verify it exists
await comfyPage.page.waitForTimeout(700) // Tooltip delay
const clearTooltip = comfyPage.page.locator('.p-tooltip:visible')
await expect(clearTooltip).toBeVisible()
})
test('should maintain reactive updates between URL input and clear button state', async ({
comfyPage
}) => {
// Open settings dialog
await comfyPage.page.keyboard.press('Control+,')
// Navigate to Appearance category
const appearanceOption = comfyPage.page.locator('text=Appearance')
await appearanceOption.click()
// Find the background image setting
const backgroundImageSetting = comfyPage.page.locator(
'#Comfy\\.Canvas\\.BackgroundImage'
)
const urlInput = backgroundImageSetting.locator('input[type="text"]')
const clearButton = backgroundImageSetting.locator('button:has(.pi-trash)')
// Initially clear button should be disabled
await expect(clearButton).toBeDisabled()
// Type some text - clear button should become enabled
await urlInput.fill('test')
await expect(clearButton).toBeEnabled()
// Clear the text manually - clear button should become disabled again
await urlInput.fill('')
await expect(clearButton).toBeDisabled()
// Add text again - clear button should become enabled
await urlInput.fill('https://example.com/image.png')
await expect(clearButton).toBeEnabled()
// Use clear button - should clear input and disable itself
await clearButton.click()
await expect(urlInput).toHaveValue('')
await expect(clearButton).toBeDisabled()
})
})

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()
})
})

View File

@@ -142,4 +142,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

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

View File

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

View File

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

View File

@@ -4,9 +4,18 @@
<meta charset="UTF-8">
<title>ComfyUI</title>
<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" />
<link rel="stylesheet" type="text/css" href="user.css" />
<link rel="stylesheet" type="text/css" href="api/userdata/user.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.21.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@comfyorg/comfyui-frontend",
"version": "1.20.2",
"version": "1.21.0",
"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.21.0",
"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

@@ -0,0 +1,103 @@
<template>
<div class="flex gap-2">
<InputText
v-model="modelValue"
class="flex-1"
:placeholder="$t('g.imageUrl')"
/>
<Button
v-tooltip="$t('g.upload')"
:icon="isUploading ? 'pi pi-spin pi-spinner' : 'pi pi-upload'"
size="small"
:disabled="isUploading"
@click="triggerFileInput"
/>
<Button
v-tooltip="$t('g.clear')"
outlined
icon="pi pi-trash"
severity="danger"
size="small"
:disabled="!modelValue"
@click="clearImage"
/>
<input
ref="fileInput"
type="file"
class="hidden"
accept="image/*"
@change="handleFileUpload"
/>
</div>
</template>
<script setup lang="ts">
import Button from 'primevue/button'
import InputText from 'primevue/inputtext'
import { ref } from 'vue'
import { api } from '@/scripts/api'
import { useToastStore } from '@/stores/toastStore'
const modelValue = defineModel<string>()
const fileInput = ref<HTMLInputElement | null>(null)
const isUploading = ref(false)
const triggerFileInput = () => {
fileInput.value?.click()
}
const uploadFile = async (file: File): Promise<string | null> => {
const body = new FormData()
body.append('image', file)
body.append('subfolder', 'backgrounds')
const resp = await api.fetchApi('/upload/image', {
method: 'POST',
body
})
if (resp.status !== 200) {
useToastStore().addAlert(
`Upload failed: ${resp.status} - ${resp.statusText}`
)
return null
}
const data = await resp.json()
return data.subfolder ? `${data.subfolder}/${data.name}` : data.name
}
const handleFileUpload = async (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files && target.files[0]) {
const file = target.files[0]
isUploading.value = true
try {
const uploadedPath = await uploadFile(file)
if (uploadedPath) {
// Set the value to the API view URL with subfolder parameter
const params = new URLSearchParams({
filename: uploadedPath,
type: 'input',
subfolder: 'backgrounds'
})
modelValue.value = `/api/view?${params.toString()}`
}
} catch (error) {
useToastStore().addAlert(`Upload error: ${String(error)}`)
} finally {
isUploading.value = false
}
}
}
const clearImage = () => {
modelValue.value = ''
if (fileInput.value) {
fileInput.value.value = ''
}
}
</script>

View File

@@ -36,6 +36,7 @@ import Select from 'primevue/select'
import ToggleSwitch from 'primevue/toggleswitch'
import { type Component, markRaw } from 'vue'
import BackgroundImageUpload from '@/components/common/BackgroundImageUpload.vue'
import CustomFormValue from '@/components/common/CustomFormValue.vue'
import FormColorPicker from '@/components/common/FormColorPicker.vue'
import FormImageUpload from '@/components/common/FormImageUpload.vue'
@@ -102,6 +103,8 @@ function getFormComponent(item: FormItem): Component {
return FormColorPicker
case 'url':
return UrlInput
case 'backgroundImage':
return BackgroundImageUpload
default:
return InputText
}

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

@@ -168,6 +168,20 @@ watch(
await colorPaletteService.loadColorPalette(currentPaletteId)
}
)
watch(
() => settingStore.get('Comfy.Canvas.BackgroundImage'),
async () => {
if (!canvasStore.canvas) return
const currentPaletteId = colorPaletteStore.activePaletteId
if (!currentPaletteId) return
// Reload color palette to apply background image
await colorPaletteService.loadColorPalette(currentPaletteId)
// Mark background canvas as dirty
canvasStore.canvas.setDirty(false, true)
}
)
watch(
() => colorPaletteStore.activePaletteId,
async (newValue) => {

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

@@ -197,30 +197,46 @@ const confirmRemoveAll = (event: Event) => {
const menu = ref<InstanceType<typeof ContextMenu> | null>(null)
const menuTargetTask = ref<TaskItemImpl | null>(null)
const menuTargetNode = ref<ComfyNode | null>(null)
const menuItems = computed<MenuItem[]>(() => [
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: () => menuTargetTask.value && removeTask(menuTargetTask.value),
disabled: isExpanded.value || isInFolderView.value
},
{
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),
icon: 'pi pi-arrow-circle-right',
command: () => {
if (!menuTargetNode.value) return
useLitegraphService().goToNode(menuTargetNode.value.id)
const menuItems = computed<MenuItem[]>(() => {
const items: MenuItem[] = [
{
label: t('g.delete'),
icon: 'pi pi-trash',
command: () => menuTargetTask.value && removeTask(menuTargetTask.value),
disabled: isExpanded.value || isInFolderView.value
},
visible: !!menuTargetNode.value
{
label: t('g.loadWorkflow'),
icon: 'pi pi-file-export',
command: () => menuTargetTask.value?.loadWorkflow(app),
disabled: !menuTargetTask.value?.workflow
},
{
label: t('g.goToNode'),
icon: 'pi pi-arrow-circle-right',
command: () => {
if (!menuTargetNode.value) return
useLitegraphService().goToNode(menuTargetNode.value.id)
},
visible: !!menuTargetNode.value
}
]
if (menuTargetTask.value?.previewOutput?.mediaType === 'images') {
items.push({
label: t('g.setAsBackground'),
icon: 'pi pi-image',
command: () => {
const url = menuTargetTask.value?.previewOutput?.url
if (url) {
void settingStore.set('Comfy.Canvas.BackgroundImage', url)
}
}
})
}
])
return items
})
const handleContextMenu = ({
task,

View File

@@ -0,0 +1,19 @@
<template>
<audio controls width="100%" height="100%">
<source :src="url" :type="htmlAudioType" />
{{ $t('g.audioFailedToLoad') }}
</audio>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { ResultItemImpl } from '@/stores/queueStore'
const { result } = defineProps<{
result: ResultItemImpl
}>()
const url = computed(() => result.url)
const htmlAudioType = computed(() => result.htmlAudioType)
</script>

View File

@@ -0,0 +1,177 @@
import { mount } from '@vue/test-utils'
import PrimeVue from 'primevue/config'
import Galleria from 'primevue/galleria'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, nextTick } from 'vue'
import type { NodeId } from '@/schemas/comfyWorkflowSchema'
import type { ResultItemImpl } from '@/stores/queueStore'
import ResultGallery from './ResultGallery.vue'
type MockResultItem = Partial<ResultItemImpl> & {
filename: string
subfolder: string
type: string
nodeId: NodeId
mediaType: string
id?: string
url?: string
isImage?: boolean
isVideo?: boolean
}
describe('ResultGallery', () => {
// Mock ComfyImage and ResultVideo components
const mockComfyImage = {
name: 'ComfyImage',
template: '<div class="mock-comfy-image" data-testid="comfy-image"></div>',
props: ['src', 'contain', 'alt']
}
const mockResultVideo = {
name: 'ResultVideo',
template:
'<div class="mock-result-video" data-testid="result-video"></div>',
props: ['result']
}
// Sample gallery items - using mock instances with only required properties
const mockGalleryItems: MockResultItem[] = [
{
filename: 'image1.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '123' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image1.jpg',
id: '1'
},
{
filename: 'image2.jpg',
subfolder: 'outputs',
type: 'output',
nodeId: '456' as NodeId,
mediaType: 'images',
isImage: true,
isVideo: false,
url: 'image2.jpg',
id: '2'
}
]
beforeEach(() => {
const app = createApp({})
app.use(PrimeVue)
// Create mock elements for Galleria to find
document.body.innerHTML = `
<div id="app"></div>
`
})
afterEach(() => {
// Clean up any elements added to body
document.body.innerHTML = ''
vi.restoreAllMocks()
})
const mountGallery = (props = {}) => {
return mount(ResultGallery, {
global: {
plugins: [PrimeVue],
components: {
Galleria,
ComfyImage: mockComfyImage,
ResultVideo: mockResultVideo
},
stubs: {
teleport: true
}
},
props: {
allGalleryItems: mockGalleryItems as unknown as ResultItemImpl[],
activeIndex: 0,
...props
},
attachTo: document.getElementById('app') || undefined
})
}
it('renders Galleria component with correct props', async () => {
const wrapper = mountGallery()
await nextTick() // Wait for component to mount
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
expect(galleria.props('value')).toEqual(mockGalleryItems)
expect(galleria.props('showIndicators')).toBe(false)
expect(galleria.props('showItemNavigators')).toBe(true)
expect(galleria.props('fullScreen')).toBe(true)
})
it('shows gallery when activeIndex changes from -1', async () => {
const wrapper = mountGallery({ activeIndex: -1 })
// Initially galleryVisible should be false
const vm: any = wrapper.vm
expect(vm.galleryVisible).toBe(false)
// Change activeIndex
await wrapper.setProps({ activeIndex: 0 })
await nextTick()
// galleryVisible should become true
expect(vm.galleryVisible).toBe(true)
})
it('should render the component properly', () => {
// This is a meta-test to confirm the component mounts properly
const wrapper = mountGallery()
// We can't directly test the compiled CSS, but we can verify the component renders
expect(wrapper.exists()).toBe(true)
// Verify that the Galleria component exists and is properly mounted
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
})
it('ensures correct configuration for mobile viewport', async () => {
// Mock window.matchMedia to simulate mobile viewport
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: vi.fn().mockImplementation((query) => ({
matches: query.includes('max-width: 768px'),
media: query,
onchange: null,
addListener: vi.fn(),
removeListener: vi.fn(),
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn()
}))
})
const wrapper = mountGallery()
await nextTick()
// Verify mobile media query is working
expect(window.matchMedia('(max-width: 768px)').matches).toBe(true)
// Check if the component renders with Galleria
const galleria = wrapper.findComponent(Galleria)
expect(galleria.exists()).toBe(true)
// Check that our PT props for positioning work correctly
const pt = galleria.props('pt') as any
expect(pt?.prevButton?.style).toContain('position: fixed')
expect(pt?.nextButton?.style).toContain('position: fixed')
})
// Additional tests for interaction could be added once we can reliably
// test Galleria component in fullscreen mode
})

View File

@@ -35,6 +35,7 @@
class="galleria-image"
/>
<ResultVideo v-else-if="item.isVideo" :result="item" />
<ResultAudio v-else-if="item.isAudio" :result="item" />
</template>
</Galleria>
</template>
@@ -46,6 +47,7 @@ import { onMounted, onUnmounted, ref, watch } from 'vue'
import ComfyImage from '@/components/common/ComfyImage.vue'
import { ResultItemImpl } from '@/stores/queueStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const galleryVisible = ref(false)
@@ -142,4 +144,12 @@ img.galleria-image {
/* Set z-index so the close button doesn't get hidden behind the image when image is large */
z-index: 1;
}
/* Mobile/tablet specific fixes */
@media screen and (max-width: 768px) {
.p-galleria-prev-button,
.p-galleria-next-button {
z-index: 2;
}
}
</style>

View File

@@ -12,6 +12,7 @@
:alt="result.filename"
/>
<ResultVideo v-else-if="result.isVideo" :result="result" />
<ResultAudio v-else-if="result.isAudio" :result="result" />
<div v-else class="task-result-preview">
<i class="pi pi-file" />
<span>{{ result.mediaType }}</span>
@@ -26,6 +27,7 @@ import ComfyImage from '@/components/common/ComfyImage.vue'
import { ResultItemImpl } from '@/stores/queueStore'
import { useSettingStore } from '@/stores/settingStore'
import ResultAudio from './ResultAudio.vue'
import ResultVideo from './ResultVideo.vue'
const props = defineProps<{

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

@@ -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'],
@@ -818,6 +826,17 @@ export const CORE_SETTINGS: SettingParams[] = [
defaultValue: false,
versionAdded: '1.18.0'
},
{
id: 'Comfy.Canvas.BackgroundImage',
category: ['Appearance', 'Canvas', 'Background'],
name: 'Canvas background image',
type: 'backgroundImage',
tooltip:
'Image URL for the canvas background. You can right-click an image in the outputs panel and select "Set as Background" to use it, or upload your own image using the upload button.',
defaultValue: '',
versionAdded: '1.20.4',
versionModified: '1.20.5'
},
{
id: 'LiteGraph.Pointer.TrackpadGestures',
category: ['LiteGraph', 'Pointer', 'Trackpad Gestures'],

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

@@ -0,0 +1,97 @@
import { t } from '@/i18n'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useExtensionService } from '@/services/extensionService'
import { useToastStore } from '@/stores/toastStore'
useExtensionService().registerExtension({
name: 'Comfy.FetchApi',
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'FetchApi') return
const onExecuted = node.onExecuted
const msg = t('toastMessages.unableToFetchFile')
const downloadFile = async (
typeValue: string,
subfolderValue: string,
filenameValue: string
) => {
try {
const params = [
'filename=' + encodeURIComponent(filenameValue),
'type=' + encodeURIComponent(typeValue),
'subfolder=' + encodeURIComponent(subfolderValue),
app.getRandParam().substring(1)
].join('&')
const fetchURL = `/view?${params}`
const response = await api.fetchApi(fetchURL)
if (!response.ok) {
console.error(response)
useToastStore().addAlert(msg)
return false
}
const blob = await response.blob()
const downloadFilename = filenameValue
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.style.display = 'none'
a.href = url
a.download = downloadFilename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
window.URL.revokeObjectURL(url)
return true
} catch (error) {
console.error(error)
useToastStore().addAlert(msg)
return false
}
}
const type = node.widgets?.find((w) => w.name === 'type')
const subfolder = node.widgets?.find((w) => w.name === 'subfolder')
const filename = node.widgets?.find((w) => w.name === 'filename')
node.onExecuted = function (message: any) {
onExecuted?.apply(this, arguments as any)
const typeInput = message.result[0]
const subfolderInput = message.result[1]
const filenameInput = message.result[2]
const autoDownload = node.widgets?.find((w) => w.name === 'auto_download')
if (type && subfolder && filename) {
type.value = typeInput
subfolder.value = subfolderInput
filename.value = filenameInput
if (autoDownload && autoDownload.value) {
downloadFile(typeInput, subfolderInput, filenameInput)
}
}
}
node.addWidget('button', 'download', 'download', async () => {
if (type && subfolder && filename) {
await downloadFile(
type.value as string,
subfolder.value as string,
filename.value as string
)
} else {
console.error(msg)
useToastStore().addAlert(msg)
}
})
}
})

View File

@@ -1,3 +1,4 @@
import './apiNode'
import './clipspace'
import './contextMenuFilter'
import './dynamicPrompts'

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

@@ -13,6 +13,7 @@
"terminal": "Terminal",
"logs": "Logs",
"videoFailedToLoad": "Video failed to load",
"audioFailedToLoad": "Audio failed to load",
"extensionName": "Extension Name",
"reloadToApplyChanges": "Reload to apply changes",
"insert": "Insert",
@@ -57,6 +58,8 @@
"deprecated": "DEPR",
"loadWorkflow": "Load Workflow",
"goToNode": "Go to Node",
"setAsBackground": "Set as Background",
"customBackground": "Custom Background",
"settings": "Settings",
"searchWorkflows": "Search Workflows",
"searchSettings": "Search Settings",
@@ -116,7 +119,9 @@
"unknownError": "Unknown error",
"title": "Title",
"edit": "Edit",
"copy": "Copy"
"copy": "Copy",
"imageUrl": "Image URL",
"clear": "Clear"
},
"manager": {
"title": "Custom Nodes Manager",
@@ -203,7 +208,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 +807,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 +891,8 @@
"3D": "3D",
"Light": "Light",
"User": "User",
"Credits": "Credits"
"Credits": "Credits",
"API Nodes": "API Nodes"
},
"serverConfigItems": {
"listen": {
@@ -1227,7 +1236,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",
@@ -1263,7 +1273,8 @@
"failedToPurchaseCredits": "Failed to purchase credits: {error}",
"unauthorizedDomain": "Your domain {domain} is not authorized to use this service. Please contact {email} to add your domain to the whitelist.",
"useApiKeyTip": "Tip: Can't access normal login? Use the Comfy API Key option.",
"nothingSelected": "Nothing selected"
"nothingSelected": "Nothing selected",
"unableToFetchFile": "Unable to fetch file"
},
"auth": {
"apiKey": {

View File

@@ -25,6 +25,10 @@
"custom": "custom"
}
},
"Comfy_Canvas_BackgroundImage": {
"name": "Canvas background image",
"tooltip": "Image URL for the canvas background. You can right-click an image in the outputs panel and select \"Set as Background\" to use it, or upload your own image using the upload button."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Show selection toolbox"
},
@@ -225,6 +229,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

@@ -248,11 +248,13 @@
"all": "Todo",
"amount": "Cantidad",
"apply": "Aplicar",
"audioFailedToLoad": "No se pudo cargar el audio",
"back": "Atrás",
"cancel": "Cancelar",
"capture": "captura",
"category": "Categoría",
"choose_file_to_upload": "elige archivo para subir",
"clear": "Limpiar",
"close": "Cerrar",
"color": "Color",
"comingSoon": "Próximamente",
@@ -267,6 +269,7 @@
"copy": "Copiar",
"copyToClipboard": "Copiar al portapapeles",
"currentUser": "Usuario actual",
"customBackground": "Fondo personalizado",
"customize": "Personalizar",
"customizeFolder": "Personalizar carpeta",
"delete": "Eliminar",
@@ -292,6 +295,7 @@
"goToNode": "Ir al nodo",
"icon": "Icono",
"imageFailedToLoad": "Falló la carga de la imagen",
"imageUrl": "URL de la imagen",
"import": "Importar",
"inProgress": "En progreso",
"insert": "Insertar",
@@ -342,6 +346,7 @@
"searchNodes": "Buscar nodos",
"searchSettings": "Buscar configuraciones",
"searchWorkflows": "Buscar flujos de trabajo",
"setAsBackground": "Establecer como fondo",
"settings": "Configuraciones",
"showReport": "Mostrar informe",
"sort": "Ordenar",
@@ -498,6 +503,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 +525,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 +715,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 +1006,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "Nodos API",
"About": "Acerca de",
"Appearance": "Apariencia",
"BrushAdjustment": "Ajuste de Pincel",
@@ -1349,6 +1359,7 @@
"pendingTasksDeleted": "Tareas pendientes eliminadas",
"pleaseSelectNodesToGroup": "Por favor, seleccione los nodos (u otros grupos) para crear un grupo para",
"pleaseSelectOutputNodes": "Por favor, selecciona los nodos de salida",
"unableToFetchFile": "No se pudo obtener el archivo",
"unableToGetModelFilePath": "No se puede obtener la ruta del archivo del modelo",
"unauthorizedDomain": "Tu dominio {domain} no está autorizado para usar este servicio. Por favor, contacta a {email} para agregar tu dominio a la lista blanca.",
"updateRequested": "Actualización solicitada",

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

@@ -25,6 +25,10 @@
},
"tooltip": "Personalizado: Reemplace la barra de título del sistema con el menú superior de ComfyUI"
},
"Comfy_Canvas_BackgroundImage": {
"name": "Imagen de fondo del lienzo",
"tooltip": "URL de la imagen para el fondo del lienzo. Puedes hacer clic derecho en una imagen del panel de resultados y seleccionar \"Establecer como fondo\" para usarla."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Mostrar caja de herramientas de selección"
},
@@ -193,6 +197,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

@@ -248,11 +248,13 @@
"all": "Tout",
"amount": "Quantité",
"apply": "Appliquer",
"audioFailedToLoad": "Échec du chargement de l'audio",
"back": "Retour",
"cancel": "Annuler",
"capture": "capture",
"category": "Catégorie",
"choose_file_to_upload": "choisissez le fichier à télécharger",
"clear": "Effacer",
"close": "Fermer",
"color": "Couleur",
"comingSoon": "Bientôt disponible",
@@ -267,6 +269,7 @@
"copy": "Copier",
"copyToClipboard": "Copier dans le presse-papiers",
"currentUser": "Utilisateur actuel",
"customBackground": "Arrière-plan personnalisé",
"customize": "Personnaliser",
"customizeFolder": "Personnaliser le dossier",
"delete": "Supprimer",
@@ -292,6 +295,7 @@
"goToNode": "Aller au nœud",
"icon": "Icône",
"imageFailedToLoad": "Échec du chargement de l'image",
"imageUrl": "URL de l'image",
"import": "Importer",
"inProgress": "En cours",
"insert": "Insérer",
@@ -342,6 +346,7 @@
"searchNodes": "Rechercher des nœuds",
"searchSettings": "Rechercher des paramètres",
"searchWorkflows": "Rechercher des flux de travail",
"setAsBackground": "Définir comme arrière-plan",
"settings": "Paramètres",
"showReport": "Afficher le rapport",
"sort": "Trier",
@@ -498,6 +503,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 +525,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 +715,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 +1006,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "Nœuds API",
"About": "À Propos",
"Appearance": "Apparence",
"BrushAdjustment": "Ajustement de Brosse",
@@ -1349,6 +1359,7 @@
"pendingTasksDeleted": "Tâches en attente supprimées",
"pleaseSelectNodesToGroup": "Veuillez sélectionner les nœuds (ou autres groupes) pour créer un groupe pour",
"pleaseSelectOutputNodes": "Veuillez sélectionner les nœuds de sortie",
"unableToFetchFile": "Impossible de récupérer le fichier",
"unableToGetModelFilePath": "Impossible d'obtenir le chemin du fichier modèle",
"unauthorizedDomain": "Votre domaine {domain} n'est pas autorisé à utiliser ce service. Veuillez contacter {email} pour ajouter votre domaine à la liste blanche.",
"updateRequested": "Mise à jour demandée",

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

@@ -25,6 +25,10 @@
},
"tooltip": "Choisissez l'option personnalisée pour masquer la barre de titre du système"
},
"Comfy_Canvas_BackgroundImage": {
"name": "Image de fond du canevas",
"tooltip": "URL de l'image pour le fond du canevas. Vous pouvez faire un clic droit sur une image dans le panneau de sortie et sélectionner « Définir comme fond » pour l'utiliser."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Afficher la boîte à outils de sélection"
},
@@ -193,6 +197,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

@@ -248,11 +248,13 @@
"all": "すべて",
"amount": "量",
"apply": "適用する",
"audioFailedToLoad": "オーディオの読み込みに失敗しました",
"back": "戻る",
"cancel": "キャンセル",
"capture": "キャプチャ",
"category": "カテゴリ",
"choose_file_to_upload": "アップロードするファイルを選択",
"clear": "クリア",
"close": "閉じる",
"color": "色",
"comingSoon": "近日公開",
@@ -267,6 +269,7 @@
"copy": "コピー",
"copyToClipboard": "クリップボードにコピー",
"currentUser": "現在のユーザー",
"customBackground": "カスタム背景",
"customize": "カスタマイズ",
"customizeFolder": "フォルダーをカスタマイズ",
"delete": "削除",
@@ -292,6 +295,7 @@
"goToNode": "ノードに移動",
"icon": "アイコン",
"imageFailedToLoad": "画像の読み込みに失敗しました",
"imageUrl": "画像URL",
"import": "インポート",
"inProgress": "進行中",
"insert": "挿入",
@@ -342,6 +346,7 @@
"searchNodes": "ノードを検索",
"searchSettings": "設定を検索",
"searchWorkflows": "ワークフローを検索",
"setAsBackground": "背景として設定",
"settings": "設定",
"showReport": "レポートを表示",
"sort": "並び替え",
@@ -498,6 +503,8 @@
"submitErrorReport": "エラーレポートを提出する(オプション)",
"systemStats": "システム統計",
"validation": {
"descriptionRequired": "説明は必須です",
"helpTypeRequired": "ヘルプの種類は必須です",
"invalidEmail": "有効なメールアドレスを入力してください",
"maxLength": "メッセージが長すぎます",
"selectIssueType": "問題の種類を選択してください"
@@ -518,6 +525,7 @@
"fov": "FOV",
"light": "ライト",
"lightIntensity": "光の強度",
"loadingBackgroundImage": "背景画像を読み込んでいます",
"loadingModel": "3Dモデルを読み込んでいます...",
"materialMode": "マテリアルモード",
"materialModes": {
@@ -707,6 +715,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 +1006,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "APIード",
"About": "情報",
"Appearance": "外観",
"BrushAdjustment": "ブラシ調整",
@@ -1349,6 +1359,7 @@
"pendingTasksDeleted": "保留中のタスクが削除されました",
"pleaseSelectNodesToGroup": "グループを作成するためのノード(または他のグループ)を選択してください",
"pleaseSelectOutputNodes": "出力ノードを選択してください",
"unableToFetchFile": "ファイルを取得できません",
"unableToGetModelFilePath": "モデルファイルのパスを取得できません",
"unauthorizedDomain": "あなたのドメイン {domain} はこのサービスを利用する権限がありません。ご利用のドメインをホワイトリストに追加するには、{email} までご連絡ください。",
"updateRequested": "更新が要求されました",

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

@@ -25,6 +25,10 @@
},
"tooltip": "システムタイトルバーを非表示にするにはカスタムオプションを選択してください"
},
"Comfy_Canvas_BackgroundImage": {
"name": "キャンバス背景画像",
"tooltip": "キャンバスの背景画像のURLです。出力パネルで画像を右クリックし、「背景として設定」を選択すると使用できます。"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "選択ツールボックスを表示"
},
@@ -193,6 +197,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

@@ -248,11 +248,13 @@
"all": "모두",
"amount": "수량",
"apply": "적용",
"audioFailedToLoad": "오디오를 불러오지 못했습니다",
"back": "뒤로",
"cancel": "취소",
"capture": "캡처",
"category": "카테고리",
"choose_file_to_upload": "업로드할 파일 선택",
"clear": "지우기",
"close": "닫기",
"color": "색상",
"comingSoon": "곧 출시 예정",
@@ -267,6 +269,7 @@
"copy": "복사",
"copyToClipboard": "클립보드에 복사",
"currentUser": "현재 사용자",
"customBackground": "맞춤 배경",
"customize": "사용자 정의",
"customizeFolder": "폴더 사용자 정의",
"delete": "삭제",
@@ -292,6 +295,7 @@
"goToNode": "노드로 이동",
"icon": "아이콘",
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
"imageUrl": "이미지 URL",
"import": "가져오기",
"inProgress": "진행 중",
"insert": "삽입",
@@ -342,6 +346,7 @@
"searchNodes": "노드 검색",
"searchSettings": "설정 검색",
"searchWorkflows": "워크플로 검색",
"setAsBackground": "배경으로 설정",
"settings": "설정",
"showReport": "보고서 보기",
"sort": "정렬",
@@ -498,6 +503,8 @@
"submitErrorReport": "오류 보고서 제출 (선택 사항)",
"systemStats": "시스템 통계",
"validation": {
"descriptionRequired": "설명은 필수입니다",
"helpTypeRequired": "도움 유형은 필수입니다",
"invalidEmail": "유효한 이메일 주소를 입력해 주세요",
"maxLength": "메시지가 너무 깁니다",
"selectIssueType": "문제 유형을 선택해 주세요"
@@ -518,6 +525,7 @@
"fov": "FOV",
"light": "빛",
"lightIntensity": "조명 강도",
"loadingBackgroundImage": "배경 이미지 불러오는 중",
"loadingModel": "3D 모델 로딩 중...",
"materialMode": "재질 모드",
"materialModes": {
@@ -707,6 +715,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 +1006,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "API 노드",
"About": "정보",
"Appearance": "모양",
"BrushAdjustment": "브러시 조정",
@@ -1349,6 +1359,7 @@
"pendingTasksDeleted": "보류 중인 작업이 삭제되었습니다",
"pleaseSelectNodesToGroup": "그룹을 만들기 위해 노드(또는 다른 그룹)를 선택해 주세요",
"pleaseSelectOutputNodes": "출력 노드를 선택해 주세요",
"unableToFetchFile": "파일을 가져올 수 없습니다",
"unableToGetModelFilePath": "모델 파일 경로를 가져올 수 없습니다",
"unauthorizedDomain": "귀하의 도메인 {domain}은(는) 이 서비스를 사용할 수 있는 권한이 없습니다. 도메인을 허용 목록에 추가하려면 {email}로 문의해 주세요.",
"updateRequested": "업데이트 요청됨",

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

@@ -25,6 +25,10 @@
},
"tooltip": "시스템 제목 표시 줄을 숨기려면 사용자 정의 옵션을 선택하세요"
},
"Comfy_Canvas_BackgroundImage": {
"name": "캔버스 배경 이미지",
"tooltip": "캔버스 배경에 사용할 이미지 URL입니다. 출력 패널에서 이미지를 마우스 오른쪽 버튼으로 클릭한 후 \"배경으로 설정\"을 선택해 사용할 수 있습니다."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "선택 도구 상자 표시"
},
@@ -193,6 +197,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

@@ -248,11 +248,13 @@
"all": "Все",
"amount": "Количество",
"apply": "Применить",
"audioFailedToLoad": "Не удалось загрузить аудио",
"back": "Назад",
"cancel": "Отмена",
"capture": "захват",
"category": "Категория",
"choose_file_to_upload": "выберите файл для загрузки",
"clear": "Очистить",
"close": "Закрыть",
"color": "Цвет",
"comingSoon": "Скоро будет",
@@ -267,6 +269,7 @@
"copy": "Копировать",
"copyToClipboard": "Скопировать в буфер обмена",
"currentUser": "Текущий пользователь",
"customBackground": "Пользовательский фон",
"customize": "Настроить",
"customizeFolder": "Настроить папку",
"delete": "Удалить",
@@ -292,6 +295,7 @@
"goToNode": "Перейти к ноде",
"icon": "Иконка",
"imageFailedToLoad": "Не удалось загрузить изображение",
"imageUrl": "URL изображения",
"import": "Импорт",
"inProgress": "В процессе",
"insert": "Вставить",
@@ -342,6 +346,7 @@
"searchNodes": "Поиск нод",
"searchSettings": "Поиск настроек",
"searchWorkflows": "Поиск рабочих процессов",
"setAsBackground": "Установить как фон",
"settings": "Настройки",
"showReport": "Показать отчёт",
"sort": "Сортировать",
@@ -498,6 +503,8 @@
"submitErrorReport": "Отправить отчёт об ошибке (необязательно)",
"systemStats": "Статистика системы",
"validation": {
"descriptionRequired": "Описание обязательно",
"helpTypeRequired": "Тип помощи обязателен",
"invalidEmail": "Пожалуйста, введите действительный адрес электронной почты",
"maxLength": "Сообщение слишком длинное",
"selectIssueType": "Пожалуйста, выберите тип проблемы"
@@ -518,6 +525,7 @@
"fov": "Угол обзора",
"light": "Свет",
"lightIntensity": "Интенсивность света",
"loadingBackgroundImage": "Загрузка фонового изображения",
"loadingModel": "Загрузка 3D модели...",
"materialMode": "Режим Материала",
"materialModes": {
@@ -707,6 +715,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 +1006,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "API-узлы",
"About": "О программе",
"Appearance": "Внешний вид",
"BrushAdjustment": "Настройка кисти",
@@ -1349,6 +1359,7 @@
"pendingTasksDeleted": "Ожидающие задачи удалены",
"pleaseSelectNodesToGroup": "Пожалуйста, выберите узлы (или другие группы) для создания группы",
"pleaseSelectOutputNodes": "Пожалуйста, выберите выходные узлы",
"unableToFetchFile": "Не удалось получить файл",
"unableToGetModelFilePath": "Не удалось получить путь к файлу модели",
"unauthorizedDomain": "Ваш домен {domain} не авторизован для использования этого сервиса. Пожалуйста, свяжитесь с {email}, чтобы добавить ваш домен в белый список.",
"updateRequested": "Запрошено обновление",

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

@@ -25,6 +25,10 @@
},
"tooltip": "Выберите пользовательский вариант, чтобы скрыть системную строку заголовка"
},
"Comfy_Canvas_BackgroundImage": {
"name": "Фоновое изображение холста",
"tooltip": "URL изображения для фона холста. Вы можете кликнуть правой кнопкой мыши на изображении в панели результатов и выбрать «Установить как фон», чтобы использовать его."
},
"Comfy_Canvas_SelectionToolbox": {
"name": "Показать панель инструментов выбора"
},
@@ -193,6 +197,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

@@ -248,11 +248,13 @@
"all": "全部",
"amount": "数量",
"apply": "应用",
"audioFailedToLoad": "音频加载失败",
"back": "返回",
"cancel": "取消",
"capture": "捕获",
"category": "类别",
"choose_file_to_upload": "选择要上传的文件",
"clear": "清除",
"close": "关闭",
"color": "颜色",
"comingSoon": "即将推出",
@@ -267,6 +269,7 @@
"copy": "复制",
"copyToClipboard": "复制到剪贴板",
"currentUser": "当前用户",
"customBackground": "自定义背景",
"customize": "自定义",
"customizeFolder": "自定义文件夹",
"delete": "删除",
@@ -292,6 +295,7 @@
"goToNode": "转到节点",
"icon": "图标",
"imageFailedToLoad": "图像加载失败",
"imageUrl": "图片网址",
"import": "导入",
"inProgress": "进行中",
"insert": "插入",
@@ -342,6 +346,7 @@
"searchNodes": "搜索节点",
"searchSettings": "搜索设置",
"searchWorkflows": "搜索工作流",
"setAsBackground": "设为背景",
"settings": "设置",
"showReport": "显示报告",
"sort": "排序",
@@ -498,6 +503,8 @@
"submitErrorReport": "提交错误报告(可选)",
"systemStats": "系统状态",
"validation": {
"descriptionRequired": "描述为必填项",
"helpTypeRequired": "帮助类型为必选项",
"invalidEmail": "请输入有效的电子邮件地址",
"maxLength": "消息过长",
"selectIssueType": "请选择一个问题类型"
@@ -518,6 +525,7 @@
"fov": "视场",
"light": "灯光",
"lightIntensity": "光照强度",
"loadingBackgroundImage": "正在加载背景图像",
"loadingModel": "正在加载3D模型...",
"materialMode": "材质模式",
"materialModes": {
@@ -707,6 +715,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 +1006,7 @@
},
"settingsCategories": {
"3D": "3D",
"API Nodes": "API 节点",
"About": "关于",
"Appearance": "外观",
"BrushAdjustment": "画笔调整",
@@ -1349,6 +1359,7 @@
"pendingTasksDeleted": "待处理任务已删除",
"pleaseSelectNodesToGroup": "请选取节点(或其他组)以创建分组",
"pleaseSelectOutputNodes": "请选择输出节点",
"unableToFetchFile": "无法获取文件",
"unableToGetModelFilePath": "无法获取模型文件路径",
"unauthorizedDomain": "您的域名 {domain} 未被授权使用此服务。请联系 {email} 将您的域名添加到白名单。",
"updateRequested": "已请求更新",

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

@@ -25,6 +25,10 @@
},
"tooltip": "选择自定义选项以隐藏系统标题栏"
},
"Comfy_Canvas_BackgroundImage": {
"name": "画布背景图像",
"tooltip": "画布背景的图像 URL。你可以在输出面板中右键点击一张图片并选择“设为背景”来使用它。"
},
"Comfy_Canvas_SelectionToolbox": {
"name": "显示选择工具箱"
},
@@ -193,6 +197,9 @@
"Show all": "显示全部"
}
},
"Comfy_NodeBadge_ShowApiPricing": {
"name": "显示 API 节点定价徽章"
},
"Comfy_NodeSearchBoxImpl": {
"name": "节点搜索框",
"options": {

View File

@@ -350,6 +350,7 @@ const zNodeBadgeMode = z.enum(
const zSettings = z.object({
'Comfy.ColorPalette': z.string(),
'Comfy.CustomColorPalettes': colorPalettesSchema,
'Comfy.Canvas.BackgroundImage': z.string().optional(),
'Comfy.ConfirmClear': z.boolean(),
'Comfy.DevMode': z.boolean(),
'Comfy.Workflow.ShowMissingNodesWarning': z.boolean(),
@@ -421,6 +422,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

@@ -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)

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