Compare commits
39 Commits
v1.20.2
...
fetch-node
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da7df96a27 | ||
|
|
2591d39da3 | ||
|
|
674d04c9cf | ||
|
|
8209765eec | ||
|
|
9d48638464 | ||
|
|
0095f02f46 | ||
|
|
178c79e559 | ||
|
|
7c0040bfec | ||
|
|
77f91dea10 | ||
|
|
4ad6475283 | ||
|
|
b531d34027 | ||
|
|
55ad207345 | ||
|
|
ccc1039abb | ||
|
|
49400c69b6 | ||
|
|
32605eeb8f | ||
|
|
f08ec0a981 | ||
|
|
356886dc29 | ||
|
|
f96de24a66 | ||
|
|
9d48487af8 | ||
|
|
69b534bf14 | ||
|
|
2acb2ac181 | ||
|
|
37a583e39c | ||
|
|
d8821db2be | ||
|
|
f2c4e567e4 | ||
|
|
fec4c4e928 | ||
|
|
49d32f4809 | ||
|
|
07f0b88e30 | ||
|
|
d92ed22908 | ||
|
|
24c0c2c499 | ||
|
|
774bff2ed6 | ||
|
|
6d87f2b2ff | ||
|
|
20911aa892 | ||
|
|
3a6018589e | ||
|
|
4c92a7142e | ||
|
|
293993e7de | ||
|
|
a7ee3fae05 | ||
|
|
22dc84324e | ||
|
|
e76e9ec61a | ||
|
|
94fde504d0 |
72
.github/workflows/dev-release.yaml
vendored
Normal file
@@ -0,0 +1,72 @@
|
||||
name: Create Dev PyPI Package
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
devVersion:
|
||||
description: 'Dev version'
|
||||
required: true
|
||||
type: number
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.current_version.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 'lts/*'
|
||||
- name: Get current version
|
||||
id: current_version
|
||||
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
|
||||
- name: Build project
|
||||
env:
|
||||
SENTRY_DSN: ${{ secrets.SENTRY_DSN }}
|
||||
ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }}
|
||||
ALGOLIA_API_KEY: ${{ secrets.ALGOLIA_API_KEY }}
|
||||
USE_PROD_CONFIG: 'true'
|
||||
run: |
|
||||
npm ci
|
||||
npm run build
|
||||
npm run zipdist
|
||||
- name: Upload dist artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: dist-files
|
||||
path: |
|
||||
dist/
|
||||
dist.zip
|
||||
|
||||
publish_pypi:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
- name: Download dist artifact
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: dist-files
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.x'
|
||||
- name: Install build dependencies
|
||||
run: python -m pip install build
|
||||
- name: Setup pypi package
|
||||
run: |
|
||||
mkdir -p comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
cp -r dist/* comfyui_frontend_package/comfyui_frontend_package/static/
|
||||
- name: Build pypi package
|
||||
run: python -m build
|
||||
working-directory: comfyui_frontend_package
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
38
CLAUDE.md
Normal 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.
|
||||
148
README.md
@@ -526,14 +526,46 @@ Have another idea? Drop into Discord or open an issue, and let's chat!
|
||||
|
||||
## Development
|
||||
|
||||
### Tech Stack
|
||||
### Prerequisites & Technology 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
|
||||
- **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
|
||||
|
||||
1. Clone the repository:
|
||||
```bash
|
||||
git clone https://github.com/Comfy-Org/ComfyUI_frontend.git
|
||||
cd ComfyUI_frontend
|
||||
```
|
||||
|
||||
2. Install dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Configure environment (optional):
|
||||
Create a `.env` file in the project root based on the provided [.env.example](.env.example) file.
|
||||
|
||||
**Note about ports**: By default, the dev server expects the ComfyUI backend at `localhost:8188`. If your ComfyUI instance runs on a different port, update this in your `.env` file.
|
||||
|
||||
### Dev Server Configuration
|
||||
|
||||
To launch ComfyUI and have it connect to your development server:
|
||||
|
||||
```bash
|
||||
python main.py --port 8188
|
||||
```
|
||||
|
||||
### Git pre-commit hooks
|
||||
|
||||
@@ -575,7 +607,7 @@ navigate to `http://<server_ip>:5173` (e.g. `http://192.168.2.20:5173` here), to
|
||||
|
||||
This project includes `.vscode/launch.json.default` and `.vscode/settings.json.default` files with recommended launch and workspace settings for editors that use the `.vscode` directory (e.g., VS Code, Cursor, etc.).
|
||||
|
||||
We’ve also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
|
||||
### Unit Test
|
||||
|
||||
@@ -606,3 +638,103 @@ This will replace the litegraph package in this repo with the local litegraph re
|
||||
### i18n
|
||||
|
||||
See [locales/README.md](src/locales/README.md) for details.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
> **Note**: For comprehensive troubleshooting and how-to guides, please refer to our [official documentation](https://docs.comfy.org/). This section covers only the most common issues related to frontend development.
|
||||
|
||||
> **Desktop Users**: For issues specific to the desktop application, please refer to the [ComfyUI desktop repository](https://github.com/Comfy-Org/desktop).
|
||||
|
||||
### Debugging Custom Node (Extension) Issues
|
||||
|
||||
If you're experiencing crashes, errors, or unexpected behavior with ComfyUI, it's often caused by custom nodes (extensions). Follow these steps to identify and resolve the issues:
|
||||
|
||||
#### Step 1: Verify if custom nodes are causing the problem
|
||||
|
||||
Run ComfyUI with the `--disable-all-custom-nodes` flag:
|
||||
|
||||
```bash
|
||||
python main.py --disable-all-custom-nodes
|
||||
```
|
||||
|
||||
If the issue disappears, a custom node is the culprit. Proceed to the next step.
|
||||
|
||||
#### Step 2: Identify the problematic custom node using binary search
|
||||
|
||||
Rather than disabling nodes one by one, use this more efficient approach:
|
||||
|
||||
1. Temporarily move half of your custom nodes out of the `custom_nodes` directory
|
||||
```bash
|
||||
# Create a temporary directory
|
||||
# Linux/Mac
|
||||
mkdir ~/custom_nodes_disabled
|
||||
|
||||
# Windows
|
||||
mkdir %USERPROFILE%\custom_nodes_disabled
|
||||
|
||||
# Move half of your custom nodes (assuming you have node1 through node8)
|
||||
# Linux/Mac
|
||||
mv custom_nodes/node1 custom_nodes/node2 custom_nodes/node3 custom_nodes/node4 ~/custom_nodes_disabled/
|
||||
|
||||
# Windows
|
||||
move custom_nodes\node1 custom_nodes\node2 custom_nodes\node3 custom_nodes\node4 %USERPROFILE%\custom_nodes_disabled\
|
||||
```
|
||||
|
||||
2. Run ComfyUI again
|
||||
- If the issue persists: The problem is in nodes 5-8 (the remaining half)
|
||||
- If the issue disappears: The problem is in nodes 1-4 (the moved half)
|
||||
|
||||
3. Let's assume the issue disappeared, so the problem is in nodes 1-4. Move half of these for the next test:
|
||||
```bash
|
||||
# Move nodes 3-4 back to custom_nodes
|
||||
# Linux/Mac
|
||||
mv ~/custom_nodes_disabled/node3 ~/custom_nodes_disabled/node4 custom_nodes/
|
||||
|
||||
# Windows
|
||||
move %USERPROFILE%\custom_nodes_disabled\node3 %USERPROFILE%\custom_nodes_disabled\node4 custom_nodes\
|
||||
```
|
||||
|
||||
4. Run ComfyUI again
|
||||
- If the issue reappears: The problem is in nodes 3-4
|
||||
- If issue still gone: The problem is in nodes 1-2
|
||||
|
||||
5. Let's assume the issue reappeared, so the problem is in nodes 3-4. Test each one:
|
||||
```bash
|
||||
# Move node 3 back to disabled
|
||||
# Linux/Mac
|
||||
mv custom_nodes/node3 ~/custom_nodes_disabled/
|
||||
|
||||
# Windows
|
||||
move custom_nodes\node3 %USERPROFILE%\custom_nodes_disabled\
|
||||
```
|
||||
|
||||
6. Run ComfyUI again
|
||||
- If the issue disappears: node3 is the problem
|
||||
- If issue persists: node4 is the problem
|
||||
|
||||
7. Repeat until you identify the specific problematic node
|
||||
|
||||
#### Step 3: Update or replace the problematic node
|
||||
|
||||
Once identified:
|
||||
1. Check for updates to the problematic custom node
|
||||
2. Consider alternatives with similar functionality
|
||||
3. Report the issue to the custom node developer with specific details
|
||||
|
||||
### Common Issues and Solutions
|
||||
|
||||
- **"Module not found" errors**: Usually indicates missing Python dependencies. Check the custom node's `requirements.txt` file for required packages and install them:
|
||||
```bash
|
||||
pip install -r custom_nodes/problematic_node/requirements.txt
|
||||
```
|
||||
|
||||
- **Frontend or Templates Package Not Updated**: After updating ComfyUI via Git, ensure you update the frontend dependencies:
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- **Can't Find Custom Node**: Make sure to disable node validation in ComfyUI settings.
|
||||
|
||||
- **Error Toast About Workflow Failing Validation**: Report the issue to the ComfyUI team. As a temporary workaround, disable workflow validation in settings.
|
||||
|
||||
- **Login Issues When Not on Localhost**: Normal login is only available when accessing from localhost. If you're running ComfyUI via LAN, another domain, or headless, you can use our API key feature to authenticate. The API key lets you log in normally through the UI. Generate an API key at [platform.comfy.org/login](https://platform.comfy.org/login) and use it in the API Key field in the login dialog or with the `--api-key` command line argument. Refer to our [API Key Integration Guide](https://docs.comfy.org/essentials/comfyui-server/api-key-integration#integration-of-api-key-to-use-comfyui-api-nodes) for complete setup instructions.
|
||||
@@ -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:
|
||||
|
||||

|
||||
|
||||
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.
|
||||
251
browser_tests/tests/backgroundImageUpload.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
139
browser_tests/tests/chatHistory.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 117 KiB |
|
After Width: | Height: | Size: 239 KiB |
|
After Width: | Height: | Size: 79 KiB |
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
11
index.html
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/* Put custom styles here */
|
||||
|
Before Width: | Height: | Size: 36 KiB |
103
src/components/common/BackgroundImageUpload.vue
Normal 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>
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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()
|
||||
|
||||
35
src/components/graph/selectionToolbox/MaskEditorButton.vue
Normal 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>
|
||||
95
src/components/graph/widgets/ChatHistoryWidget.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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 ?? ''
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
19
src/components/sidebar/tabs/queue/ResultAudio.vue
Normal 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>
|
||||
177
src/components/sidebar/tabs/queue/ResultGallery.spec.ts
Normal 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
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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<{
|
||||
|
||||
205
src/components/templates/TemplateWorkflowCard.spec.ts
Normal 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'
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
133
src/components/templates/TemplateWorkflowView.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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"
|
||||
|
||||
35
src/components/templates/thumbnails/AudioThumbnail.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
66
src/components/templates/thumbnails/BaseThumbnail.spec.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
"
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
||||
102
src/components/templates/thumbnails/DefaultThumbnail.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
122
src/components/topbar/CurrentUserButton.spec.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
|
||||
173
src/components/topbar/CurrentUserPopover.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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/).
|
||||
@@ -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'
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
426
src/composables/node/useNodePricing.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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'],
|
||||
|
||||
72
src/constants/supportedWorkflowFormats.ts
Normal 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(',')
|
||||
139
src/extensions/core/README.md
Normal 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.
|
||||
97
src/extensions/core/apiNode.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,3 +1,4 @@
|
||||
import './apiNode'
|
||||
import './clipspace'
|
||||
import './contextMenuFilter'
|
||||
import './dynamicPrompts'
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
533
src/extensions/core/load3d/threejsOverride/OverrideMTLLoader.js
Normal 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 }
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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 l’image d’arriè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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 l’insigne de tarification API du nœud"
|
||||
},
|
||||
"Comfy_NodeSearchBoxImpl": {
|
||||
"name": "Implémentation de la boîte de recherche de nœud",
|
||||
"options": {
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "プログレスダイアログの切り替え"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "選択したノードのマスクエディタを開く"
|
||||
},
|
||||
"Comfy_NewBlankWorkflow": {
|
||||
"label": "新しい空のワークフロー"
|
||||
},
|
||||
|
||||
@@ -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": "更新が要求されました",
|
||||
|
||||
@@ -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": "音声を読み込む",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "진행 상황 대화 상자 전환"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "선택한 노드 마스크 편집기 열기"
|
||||
},
|
||||
"Comfy_NewBlankWorkflow": {
|
||||
"label": "새로운 빈 워크플로"
|
||||
},
|
||||
|
||||
@@ -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": "업데이트 요청됨",
|
||||
|
||||
@@ -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": "오디오 로드",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "Переключить диалоговое окно прогресса"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "Открыть редактор масок для выбранной ноды"
|
||||
},
|
||||
"Comfy_NewBlankWorkflow": {
|
||||
"label": "Новый пустой рабочий процесс"
|
||||
},
|
||||
|
||||
@@ -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": "Запрошено обновление",
|
||||
|
||||
@@ -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": "Загрузить аудио",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -140,6 +140,9 @@
|
||||
"Comfy_Manager_ToggleManagerProgressDialog": {
|
||||
"label": "切换进度对话框"
|
||||
},
|
||||
"Comfy_MaskEditor_OpenMaskEditor": {
|
||||
"label": "打开选中节点的遮罩编辑器"
|
||||
},
|
||||
"Comfy_NewBlankWorkflow": {
|
||||
"label": "新建空白工作流"
|
||||
},
|
||||
|
||||
@@ -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": "已请求更新",
|
||||
|
||||
@@ -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": "加载音频",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||