Compare commits
30 Commits
fix-firefo
...
fix/node-d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75a56c460d | ||
|
|
f251af25cc | ||
|
|
e2024c1e79 | ||
|
|
e8236e1a85 | ||
|
|
79a63de70e | ||
|
|
3eee7cde0b | ||
|
|
6bbe46009b | ||
|
|
1ca71caf45 | ||
|
|
65289b1927 | ||
|
|
9e2180dcd8 | ||
|
|
defea56ba5 | ||
|
|
e6bca95a5f | ||
|
|
841e3f743a | ||
|
|
73be826956 | ||
|
|
398dc6d8a6 | ||
|
|
d1f4341319 | ||
|
|
8c8bb1a3b7 | ||
|
|
05ef25a7a3 | ||
|
|
86aeeb87bb | ||
|
|
f7093f6ce0 | ||
|
|
88817e5bc0 | ||
|
|
3ac8aa248c | ||
|
|
75ab54ee04 | ||
|
|
a5729c9e06 | ||
|
|
d1da3476da | ||
|
|
ac01bff67e | ||
|
|
ec4ced26e7 | ||
|
|
40cfc43c54 | ||
|
|
35a811c5cf | ||
|
|
3d4ac07957 |
57
.claude/commands/verify-visually.md
Normal file
@@ -0,0 +1,57 @@
|
||||
Your task is to perform visual verification of our recent changes to ensure they display correctly in the browser. This verification is critical for catching visual regressions, layout issues, and ensuring our UI changes render properly for end users.
|
||||
|
||||
<instructions>
|
||||
Follow these steps systematically to verify our changes:
|
||||
|
||||
1. **Server Setup**
|
||||
- Check if the dev server is running on port 5173 using browser navigation or port checking
|
||||
- If not running, start it with `npm run dev` from the root directory
|
||||
- If the server fails to start, provide detailed troubleshooting steps by reading package.json and README.md for accurate instructions
|
||||
- Wait for the server to be fully ready before proceeding
|
||||
|
||||
2. **Visual Testing Process**
|
||||
- Navigate to http://localhost:5173/
|
||||
- For each target page (specified in arguments or recently changed files):
|
||||
* Navigate to the page using direct URL or site navigation
|
||||
* Take a high-quality screenshot
|
||||
* Analyze the screenshot for the specific changes we implemented
|
||||
* Document any visual issues or improvements needed
|
||||
|
||||
3. **Quality Verification**
|
||||
Check each page for:
|
||||
- Content accuracy and completeness
|
||||
- Proper styling and layout alignment
|
||||
- Responsive design elements
|
||||
- Navigation functionality
|
||||
- Image loading and display
|
||||
- Typography and readability
|
||||
- Color scheme consistency
|
||||
- Interactive elements (buttons, links, forms)
|
||||
</instructions>
|
||||
|
||||
<examples>
|
||||
Common issues to watch for:
|
||||
- Broken layouts or overlapping elements
|
||||
- Missing images or broken image links
|
||||
- Inconsistent styling or spacing
|
||||
- Navigation menu problems
|
||||
- Mobile responsiveness issues
|
||||
- Text overflow or truncation
|
||||
- Color contrast problems
|
||||
</examples>
|
||||
|
||||
<reporting>
|
||||
For each page tested, provide:
|
||||
1. Page URL and screenshot
|
||||
2. Confirmation that changes display correctly OR detailed description of issues found
|
||||
3. Any design improvement suggestions
|
||||
4. Overall assessment of visual quality
|
||||
|
||||
If you find issues, be specific about:
|
||||
- Exact location of the problem
|
||||
- Expected vs actual behavior
|
||||
- Severity level (critical, important, minor)
|
||||
- Suggested fix if obvious
|
||||
</reporting>
|
||||
|
||||
Remember: Take your time with each screenshot and analysis. Visual quality directly impacts user experience and our project's professional appearance.
|
||||
59
.cursorrules
@@ -1,26 +1,25 @@
|
||||
// Vue 3 Composition API .cursorrules
|
||||
# Vue 3 Composition API Project Rules
|
||||
|
||||
// Vue 3 Composition API best practices
|
||||
const vue3CompositionApiBestPractices = [
|
||||
"Use setup() function for component logic",
|
||||
"Utilize ref and reactive for reactive state",
|
||||
"Implement computed properties with computed()",
|
||||
"Use watch and watchEffect for side effects",
|
||||
"Implement lifecycle hooks with onMounted, onUpdated, etc.",
|
||||
"Utilize provide/inject for dependency injection",
|
||||
"Use vue 3.5 style of default prop declaration. Example:
|
||||
## Vue 3 Composition API Best Practices
|
||||
- Use setup() function for component logic
|
||||
- Utilize ref and reactive for reactive state
|
||||
- Implement computed properties with computed()
|
||||
- Use watch and watchEffect for side effects
|
||||
- Implement lifecycle hooks with onMounted, onUpdated, etc.
|
||||
- Utilize provide/inject for dependency injection
|
||||
- Use vue 3.5 style of default prop declaration. Example:
|
||||
|
||||
```typescript
|
||||
const { nodes, showTotal = true } = defineProps<{
|
||||
nodes: ApiNodeCost[]
|
||||
showTotal?: boolean
|
||||
}>()
|
||||
```
|
||||
|
||||
",
|
||||
"Organize vue component in <template> <script> <style> order",
|
||||
]
|
||||
- Organize vue component in <template> <script> <style> order
|
||||
|
||||
// Folder structure
|
||||
const folderStructure = `
|
||||
## Project Structure
|
||||
```
|
||||
src/
|
||||
components/
|
||||
constants/
|
||||
@@ -30,16 +29,25 @@ src/
|
||||
services/
|
||||
App.vue
|
||||
main.ts
|
||||
`;
|
||||
```
|
||||
|
||||
// Tailwind CSS best practices
|
||||
const tailwindCssBestPractices = [
|
||||
"Use Tailwind CSS for styling",
|
||||
"Implement responsive design with Tailwind CSS",
|
||||
]
|
||||
## Styling Guidelines
|
||||
- Use Tailwind CSS for styling
|
||||
- Implement responsive design with Tailwind CSS
|
||||
|
||||
// Additional instructions
|
||||
const additionalInstructions = `
|
||||
## PrimeVue Component Guidelines
|
||||
DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
- Dropdown → Use Select (import from 'primevue/select')
|
||||
- OverlayPanel → Use Popover (import from 'primevue/popover')
|
||||
- Calendar → Use DatePicker (import from 'primevue/datepicker')
|
||||
- InputSwitch → Use ToggleSwitch (import from 'primevue/toggleswitch')
|
||||
- Sidebar → Use Drawer (import from 'primevue/drawer')
|
||||
- Chips → Use AutoComplete with multiple enabled and typeahead disabled
|
||||
- TabMenu → Use Tabs without panels
|
||||
- Steps → Use Stepper without panels
|
||||
- InlineMessage → Use Message component
|
||||
|
||||
## Development Guidelines
|
||||
1. Leverage VueUse functions for performance-enhancing styles
|
||||
2. Use lodash for utility functions
|
||||
3. Use TypeScript for type safety
|
||||
@@ -49,6 +57,5 @@ const additionalInstructions = `
|
||||
7. Implement proper error handling
|
||||
8. Follow Vue 3 style guide and naming conventions
|
||||
9. Use Vite for fast development and building
|
||||
10. Use vue-i18n in composition API for any string literals. Place new translation
|
||||
entries in src/locales/en/main.json.
|
||||
`;
|
||||
10. Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json
|
||||
11. Never use deprecated PrimeVue components listed above
|
||||
|
||||
@@ -29,3 +29,7 @@ DISABLE_TEMPLATES_PROXY=false
|
||||
# If playwright tests are being run via vite dev server, Vue plugins will
|
||||
# invalidate screenshots. When `true`, vite plugins will not be loaded.
|
||||
DISABLE_VUE_PLUGINS=false
|
||||
|
||||
# Algolia credentials required for developing with the new custom node manager.
|
||||
ALGOLIA_APP_ID=4E0RO38HS8
|
||||
ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
|
||||
11
.github/ISSUE_TEMPLATE/bug-report.yaml
vendored
@@ -12,8 +12,15 @@ body:
|
||||
|
||||
- **1:** You are running the latest version of ComfyUI.
|
||||
- **2:** You have looked at the existing bug reports and made sure this isn't already reported.
|
||||
- **3:** You confirmed that the bug is not caused by a custom node. You can disable all custom nodes by passing
|
||||
`--disable-all-custom-nodes` command line argument.
|
||||
|
||||
- type: checkboxes
|
||||
id: custom-nodes-test
|
||||
attributes:
|
||||
label: Custom Node Testing
|
||||
description: Please confirm you have tried to reproduce the issue with all custom nodes disabled.
|
||||
options:
|
||||
- label: I have tried disabling custom nodes and the issue persists (see [how to disable custom nodes](https://docs.comfy.org/troubleshooting/custom-node-issues#step-1%3A-test-with-all-custom-nodes-disabled) if you need help)
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
attributes:
|
||||
|
||||
2
.github/workflows/dev-release.yaml
vendored
@@ -66,7 +66,7 @@ jobs:
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ format('{0}.dev{1}', needs.build.outputs.version, inputs.devVersion) }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
2
.github/workflows/i18n-custom-nodes.yaml
vendored
@@ -136,7 +136,7 @@ jobs:
|
||||
git commit -m "Update locales"
|
||||
|
||||
- name: Install SSH key For PUSH
|
||||
uses: shimataro/ssh-key-action@v2
|
||||
uses: shimataro/ssh-key-action@d4fffb50872869abe2d9a9098a6d9c5aa7d16be4
|
||||
with:
|
||||
# PR private key from action server
|
||||
key: ${{ secrets.PR_SSH_PRIVATE_KEY }}
|
||||
|
||||
2
.github/workflows/i18n-node-defs.yaml
vendored
@@ -33,7 +33,7 @@ jobs:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
working-directory: ComfyUI_frontend
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: "Update locales for node definitions"
|
||||
|
||||
4
.github/workflows/release.yaml
vendored
@@ -54,7 +54,7 @@ jobs:
|
||||
name: dist-files
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@da05d552573ad5aba039eaac05058a918a7bf631
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
@@ -93,7 +93,7 @@ jobs:
|
||||
env:
|
||||
COMFYUI_FRONTEND_VERSION: ${{ needs.build.outputs.version }}
|
||||
- name: Publish pypi package
|
||||
uses: pypa/gh-action-pypi-publish@release/v1
|
||||
uses: pypa/gh-action-pypi-publish@76f52bc884231f62b9a034ebfe128415bbaabdfc
|
||||
with:
|
||||
password: ${{ secrets.PYPI_TOKEN }}
|
||||
packages-dir: comfyui_frontend_package/dist
|
||||
|
||||
2
.github/workflows/update-electron-types.yaml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update electron-types to ${{ steps.get-version.outputs.NEW_VERSION }}'
|
||||
|
||||
2
.github/workflows/update-litegraph.yaml
vendored
@@ -29,7 +29,7 @@ jobs:
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update litegraph to ${{ steps.get-version.outputs.NEW_VERSION }}'
|
||||
|
||||
92
.github/workflows/update-manager-types.yaml
vendored
Normal file
@@ -0,0 +1,92 @@
|
||||
name: Update ComfyUI-Manager API Types
|
||||
|
||||
on:
|
||||
# Manual trigger
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
update-manager-types:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
cache: 'npm'
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Checkout ComfyUI-Manager repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI-Manager
|
||||
path: ComfyUI-Manager
|
||||
clean: true
|
||||
|
||||
- name: Get Manager commit information
|
||||
id: manager-info
|
||||
run: |
|
||||
cd ComfyUI-Manager
|
||||
MANAGER_COMMIT=$(git rev-parse --short HEAD)
|
||||
echo "commit=${MANAGER_COMMIT}" >> $GITHUB_OUTPUT
|
||||
cd ..
|
||||
|
||||
- name: Generate Manager API types
|
||||
run: |
|
||||
echo "Generating TypeScript types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}..."
|
||||
npx openapi-typescript ./ComfyUI-Manager/openapi.yaml --output ./src/types/generatedManagerTypes.ts
|
||||
|
||||
- name: Validate generated types
|
||||
run: |
|
||||
if [ ! -f ./src/types/generatedManagerTypes.ts ]; then
|
||||
echo "Error: Types file was not generated."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check if file is not empty
|
||||
if [ ! -s ./src/types/generatedManagerTypes.ts ]; then
|
||||
echo "Error: Generated types file is empty."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Check for changes
|
||||
id: check-changes
|
||||
run: |
|
||||
if [[ -z $(git status --porcelain ./src/types/generatedManagerTypes.ts) ]]; then
|
||||
echo "No changes to ComfyUI-Manager API types detected."
|
||||
echo "changed=false" >> $GITHUB_OUTPUT
|
||||
exit 0
|
||||
else
|
||||
echo "Changes detected in ComfyUI-Manager API types."
|
||||
echo "changed=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
|
||||
title: '[chore] Update ComfyUI-Manager API types from ComfyUI-Manager@${{ steps.manager-info.outputs.commit }}'
|
||||
body: |
|
||||
## Automated API Type Update
|
||||
|
||||
This PR updates the ComfyUI-Manager API types from the latest ComfyUI-Manager OpenAPI specification.
|
||||
|
||||
- Manager commit: ${{ steps.manager-info.outputs.commit }}
|
||||
- Generated on: ${{ github.event.repository.updated_at }}
|
||||
|
||||
These types are automatically generated using openapi-typescript.
|
||||
branch: update-manager-types-${{ steps.manager-info.outputs.commit }}
|
||||
base: main
|
||||
labels: Manager
|
||||
delete-branch: true
|
||||
add-paths: |
|
||||
src/types/generatedManagerTypes.ts
|
||||
2
.github/workflows/update-registry-types.yaml
vendored
@@ -75,7 +75,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: steps.check-changes.outputs.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[chore] Update Comfy Registry API types from comfy-api@${{ steps.api-info.outputs.commit }}'
|
||||
|
||||
2
.github/workflows/version-bump.yaml
vendored
@@ -38,7 +38,7 @@ jobs:
|
||||
echo "NEW_VERSION=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Create Pull Request
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e
|
||||
with:
|
||||
token: ${{ secrets.PR_GH_TOKEN }}
|
||||
commit-message: '[release] Bump version to ${{ steps.bump-version.outputs.NEW_VERSION }}'
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@executeautomation/playwright-mcp-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
10
CLAUDE.md
@@ -36,3 +36,13 @@
|
||||
- Use Vite for fast development and building
|
||||
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
|
||||
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
|
||||
- DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
* `Dropdown` → Use `Select` (import from 'primevue/select')
|
||||
* `OverlayPanel` → Use `Popover` (import from 'primevue/popover')
|
||||
* `Calendar` → Use `DatePicker` (import from 'primevue/datepicker')
|
||||
* `InputSwitch` → Use `ToggleSwitch` (import from 'primevue/toggleswitch')
|
||||
* `Sidebar` → Use `Drawer` (import from 'primevue/drawer')
|
||||
* `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
|
||||
* `TabMenu` → Use `Tabs` without panels
|
||||
* `Steps` → Use `Stepper` without panels
|
||||
* `InlineMessage` → Use `Message` component
|
||||
|
||||
62
README.md
@@ -609,68 +609,6 @@ This project includes `.vscode/launch.json.default` and `.vscode/settings.json.d
|
||||
|
||||
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
|
||||
### Recommended MCP Integrations
|
||||
|
||||
#### Playwright Browser Automation
|
||||
|
||||
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
|
||||
|
||||
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
|
||||
|
||||
##### Setup for Claude Code
|
||||
|
||||
After installing dependencies with `npm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
|
||||
|
||||
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
|
||||
|
||||
|
||||
```
|
||||
> navigate to localhost:5173 and take a screenshot.
|
||||
tell me the items in the menubar
|
||||
|
||||
● I'll navigate to localhost:5173 and take a
|
||||
screenshot to see the menubar items.
|
||||
|
||||
● playwright:playwright_navigate (MCP)(url: …
|
||||
"http://local
|
||||
host:5173")
|
||||
⎿ Navigated to http://localhost:5173
|
||||
|
||||
● playwright:playwright_screenshot (MCP)(name: "lo…
|
||||
alhost_5173
|
||||
_menubar")
|
||||
|
||||
⎿ Screenshot saved to: ../../../Downloads/localho
|
||||
st_5173_menubar-2025-05-30T17-23-00-942Z.png
|
||||
⎿ Screenshot also stored in memory with name:
|
||||
'localhost_5173_menubar'
|
||||
|
||||
● Read(../../../Downloads/localhost_5173_menubar-2…
|
||||
25-05-30T17-23-00-942Z.png)
|
||||
⎿ Read image (94.3KB)
|
||||
|
||||
● Based on the screenshot, I can see the menubar at
|
||||
the top of the ComfyUI interface contains the
|
||||
following items:
|
||||
|
||||
Main Menubar Items:
|
||||
- Workflow - Menu for workflow-related actions
|
||||
- Edit - Menu for editing operations
|
||||
- Help - Menu for help and documentation
|
||||
|
||||
Additional UI Elements:
|
||||
- ComfyUI Logo - On the far left
|
||||
- Workflow Tab - Shows "Unsaved Workflow" with a
|
||||
dropdown and close button
|
||||
- Layout Controls - On the far right (grid view
|
||||
and hamburger menu icons)
|
||||
|
||||
The interface shows a typical ComfyUI workflow
|
||||
graph with nodes like "Load Checkpoint", "CLIP
|
||||
Text Encode (Prompt)", "KSampler", and "Empty
|
||||
Latent Image" connected with colored cables.
|
||||
```
|
||||
|
||||
### Unit Test
|
||||
|
||||
- `npm i` to install all dependencies
|
||||
|
||||
|
Before Width: | Height: | Size: 83 KiB After Width: | Height: | Size: 85 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 99 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
556
browser_tests/tests/nodeHelp.spec.ts
Normal file
@@ -0,0 +1,556 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
// TODO: there might be a better solution for this
|
||||
// Helper function to pan canvas and select node
|
||||
async function selectNodeWithPan(comfyPage: any, nodeRef: any) {
|
||||
const nodePos = await nodeRef.getPosition()
|
||||
|
||||
await comfyPage.page.evaluate((pos) => {
|
||||
const app = window['app']
|
||||
const canvas = app.canvas
|
||||
canvas.ds.offset[0] = -pos.x + canvas.canvas.width / 2
|
||||
canvas.ds.offset[1] = -pos.y + canvas.canvas.height / 2 + 100
|
||||
canvas.setDirty(true, true)
|
||||
}, nodePos)
|
||||
|
||||
await comfyPage.nextFrame()
|
||||
await nodeRef.click('title')
|
||||
}
|
||||
|
||||
test.describe('Node Help', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setup()
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox', () => {
|
||||
test('Should open help menu for selected node', async ({ comfyPage }) => {
|
||||
// Load a workflow with a node
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
await comfyPage.loadWorkflow('default')
|
||||
|
||||
// Select a single node (KSampler) using node references
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
if (ksamplerNodes.length === 0) {
|
||||
throw new Error('No KSampler nodes found in the workflow')
|
||||
}
|
||||
|
||||
// Select the node with panning to ensure toolbox is visible
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
// Wait for selection overlay container and toolbox to appear
|
||||
await expect(
|
||||
comfyPage.page.locator('.selection-overlay-container')
|
||||
).toBeVisible()
|
||||
await expect(comfyPage.page.locator('.selection-toolbox')).toBeVisible()
|
||||
|
||||
// Click the help button in the selection toolbox
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click()
|
||||
|
||||
// Verify that the node library sidebar is opened
|
||||
await expect(
|
||||
comfyPage.menu.nodeLibraryTab.selectedTabButton
|
||||
).toBeVisible()
|
||||
|
||||
// Verify that the help page is shown for the correct node
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler')
|
||||
await expect(helpPage.locator('.node-help-content')).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Node Library Sidebar', () => {
|
||||
test('Should open help menu from node library', async ({ comfyPage }) => {
|
||||
// Open the node library sidebar
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
|
||||
// Wait for node library to load
|
||||
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
|
||||
|
||||
// Search for KSampler to make it easier to find
|
||||
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
|
||||
'KSampler'
|
||||
)
|
||||
|
||||
// Find the KSampler node in search results
|
||||
const ksamplerNode = comfyPage.page
|
||||
.locator('.tree-explorer-node-label')
|
||||
.filter({ hasText: 'KSampler' })
|
||||
.first()
|
||||
await expect(ksamplerNode).toBeVisible()
|
||||
|
||||
// Hover over the node to show action buttons
|
||||
await ksamplerNode.hover()
|
||||
|
||||
// Click the help button
|
||||
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
|
||||
await expect(helpButton).toBeVisible()
|
||||
await helpButton.click()
|
||||
|
||||
// Verify that the help page is shown
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler')
|
||||
await expect(helpPage.locator('.node-help-content')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should show node library tab when clicking back from help page', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Open the node library sidebar
|
||||
await comfyPage.menu.nodeLibraryTab.open()
|
||||
|
||||
// Wait for node library to load
|
||||
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
|
||||
|
||||
// Search for KSampler
|
||||
await comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput.fill(
|
||||
'KSampler'
|
||||
)
|
||||
|
||||
// Find and interact with the node
|
||||
const ksamplerNode = comfyPage.page
|
||||
.locator('.tree-explorer-node-label')
|
||||
.filter({ hasText: 'KSampler' })
|
||||
.first()
|
||||
await ksamplerNode.hover()
|
||||
const helpButton = ksamplerNode.locator('button:has(.pi-question)')
|
||||
await helpButton.click()
|
||||
|
||||
// Verify help page is shown
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler')
|
||||
|
||||
// Click the back button - use a more specific selector
|
||||
const backButton = comfyPage.page.locator('button:has(.pi-arrow-left)')
|
||||
await expect(backButton).toBeVisible()
|
||||
await backButton.click()
|
||||
|
||||
// Verify that we're back to the node library view
|
||||
await expect(comfyPage.menu.nodeLibraryTab.nodeLibraryTree).toBeVisible()
|
||||
await expect(
|
||||
comfyPage.menu.nodeLibraryTab.nodeLibrarySearchBoxInput
|
||||
).toBeVisible()
|
||||
|
||||
// Verify help page is no longer visible
|
||||
await expect(helpPage.locator('.node-help-content')).not.toBeVisible()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Help Content', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
})
|
||||
|
||||
test('Should display loading state while fetching help', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock slow network response
|
||||
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '# Test Help Content\nThis is test help content.'
|
||||
})
|
||||
})
|
||||
|
||||
// Load workflow and select a node
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
// Click help button
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
// Verify loading spinner is shown
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage.locator('.p-progressspinner')).toBeVisible()
|
||||
|
||||
// Wait for content to load
|
||||
await expect(helpPage).toContainText('Test Help Content')
|
||||
})
|
||||
|
||||
test('Should display fallback content when help file not found', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock 404 response for help files
|
||||
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 404,
|
||||
body: 'Not Found'
|
||||
})
|
||||
})
|
||||
|
||||
// Load workflow and select a node
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
// Click help button
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
// Verify fallback content is shown (description, inputs, outputs)
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('Description')
|
||||
await expect(helpPage).toContainText('Inputs')
|
||||
await expect(helpPage).toContainText('Outputs')
|
||||
})
|
||||
|
||||
test('Should render markdown with images correctly', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock response with markdown containing images
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Documentation
|
||||
|
||||

|
||||

|
||||
|
||||
## Parameters
|
||||
- **steps**: Number of steps
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler Documentation')
|
||||
|
||||
// Check that relative image paths are prefixed correctly
|
||||
const relativeImage = helpPage.locator('img[alt="Example Image"]')
|
||||
await expect(relativeImage).toBeVisible()
|
||||
await expect(relativeImage).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/docs\/KSampler\/example\.jpg/
|
||||
)
|
||||
|
||||
// Check that absolute URLs are not modified
|
||||
const externalImage = helpPage.locator('img[alt="External Image"]')
|
||||
await expect(externalImage).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/image.png'
|
||||
)
|
||||
})
|
||||
|
||||
test('Should render video elements with source tags in markdown', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock response with video elements
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Demo
|
||||
|
||||
<video src="demo.mp4" controls autoplay></video>
|
||||
<video src="/absolute/video.mp4" controls></video>
|
||||
|
||||
<video controls>
|
||||
<source src="video.mp4" type="video/mp4">
|
||||
<source src="https://example.com/video.webm" type="video/webm">
|
||||
</video>
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
|
||||
// Check relative video paths are prefixed
|
||||
const relativeVideo = helpPage.locator('video[src*="demo.mp4"]')
|
||||
await expect(relativeVideo).toBeVisible()
|
||||
await expect(relativeVideo).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/docs\/KSampler\/demo\.mp4/
|
||||
)
|
||||
await expect(relativeVideo).toHaveAttribute('controls', '')
|
||||
await expect(relativeVideo).toHaveAttribute('autoplay', '')
|
||||
|
||||
// Check absolute paths are not modified
|
||||
const absoluteVideo = helpPage.locator('video[src="/absolute/video.mp4"]')
|
||||
await expect(absoluteVideo).toHaveAttribute('src', '/absolute/video.mp4')
|
||||
|
||||
// Check video source elements
|
||||
const relativeVideoSource = helpPage.locator('source[src*="video.mp4"]')
|
||||
await expect(relativeVideoSource).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/docs\/KSampler\/video\.mp4/
|
||||
)
|
||||
|
||||
const externalVideoSource = helpPage.locator(
|
||||
'source[src="https://example.com/video.webm"]'
|
||||
)
|
||||
await expect(externalVideoSource).toHaveAttribute(
|
||||
'src',
|
||||
'https://example.com/video.webm'
|
||||
)
|
||||
})
|
||||
|
||||
test('Should handle custom node documentation paths', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// First load workflow with custom node
|
||||
await comfyPage.loadWorkflow('group_node_v1.3.3')
|
||||
|
||||
// Mock custom node documentation with fallback
|
||||
await comfyPage.page.route(
|
||||
'**/extensions/*/docs/*/en.md',
|
||||
async (route) => {
|
||||
await route.fulfill({ status: 404 })
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.page.route('**/extensions/*/docs/*.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# Custom Node Documentation
|
||||
|
||||
This is documentation for a custom node.
|
||||
|
||||

|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
// Find and select a custom/group node
|
||||
const nodeRefs = await comfyPage.page.evaluate(() => {
|
||||
return window['app'].graph.nodes.map((n: any) => n.id)
|
||||
})
|
||||
if (nodeRefs.length > 0) {
|
||||
const firstNode = await comfyPage.getNodeRefById(nodeRefs[0])
|
||||
await selectNodeWithPan(comfyPage, firstNode)
|
||||
}
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
if (await helpButton.isVisible()) {
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('Custom Node Documentation')
|
||||
|
||||
// Check image path for custom nodes
|
||||
const image = helpPage.locator('img[alt="Custom Image"]')
|
||||
await expect(image).toHaveAttribute(
|
||||
'src',
|
||||
/.*\/extensions\/.*\/docs\/assets\/custom\.png/
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
test('Should sanitize dangerous HTML content', async ({ comfyPage }) => {
|
||||
// Mock response with potentially dangerous content
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# Safe Content
|
||||
|
||||
<script>alert('XSS')</script>
|
||||
<img src="x" onerror="alert('XSS')">
|
||||
<a href="javascript:alert('XSS')">Dangerous Link</a>
|
||||
<iframe src="evil.com"></iframe>
|
||||
|
||||
<!-- Safe content -->
|
||||
<video src="safe.mp4" controls></video>
|
||||
<img src="safe.jpg" alt="Safe Image">
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
|
||||
// Dangerous elements should be removed
|
||||
await expect(helpPage.locator('script')).toHaveCount(0)
|
||||
await expect(helpPage.locator('iframe')).toHaveCount(0)
|
||||
|
||||
// Check that onerror attribute is removed
|
||||
const images = helpPage.locator('img')
|
||||
const imageCount = await images.count()
|
||||
for (let i = 0; i < imageCount; i++) {
|
||||
const img = images.nth(i)
|
||||
const onError = await img.getAttribute('onerror')
|
||||
expect(onError).toBeNull()
|
||||
}
|
||||
|
||||
// Check that javascript: links are sanitized
|
||||
const links = helpPage.locator('a')
|
||||
const linkCount = await links.count()
|
||||
for (let i = 0; i < linkCount; i++) {
|
||||
const link = links.nth(i)
|
||||
const href = await link.getAttribute('href')
|
||||
if (href !== null) {
|
||||
expect(href).not.toContain('javascript:')
|
||||
}
|
||||
}
|
||||
|
||||
// Safe content should remain
|
||||
await expect(helpPage.locator('video[src*="safe.mp4"]')).toBeVisible()
|
||||
await expect(helpPage.locator('img[alt="Safe Image"]')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Should handle locale-specific documentation', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different responses for different locales
|
||||
await comfyPage.page.route('**/docs/KSampler/ja.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSamplerノード
|
||||
|
||||
これは日本語のドキュメントです。
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: `# KSampler Node
|
||||
|
||||
This is English documentation.
|
||||
`
|
||||
})
|
||||
})
|
||||
|
||||
// Set locale to Japanese
|
||||
await comfyPage.setSetting('Comfy.Locale', 'ja')
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSamplerノード')
|
||||
await expect(helpPage).toContainText('これは日本語のドキュメントです')
|
||||
|
||||
// Reset locale
|
||||
await comfyPage.setSetting('Comfy.Locale', 'en')
|
||||
})
|
||||
|
||||
test('Should handle network errors gracefully', async ({ comfyPage }) => {
|
||||
// Mock network error
|
||||
await comfyPage.page.route('**/docs/**/*.md', async (route) => {
|
||||
await route.abort('failed')
|
||||
})
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
|
||||
// Should show fallback content (node description)
|
||||
await expect(helpPage).toBeVisible()
|
||||
await expect(helpPage.locator('.p-progressspinner')).not.toBeVisible()
|
||||
|
||||
// Should show some content even on error
|
||||
const content = await helpPage.textContent()
|
||||
expect(content).toBeTruthy()
|
||||
})
|
||||
|
||||
test('Should update help content when switching between nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// Mock different help content for different nodes
|
||||
await comfyPage.page.route('**/docs/KSampler/en.md', async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '# KSampler Help\n\nThis is KSampler documentation.'
|
||||
})
|
||||
})
|
||||
|
||||
await comfyPage.page.route(
|
||||
'**/docs/CheckpointLoaderSimple/en.md',
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
body: '# Checkpoint Loader Help\n\nThis is Checkpoint Loader documentation.'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
await comfyPage.loadWorkflow('default')
|
||||
|
||||
// Select KSampler first
|
||||
const ksamplerNodes = await comfyPage.getNodeRefsByType('KSampler')
|
||||
await selectNodeWithPan(comfyPage, ksamplerNodes[0])
|
||||
|
||||
const helpButton = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton.click()
|
||||
|
||||
const helpPage = comfyPage.page.locator('.sidebar-content-container')
|
||||
await expect(helpPage).toContainText('KSampler Help')
|
||||
await expect(helpPage).toContainText('This is KSampler documentation')
|
||||
|
||||
// Now select Checkpoint Loader
|
||||
const checkpointNodes = await comfyPage.getNodeRefsByType(
|
||||
'CheckpointLoaderSimple'
|
||||
)
|
||||
await selectNodeWithPan(comfyPage, checkpointNodes[0])
|
||||
|
||||
// Click help button again
|
||||
const helpButton2 = comfyPage.page.locator(
|
||||
'.selection-toolbox button:has(.pi-question-circle)'
|
||||
)
|
||||
await helpButton2.click()
|
||||
|
||||
// Content should update
|
||||
await expect(helpPage).toContainText('Checkpoint Loader Help')
|
||||
await expect(helpPage).toContainText(
|
||||
'This is Checkpoint Loader documentation'
|
||||
)
|
||||
await expect(helpPage).not.toContainText('KSampler documentation')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 101 KiB |
|
Before Width: | Height: | Size: 191 KiB After Width: | Height: | Size: 190 KiB |
|
Before Width: | Height: | Size: 117 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 239 KiB After Width: | Height: | Size: 238 KiB |
|
Before Width: | Height: | Size: 79 KiB After Width: | Height: | Size: 78 KiB |
59
build/plugins/addElementVnodeExportPlugin.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
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,24 +1,9 @@
|
||||
import glob from 'fast-glob'
|
||||
import fs from 'fs-extra'
|
||||
import { dirname, join } from 'node:path'
|
||||
import { HtmlTagDescriptor, Plugin, normalizePath } from 'vite'
|
||||
import type { OutputOptions } from 'rollup'
|
||||
import { HtmlTagDescriptor, Plugin } from 'vite'
|
||||
|
||||
interface ImportMapSource {
|
||||
interface VendorLibrary {
|
||||
name: string
|
||||
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 []
|
||||
pattern: RegExp
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,89 +23,53 @@ const parseDeps = (root: string, pkg: string) => {
|
||||
* @returns {Plugin} A Vite plugin that generates and injects an import map
|
||||
*/
|
||||
export function generateImportMapPlugin(
|
||||
importMapSources: ImportMapSource[]
|
||||
vendorLibraries: VendorLibrary[]
|
||||
): 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 = {}
|
||||
}
|
||||
|
||||
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)
|
||||
const outputOptions: OutputOptions = {
|
||||
manualChunks: (id: string) => {
|
||||
for (const lib of vendorLibraries) {
|
||||
if (lib.pattern.test(id)) {
|
||||
return `vendor-${lib.name}`
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
},
|
||||
// Disable minification of internal exports to preserve function names
|
||||
minifyInternalExports: false
|
||||
}
|
||||
|
||||
const external: (string | RegExp)[] = []
|
||||
for (const [, source] of resolvedImportMapSources) {
|
||||
external.push(source.pattern)
|
||||
}
|
||||
config.build.rollupOptions.external = external
|
||||
config.build.rollupOptions.output = outputOptions
|
||||
}
|
||||
},
|
||||
|
||||
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)
|
||||
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}`
|
||||
)
|
||||
|
||||
importMapEntries[source.name] =
|
||||
'./' + normalizePath(join(assetDir, moduleFile))
|
||||
if (vendorLib) {
|
||||
const relativePath = `./${chunk.fileName.replace(/\\/g, '/')}`
|
||||
importMapEntries[vendorLib.name] = 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)
|
||||
console.log(
|
||||
`[ImportMap Plugin] Found chunk: ${chunk.name} -> Mapped '${vendorLib.name}' to '${relativePath}'`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
export { addElementVnodeExportPlugin } from './addElementVnodeExportPlugin'
|
||||
export { comfyAPIPlugin } from './comfyAPIPlugin'
|
||||
export { generateImportMapPlugin } from './generateImportMapPlugin'
|
||||
|
||||
1891
package-lock.json
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.21.3",
|
||||
"version": "1.22.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,12 +29,12 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@executeautomation/playwright-mcp-server": "^1.0.5",
|
||||
"@iconify/json": "^2.2.245",
|
||||
"@lobehub/i18n-cli": "^1.20.0",
|
||||
"@pinia/testing": "^0.1.5",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^20.14.8",
|
||||
@@ -75,7 +75,7 @@
|
||||
"@alloc/quick-lru": "^5.2.0",
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "^0.4.43",
|
||||
"@comfyorg/litegraph": "^0.15.14",
|
||||
"@comfyorg/litegraph": "^0.15.15",
|
||||
"@primevue/forms": "^4.2.5",
|
||||
"@primevue/themes": "^4.2.5",
|
||||
"@sentry/vue": "^8.48.0",
|
||||
@@ -92,12 +92,14 @@
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"algoliasearch": "^5.21.0",
|
||||
"axios": "^1.8.2",
|
||||
"dompurify": "^3.2.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"firebase": "^11.6.0",
|
||||
"fuse.js": "^7.0.0",
|
||||
"jsondiffpatch": "^0.6.0",
|
||||
"lodash": "^4.17.21",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"pinia": "^2.1.7",
|
||||
"primeicons": "^7.0.0",
|
||||
"primevue": "^4.2.5",
|
||||
|
||||
@@ -408,19 +408,30 @@ const handleGridContainerClick = (event: MouseEvent) => {
|
||||
|
||||
const hasMultipleSelections = computed(() => selectedNodePacks.value.length > 1)
|
||||
|
||||
// Track the last pack ID for which we've fetched full registry data
|
||||
const lastFetchedPackId = ref<string | null>(null)
|
||||
|
||||
// Whenever a single pack is selected, fetch its full info once
|
||||
whenever(selectedNodePack, async () => {
|
||||
// Cancel any in-flight requests from previously selected node pack
|
||||
getPackById.cancel()
|
||||
|
||||
if (!selectedNodePack.value?.id) return
|
||||
|
||||
// If only a single node pack is selected, fetch full node pack info from registry
|
||||
const pack = selectedNodePack.value
|
||||
if (!pack?.id) return
|
||||
if (hasMultipleSelections.value) return
|
||||
const data = await getPackById.call(selectedNodePack.value.id)
|
||||
|
||||
if (data?.id === selectedNodePack.value?.id) {
|
||||
// If selected node hasn't changed since request, merge registry & Algolia data
|
||||
selectedNodePacks.value = [merge(selectedNodePack.value, data)]
|
||||
// Only fetch if we haven't already for this pack
|
||||
if (lastFetchedPackId.value === pack.id) return
|
||||
const data = await getPackById.call(pack.id)
|
||||
// If selected node hasn't changed since request, merge registry & Algolia data
|
||||
if (data?.id === pack.id) {
|
||||
lastFetchedPackId.value = pack.id
|
||||
const mergedPack = merge({}, pack, data)
|
||||
selectedNodePacks.value = [mergedPack]
|
||||
// Replace pack in displayPacks so that children receive a fresh prop reference
|
||||
const idx = displayPacks.value.findIndex((p) => p.id === mergedPack.id)
|
||||
if (idx !== -1) {
|
||||
displayPacks.value.splice(idx, 1, mergedPack)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<template>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
class="object-cover"
|
||||
:style="{ width: cssWidth, height: cssHeight }"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const DEFAULT_BANNER = '/assets/images/fallback-gradient-avatar.svg'
|
||||
|
||||
const {
|
||||
nodePack,
|
||||
width = '100%',
|
||||
height = '12rem'
|
||||
} = defineProps<{
|
||||
nodePack: components['schemas']['Node'] & { banner?: string } // Temporary measure until banner is in backend
|
||||
width?: string
|
||||
height?: string
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
const shouldShowFallback = computed(
|
||||
() => !nodePack.banner || nodePack.banner.trim() === '' || isImageError.value
|
||||
)
|
||||
const imgSrc = computed(() =>
|
||||
shouldShowFallback.value ? DEFAULT_BANNER : nodePack.banner
|
||||
)
|
||||
|
||||
const convertToCssValue = (value: string | number) =>
|
||||
typeof value === 'number' ? `${value}rem` : value
|
||||
|
||||
const cssWidth = computed(() => convertToCssValue(width))
|
||||
const cssHeight = computed(() => convertToCssValue(height))
|
||||
</script>
|
||||
@@ -7,19 +7,15 @@
|
||||
}"
|
||||
:pt="{
|
||||
body: { class: 'p-0 flex flex-col w-full h-full rounded-2xl gap-0' },
|
||||
content: { class: 'flex-1 flex flex-col rounded-2xl' },
|
||||
title: {
|
||||
class:
|
||||
'self-stretch w-full px-4 py-3 inline-flex justify-start items-center gap-6'
|
||||
},
|
||||
content: { class: 'flex-1 flex flex-col rounded-2xl min-h-0' },
|
||||
title: { class: 'w-full h-full rounded-t-lg cursor-pointer' },
|
||||
footer: { class: 'p-0 m-0' }
|
||||
}"
|
||||
>
|
||||
<template #title>
|
||||
<PackCardHeader :node-pack="nodePack" />
|
||||
<PackBanner :node-pack="nodePack" />
|
||||
</template>
|
||||
<template #content>
|
||||
<ContentDivider />
|
||||
<template v-if="isInstalling">
|
||||
<div
|
||||
class="self-stretch inline-flex flex-col justify-center items-center gap-2 h-full"
|
||||
@@ -34,46 +30,63 @@
|
||||
</template>
|
||||
<template v-else>
|
||||
<div
|
||||
class="self-stretch px-4 py-3 inline-flex justify-start items-start cursor-pointer"
|
||||
class="self-stretch inline-flex flex-col justify-start items-start"
|
||||
>
|
||||
<PackIcon :node-pack="nodePack" />
|
||||
<div
|
||||
class="px-4 inline-flex flex-col justify-start items-start overflow-hidden"
|
||||
class="px-4 py-3 inline-flex justify-start items-start cursor-pointer w-full"
|
||||
>
|
||||
<span
|
||||
class="text-sm font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<div
|
||||
class="self-stretch inline-flex justify-center items-center gap-2.5"
|
||||
class="inline-flex flex-col justify-start items-start overflow-hidden gap-y-3 w-full"
|
||||
>
|
||||
<span
|
||||
class="text-base font-bold truncate overflow-hidden text-ellipsis"
|
||||
>
|
||||
{{ nodePack.name }}
|
||||
</span>
|
||||
<p
|
||||
v-if="nodePack.description"
|
||||
class="flex-1 justify-start text-muted text-sm font-medium leading-3 break-words overflow-hidden min-h-12 line-clamp-3"
|
||||
class="flex-1 justify-start text-muted text-sm font-medium break-words overflow-hidden min-h-12 line-clamp-3 my-0 leading-5"
|
||||
>
|
||||
{{ nodePack.description }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="self-stretch inline-flex justify-start items-center gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="nodesCount"
|
||||
class="px-2 py-1 flex justify-center text-sm items-center gap-1"
|
||||
>
|
||||
<div class="text-center justify-center font-medium leading-3">
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-1 flex justify-center items-center gap-1">
|
||||
<div class="flex flex-col gap-y-2">
|
||||
<div
|
||||
v-if="isUpdateAvailable"
|
||||
class="w-4 h-4 relative overflow-hidden"
|
||||
class="self-stretch inline-flex justify-start items-center gap-1"
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
<div
|
||||
v-if="nodesCount"
|
||||
class="pr-2 py-1 flex justify-center text-sm items-center gap-1"
|
||||
>
|
||||
<div
|
||||
class="text-center justify-center font-medium leading-3"
|
||||
>
|
||||
{{ nodesCount }} {{ $t('g.nodes') }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-2 py-1 flex justify-center items-center gap-1">
|
||||
<div
|
||||
v-if="isUpdateAvailable"
|
||||
class="w-4 h-4 relative overflow-hidden"
|
||||
>
|
||||
<i class="pi pi-arrow-circle-up text-blue-600" />
|
||||
</div>
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</div>
|
||||
<div
|
||||
v-if="formattedLatestVersionDate"
|
||||
class="px-2 py-1 flex justify-center items-center gap-1 text-xs text-muted font-medium"
|
||||
>
|
||||
{{ formattedLatestVersionDate }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<span
|
||||
v-if="publisherName"
|
||||
class="text-xs text-muted font-medium leading-3 max-w-40 truncate"
|
||||
>
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
<PackVersionBadge :node-pack="nodePack" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -92,11 +105,12 @@ import { whenever } from '@vueuse/core'
|
||||
import Card from 'primevue/card'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, provide, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ContentDivider from '@/components/common/ContentDivider.vue'
|
||||
import PackVersionBadge from '@/components/dialog/content/manager/PackVersionBadge.vue'
|
||||
import PackBanner from '@/components/dialog/content/manager/packBanner/PackBanner.vue'
|
||||
import PackCardFooter from '@/components/dialog/content/manager/packCard/PackCardFooter.vue'
|
||||
import PackIcon from '@/components/dialog/content/manager/packIcon/PackIcon.vue'
|
||||
import { usePackUpdateStatus } from '@/composables/nodePack/usePackUpdateStatus'
|
||||
import { useComfyManagerStore } from '@/stores/comfyManagerStore'
|
||||
import { IsInstallingKey } from '@/types/comfyManagerTypes'
|
||||
@@ -107,6 +121,8 @@ const { nodePack, isSelected = false } = defineProps<{
|
||||
isSelected?: boolean
|
||||
}>()
|
||||
|
||||
const { d } = useI18n()
|
||||
|
||||
const isInstalling = ref(false)
|
||||
provide(IsInstallingKey, isInstalling)
|
||||
|
||||
@@ -122,4 +138,19 @@ whenever(isInstalled, () => (isInstalling.value = false))
|
||||
|
||||
// TODO: remove type assertion once comfy_nodes is added to node (pack) info type in backend
|
||||
const nodesCount = computed(() => (nodePack as any).comfy_nodes?.length)
|
||||
|
||||
const publisherName = computed(() => {
|
||||
if (!nodePack) return null
|
||||
|
||||
const { publisher, author } = nodePack
|
||||
return publisher?.name ?? publisher?.id ?? author
|
||||
})
|
||||
|
||||
const formattedLatestVersionDate = computed(() => {
|
||||
if (!nodePack.latest_version?.createdAt) return null
|
||||
|
||||
return d(new Date(nodePack.latest_version.createdAt), {
|
||||
dateStyle: 'medium'
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -1,39 +1,29 @@
|
||||
<template>
|
||||
<div
|
||||
class="flex justify-between px-5 py-4 text-xs text-muted font-medium leading-3"
|
||||
class="flex justify-between items-center px-4 py-2 text-xs text-muted font-medium leading-3"
|
||||
>
|
||||
<div class="flex items-center gap-2 cursor-pointer">
|
||||
<span v-if="publisherName" class="max-w-40 truncate">
|
||||
{{ publisherName }}
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="nodePack.latest_version?.createdAt"
|
||||
class="flex items-center gap-2 truncate"
|
||||
>
|
||||
{{ $t('g.updated') }}
|
||||
{{
|
||||
$d(new Date(nodePack.latest_version.createdAt), {
|
||||
dateStyle: 'medium'
|
||||
})
|
||||
}}
|
||||
<div v-if="nodePack.downloads" class="flex items-center gap-1.5">
|
||||
<i class="pi pi-download text-muted"></i>
|
||||
<span>{{ formattedDownloads }}</span>
|
||||
</div>
|
||||
<PackInstallButton :node-packs="[nodePack]" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import PackInstallButton from '@/components/dialog/content/manager/button/PackInstallButton.vue'
|
||||
import type { components } from '@/types/comfyRegistryTypes'
|
||||
|
||||
const { nodePack } = defineProps<{
|
||||
nodePack: components['schemas']['Node']
|
||||
}>()
|
||||
|
||||
const publisherName = computed(() => {
|
||||
if (!nodePack) return null
|
||||
const { n } = useI18n()
|
||||
|
||||
const { publisher, author } = nodePack
|
||||
return publisher?.name ?? publisher?.id ?? author
|
||||
})
|
||||
const formattedDownloads = computed(() =>
|
||||
nodePack.downloads ? n(nodePack.downloads) : ''
|
||||
)
|
||||
</script>
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
:key="command.id"
|
||||
:command="command"
|
||||
/>
|
||||
<HelpButton />
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
@@ -30,6 +31,7 @@ import ColorPickerButton from '@/components/graph/selectionToolbox/ColorPickerBu
|
||||
import DeleteButton from '@/components/graph/selectionToolbox/DeleteButton.vue'
|
||||
import ExecuteButton from '@/components/graph/selectionToolbox/ExecuteButton.vue'
|
||||
import ExtensionCommandButton from '@/components/graph/selectionToolbox/ExtensionCommandButton.vue'
|
||||
import HelpButton from '@/components/graph/selectionToolbox/HelpButton.vue'
|
||||
import MaskEditorButton from '@/components/graph/selectionToolbox/MaskEditorButton.vue'
|
||||
import PinButton from '@/components/graph/selectionToolbox/PinButton.vue'
|
||||
import RefreshButton from '@/components/graph/selectionToolbox/RefreshButton.vue'
|
||||
|
||||
49
src/components/graph/selectionToolbox/HelpButton.vue
Normal file
@@ -0,0 +1,49 @@
|
||||
<template>
|
||||
<Button
|
||||
v-show="nodeDef"
|
||||
v-tooltip.top="{
|
||||
value: $t('g.help'),
|
||||
showDelay: 1000
|
||||
}"
|
||||
class="help-button"
|
||||
text
|
||||
icon="pi pi-question-circle"
|
||||
severity="secondary"
|
||||
@click="showHelp"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from 'primevue/button'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useNodeLibrarySidebarTab } from '@/composables/sidebarTabs/useNodeLibrarySidebarTab'
|
||||
import { useCanvasStore } from '@/stores/graphStore'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import { useSidebarTabStore } from '@/stores/workspace/sidebarTabStore'
|
||||
import { isLGraphNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const canvasStore = useCanvasStore()
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const sidebarTabStore = useSidebarTabStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { id: nodeLibraryTabId } = useNodeLibrarySidebarTab()
|
||||
|
||||
const nodeDef = computed<ComfyNodeDefImpl | null>(() => {
|
||||
if (canvasStore.selectedItems.length !== 1) return null
|
||||
const item = canvasStore.selectedItems[0]
|
||||
if (!isLGraphNode(item)) return null
|
||||
return nodeDefStore.fromLGraphNode(item)
|
||||
})
|
||||
|
||||
const showHelp = () => {
|
||||
const def = nodeDef.value
|
||||
if (!def) return
|
||||
if (sidebarTabStore.activeSidebarTabId !== nodeLibraryTabId) {
|
||||
sidebarTabStore.toggleSidebarTab(nodeLibraryTabId)
|
||||
}
|
||||
nodeHelpStore.openHelp(def)
|
||||
}
|
||||
</script>
|
||||
@@ -1,113 +1,124 @@
|
||||
<template>
|
||||
<SidebarTabTemplate
|
||||
:title="$t('sideToolbar.nodeLibrary')"
|
||||
class="bg-[var(--p-tree-background)]"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.newFolder')"
|
||||
class="new-folder-button"
|
||||
icon="pi pi-folder-plus"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupBy')"
|
||||
:icon="selectedGroupingIcon"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="groupingPopover?.toggle($event)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortMode')"
|
||||
:icon="selectedSortingIcon"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="sortingPopover?.toggle($event)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="resetOrganization"
|
||||
/>
|
||||
<Popover ref="groupingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
v-for="option in groupingOptions"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
:label="$t(option.label)"
|
||||
text
|
||||
:severity="
|
||||
selectedGroupingId === option.id ? 'primary' : 'secondary'
|
||||
"
|
||||
class="justify-start"
|
||||
@click="selectGrouping(option.id)"
|
||||
<div class="h-full">
|
||||
<SidebarTabTemplate
|
||||
v-if="!isHelpOpen"
|
||||
:title="$t('sideToolbar.nodeLibrary')"
|
||||
class="bg-[var(--p-tree-background)]"
|
||||
>
|
||||
<template #tool-buttons>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.newFolder')"
|
||||
class="new-folder-button"
|
||||
icon="pi pi-folder-plus"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="nodeBookmarkTreeExplorerRef?.addNewBookmarkFolder()"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.groupBy')"
|
||||
:icon="selectedGroupingIcon"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="groupingPopover?.toggle($event)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.sortMode')"
|
||||
:icon="selectedSortingIcon"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="sortingPopover?.toggle($event)"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('sideToolbar.nodeLibraryTab.resetView')"
|
||||
icon="pi pi-refresh"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="resetOrganization"
|
||||
/>
|
||||
<Popover ref="groupingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
v-for="option in groupingOptions"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
:label="$t(option.label)"
|
||||
text
|
||||
:severity="
|
||||
selectedGroupingId === option.id ? 'primary' : 'secondary'
|
||||
"
|
||||
class="justify-start"
|
||||
@click="selectGrouping(option.id)"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover ref="sortingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
v-for="option in sortingOptions"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
:label="$t(option.label)"
|
||||
text
|
||||
:severity="
|
||||
selectedSortingId === option.id ? 'primary' : 'secondary'
|
||||
"
|
||||
class="justify-start"
|
||||
@click="selectSorting(option.id)"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
<template #header>
|
||||
<div>
|
||||
<SearchBox
|
||||
v-model:modelValue="searchQuery"
|
||||
class="node-lib-search-box p-2 2xl:p-4"
|
||||
:placeholder="$t('g.searchNodes') + '...'"
|
||||
filter-icon="pi pi-filter"
|
||||
:filters
|
||||
@search="handleSearch"
|
||||
@show-filter="($event) => searchFilter?.toggle($event)"
|
||||
@remove-filter="onRemoveFilter"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
<Popover ref="sortingPopover">
|
||||
<div class="flex flex-col gap-1 p-2">
|
||||
<Button
|
||||
v-for="option in sortingOptions"
|
||||
:key="option.id"
|
||||
:icon="option.icon"
|
||||
:label="$t(option.label)"
|
||||
text
|
||||
:severity="
|
||||
selectedSortingId === option.id ? 'primary' : 'secondary'
|
||||
"
|
||||
class="justify-start"
|
||||
@click="selectSorting(option.id)"
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</template>
|
||||
<template #header>
|
||||
<SearchBox
|
||||
v-model:modelValue="searchQuery"
|
||||
class="node-lib-search-box p-2 2xl:p-4"
|
||||
:placeholder="$t('g.searchNodes') + '...'"
|
||||
filter-icon="pi pi-filter"
|
||||
:filters
|
||||
@search="handleSearch"
|
||||
@show-filter="($event) => searchFilter?.toggle($event)"
|
||||
@remove-filter="onRemoveFilter"
|
||||
/>
|
||||
|
||||
<Popover ref="searchFilter" class="ml-[-13px]">
|
||||
<NodeSearchFilter @add-filter="onAddFilter" />
|
||||
</Popover>
|
||||
</template>
|
||||
<template #body>
|
||||
<NodeBookmarkTreeExplorer
|
||||
ref="nodeBookmarkTreeExplorerRef"
|
||||
:filtered-node-defs="filteredNodeDefs"
|
||||
/>
|
||||
<Divider
|
||||
v-show="nodeBookmarkStore.bookmarks.length > 0"
|
||||
type="dashed"
|
||||
class="m-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
class="node-lib-tree-explorer"
|
||||
:root="renderedRoot"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<NodeTreeLeaf :node="node" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
<Popover ref="searchFilter" class="ml-[-13px]">
|
||||
<NodeSearchFilter @add-filter="onAddFilter" />
|
||||
</Popover>
|
||||
</div>
|
||||
</template>
|
||||
<template #body>
|
||||
<div>
|
||||
<NodeBookmarkTreeExplorer
|
||||
ref="nodeBookmarkTreeExplorerRef"
|
||||
:filtered-node-defs="filteredNodeDefs"
|
||||
:open-node-help="openHelp"
|
||||
/>
|
||||
<Divider
|
||||
v-show="nodeBookmarkStore.bookmarks.length > 0"
|
||||
type="dashed"
|
||||
class="m-2"
|
||||
/>
|
||||
<TreeExplorer
|
||||
v-model:expandedKeys="expandedKeys"
|
||||
class="node-lib-tree-explorer"
|
||||
:root="renderedRoot"
|
||||
>
|
||||
<template #node="{ node }">
|
||||
<NodeTreeLeaf :node="node" :open-node-help="openHelp" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
</div>
|
||||
</template>
|
||||
</SidebarTabTemplate>
|
||||
|
||||
<NodeHelpPage v-else :node="currentHelpNode!" @close="closeHelp" />
|
||||
</div>
|
||||
<div id="node-library-node-preview-container" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import Popover from 'primevue/popover'
|
||||
@@ -119,6 +130,7 @@ import TreeExplorer from '@/components/common/TreeExplorer.vue'
|
||||
import NodePreview from '@/components/node/NodePreview.vue'
|
||||
import NodeSearchFilter from '@/components/searchbox/NodeSearchFilter.vue'
|
||||
import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue'
|
||||
import NodeHelpPage from '@/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue'
|
||||
import NodeTreeLeaf from '@/components/sidebar/tabs/nodeLibrary/NodeTreeLeaf.vue'
|
||||
import { useTreeExpansion } from '@/composables/useTreeExpansion'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -129,6 +141,7 @@ import {
|
||||
} from '@/services/nodeOrganizationService'
|
||||
import { useNodeBookmarkStore } from '@/stores/nodeBookmarkStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
import type {
|
||||
GroupingStrategyId,
|
||||
SortingStrategyId
|
||||
@@ -141,6 +154,7 @@ import NodeBookmarkTreeExplorer from './nodeLibrary/NodeBookmarkTreeExplorer.vue
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
const nodeBookmarkStore = useNodeBookmarkStore()
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
const { expandNode, toggleNodeOnEvent } = useTreeExpansion(expandedKeys)
|
||||
|
||||
@@ -161,6 +175,9 @@ const selectedSortingId = useLocalStorage<SortingStrategyId>(
|
||||
|
||||
const searchQuery = ref<string>('')
|
||||
|
||||
const { currentHelpNode, isHelpOpen } = storeToRefs(nodeHelpStore)
|
||||
const { openHelp, closeHelp } = nodeHelpStore
|
||||
|
||||
const groupingOptions = computed(() =>
|
||||
nodeOrganizationService.getGroupingStrategies().map((strategy) => ({
|
||||
id: strategy.id,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<NodeTreeFolder :node="node" />
|
||||
</template>
|
||||
<template #node="{ node }">
|
||||
<NodeTreeLeaf :node="node" />
|
||||
<NodeTreeLeaf :node="node" :open-node-help="props.openNodeHelp" />
|
||||
</template>
|
||||
</TreeExplorer>
|
||||
|
||||
@@ -43,6 +43,7 @@ import type {
|
||||
|
||||
const props = defineProps<{
|
||||
filteredNodeDefs: ComfyNodeDefImpl[]
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
}>()
|
||||
|
||||
const expandedKeys = ref<Record<string, boolean>>({})
|
||||
|
||||
230
src/components/sidebar/tabs/nodeLibrary/NodeHelpPage.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div class="flex flex-col h-full bg-[var(--p-tree-background)] overflow-auto">
|
||||
<div
|
||||
class="px-3 py-2 flex items-center border-b border-[var(--p-divider-color)]"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.back')"
|
||||
icon="pi pi-arrow-left"
|
||||
text
|
||||
severity="secondary"
|
||||
@click="$emit('close')"
|
||||
/>
|
||||
<span class="ml-2 font-semibold">{{ node.display_name }}</span>
|
||||
</div>
|
||||
<div class="p-4 flex-grow node-help-content max-w-[600px] mx-auto">
|
||||
<ProgressSpinner
|
||||
v-if="isLoading"
|
||||
class="m-auto"
|
||||
aria-label="Loading help"
|
||||
/>
|
||||
<!-- Markdown fetched successfully -->
|
||||
<div
|
||||
v-else-if="!error"
|
||||
class="markdown-content"
|
||||
v-html="renderedHelpHtml"
|
||||
/>
|
||||
<!-- Fallback: markdown not found or fetch error -->
|
||||
<div v-else class="text-sm space-y-6 fallback-content">
|
||||
<p v-if="node.description">
|
||||
<strong>{{ $t('g.description') }}:</strong> {{ node.description }}
|
||||
</p>
|
||||
|
||||
<div v-if="inputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.inputs') }}:</strong>
|
||||
</p>
|
||||
<!-- Using plain HTML table instead of DataTable for consistent styling with markdown content -->
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="input in inputList" :key="input.name">
|
||||
<td>
|
||||
<code>{{ input.name }}</code>
|
||||
</td>
|
||||
<td>{{ input.type }}</td>
|
||||
<td>{{ input.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div v-if="outputList.length">
|
||||
<p>
|
||||
<strong>{{ $t('nodeHelpPage.outputs') }}:</strong>
|
||||
</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('g.name') }}</th>
|
||||
<th>{{ $t('nodeHelpPage.type') }}</th>
|
||||
<th>{{ $t('g.description') }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="output in outputList" :key="output.name">
|
||||
<td>
|
||||
<code>{{ output.name }}</code>
|
||||
</td>
|
||||
<td>{{ output.type }}</td>
|
||||
<td>{{ output.tooltip || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import Button from 'primevue/button'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
const { node } = defineProps<{ node: ComfyNodeDefImpl }>()
|
||||
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
const { renderedHelpHtml, isLoading, error } = storeToRefs(nodeHelpStore)
|
||||
|
||||
defineEmits<{
|
||||
(e: 'close'): void
|
||||
}>()
|
||||
|
||||
const inputList = computed(() =>
|
||||
Object.values(node.inputs).map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
|
||||
const outputList = computed(() =>
|
||||
node.outputs.map((spec) => ({
|
||||
name: spec.name,
|
||||
type: spec.type,
|
||||
tooltip: spec.tooltip || ''
|
||||
}))
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped lang="postcss">
|
||||
.node-help-content :deep(:is(img, video)) {
|
||||
@apply max-w-full h-auto block mb-4;
|
||||
}
|
||||
|
||||
.markdown-content,
|
||||
.fallback-content {
|
||||
@apply text-sm;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h1),
|
||||
.fallback-content h1 {
|
||||
@apply text-[22px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h2),
|
||||
.fallback-content h2 {
|
||||
@apply text-[18px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h3),
|
||||
.fallback-content h3 {
|
||||
@apply text-[16px] font-bold mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(h4),
|
||||
.markdown-content :deep(h5),
|
||||
.markdown-content :deep(h6),
|
||||
.fallback-content h4,
|
||||
.fallback-content h5,
|
||||
.fallback-content h6 {
|
||||
@apply mt-8 mb-4 first:mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content td {
|
||||
color: var(--drag-text);
|
||||
}
|
||||
|
||||
.markdown-content :deep(a),
|
||||
.fallback-content a {
|
||||
color: var(--drag-text);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.fallback-content th {
|
||||
color: var(--fg-color);
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul),
|
||||
.markdown-content :deep(ol),
|
||||
.fallback-content ul,
|
||||
.fallback-content ol {
|
||||
@apply pl-8 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(ul ul),
|
||||
.markdown-content :deep(ol ol),
|
||||
.markdown-content :deep(ul ol),
|
||||
.markdown-content :deep(ol ul),
|
||||
.fallback-content ul ul,
|
||||
.fallback-content ol ol,
|
||||
.fallback-content ul ol,
|
||||
.fallback-content ol ul {
|
||||
@apply pl-6 my-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(li),
|
||||
.fallback-content li {
|
||||
@apply my-1;
|
||||
}
|
||||
|
||||
.markdown-content :deep(*:first-child),
|
||||
.fallback-content > *:first-child {
|
||||
@apply mt-0;
|
||||
}
|
||||
|
||||
.markdown-content :deep(code),
|
||||
.fallback-content code {
|
||||
@apply text-[var(--error-text)] bg-[var(--content-bg)] rounded px-1 py-0.5;
|
||||
}
|
||||
|
||||
.markdown-content :deep(table),
|
||||
.fallback-content table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
|
||||
.markdown-content :deep(th),
|
||||
.markdown-content :deep(td),
|
||||
.fallback-content th,
|
||||
.fallback-content td {
|
||||
@apply px-2 py-2;
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr),
|
||||
.fallback-content tr {
|
||||
border-bottom: 1px solid var(--content-bg);
|
||||
}
|
||||
|
||||
.markdown-content :deep(tr:last-child),
|
||||
.fallback-content tr:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.markdown-content :deep(thead),
|
||||
.fallback-content thead {
|
||||
border-bottom: 1px solid var(--p-text-color);
|
||||
}
|
||||
</style>
|
||||
@@ -22,6 +22,15 @@
|
||||
severity="secondary"
|
||||
@click.stop="toggleBookmark"
|
||||
/>
|
||||
<Button
|
||||
v-tooltip.bottom="$t('g.learnMore')"
|
||||
class="help-button"
|
||||
size="small"
|
||||
icon="pi pi-question"
|
||||
text
|
||||
severity="secondary"
|
||||
@click.stop="props.openNodeHelp(nodeDef)"
|
||||
/>
|
||||
</template>
|
||||
</TreeExplorerTreeNode>
|
||||
|
||||
@@ -54,6 +63,7 @@ import { RenderedTreeExplorerNode } from '@/types/treeExplorerTypes'
|
||||
|
||||
const props = defineProps<{
|
||||
node: RenderedTreeExplorerNode<ComfyNodeDefImpl>
|
||||
openNodeHelp: (nodeDef: ComfyNodeDefImpl) => void
|
||||
}>()
|
||||
|
||||
// Note: node.data should be present for leaf nodes.
|
||||
|
||||
@@ -46,10 +46,68 @@ vi.mock('@vueuse/core', () => ({
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: (path: string) => `/fileURL${path}`,
|
||||
apiURL: (path: string) => `/apiURL${path}`
|
||||
apiURL: (path: string) => `/apiURL${path}`,
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: () => ({
|
||||
closeDialog: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/workflowTemplatesStore', () => ({
|
||||
useWorkflowTemplatesStore: () => ({
|
||||
isLoaded: true,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
groupedTemplates: []
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: (key: string, fallback: string) => fallback || key
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useTemplateWorkflows', () => ({
|
||||
useTemplateWorkflows: () => ({
|
||||
getTemplateThumbnailUrl: (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? `/fileURL/templates/${template.name}`
|
||||
: `/apiURL/workflow_templates/${sourceModule}/${template.name}`
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
},
|
||||
getTemplateTitle: (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
},
|
||||
getTemplateDescription: (template: TemplateInfo, sourceModule: string) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
},
|
||||
loadWorkflowTemplate: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
describe('TemplateWorkflowCard', () => {
|
||||
const createTemplate = (overrides = {}): TemplateInfo => ({
|
||||
name: 'test-template',
|
||||
|
||||
@@ -86,7 +86,7 @@ import AudioThumbnail from '@/components/templates/thumbnails/AudioThumbnail.vue
|
||||
import CompareSliderThumbnail from '@/components/templates/thumbnails/CompareSliderThumbnail.vue'
|
||||
import DefaultThumbnail from '@/components/templates/thumbnails/DefaultThumbnail.vue'
|
||||
import HoverDissolveThumbnail from '@/components/templates/thumbnails/HoverDissolveThumbnail.vue'
|
||||
import { api } from '@/scripts/api'
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const UPSCALE_ZOOM_SCALE = 16 // for upscale templates, exaggerate the hover zoom
|
||||
@@ -102,36 +102,36 @@ const { sourceModule, loading, template } = defineProps<{
|
||||
const cardRef = ref<HTMLElement | null>(null)
|
||||
const isHovered = useElementHover(cardRef)
|
||||
|
||||
const getThumbnailUrl = (index = '') => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
const { getTemplateThumbnailUrl, getTemplateTitle, getTemplateDescription } =
|
||||
useTemplateWorkflows()
|
||||
|
||||
// For templates from custom nodes, multiple images is not yet supported
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
// Determine the effective source module to use (from template or prop)
|
||||
const effectiveSourceModule = computed(
|
||||
() => template.sourceModule || sourceModule
|
||||
)
|
||||
|
||||
const baseThumbnailSrc = computed(() =>
|
||||
getThumbnailUrl(sourceModule === 'default' ? '1' : '')
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '1' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const overlayThumbnailSrc = computed(() =>
|
||||
getThumbnailUrl(sourceModule === 'default' ? '2' : '')
|
||||
getTemplateThumbnailUrl(
|
||||
template,
|
||||
effectiveSourceModule.value,
|
||||
effectiveSourceModule.value === 'default' ? '2' : ''
|
||||
)
|
||||
)
|
||||
|
||||
const description = computed(() => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description.replace(/[-_]/g, ' ').trim()
|
||||
})
|
||||
|
||||
const title = computed(() => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? ''
|
||||
: template.name
|
||||
})
|
||||
const description = computed(() =>
|
||||
getTemplateDescription(template, effectiveSourceModule.value)
|
||||
)
|
||||
const title = computed(() =>
|
||||
getTemplateTitle(template, effectiveSourceModule.value)
|
||||
)
|
||||
|
||||
defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
<template>
|
||||
<DataTable
|
||||
v-model:selection="selectedTemplate"
|
||||
:value="templates"
|
||||
:value="enrichedTemplates"
|
||||
striped-rows
|
||||
selection-mode="single"
|
||||
>
|
||||
<Column field="title" :header="$t('g.title')">
|
||||
<template #body="slotProps">
|
||||
<span :title="getTemplateTitle(slotProps.data)">{{
|
||||
getTemplateTitle(slotProps.data)
|
||||
}}</span>
|
||||
<span :title="slotProps.data.title">{{ slotProps.data.title }}</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="description" :header="$t('g.description')">
|
||||
<template #body="slotProps">
|
||||
<span :title="getTemplateDescription(slotProps.data)">
|
||||
{{ getTemplateDescription(slotProps.data) }}
|
||||
<span :title="slotProps.data.description">
|
||||
{{ slotProps.data.description }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
@@ -38,8 +36,9 @@
|
||||
import Button from 'primevue/button'
|
||||
import Column from 'primevue/column'
|
||||
import DataTable from 'primevue/datatable'
|
||||
import { ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import type { TemplateInfo } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const { sourceModule, loading, templates } = defineProps<{
|
||||
@@ -50,21 +49,20 @@ const { sourceModule, loading, templates } = defineProps<{
|
||||
}>()
|
||||
|
||||
const selectedTemplate = ref(null)
|
||||
const { getTemplateTitle, getTemplateDescription } = useTemplateWorkflows()
|
||||
|
||||
const enrichedTemplates = computed(() => {
|
||||
return templates.map((template) => {
|
||||
const actualSourceModule = template.sourceModule || sourceModule
|
||||
return {
|
||||
...template,
|
||||
title: getTemplateTitle(template, actualSourceModule),
|
||||
description: getTemplateDescription(template, actualSourceModule)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
loadWorkflow: [name: string]
|
||||
}>()
|
||||
|
||||
const getTemplateTitle = (template: TemplateInfo) => {
|
||||
const fallback = template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
const getTemplateDescription = (template: TemplateInfo) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description.replace(/[-_]/g, ' ').trim()
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -20,12 +20,12 @@
|
||||
class="absolute translate-x-0 top-0 left-0 h-full w-80 shadow-md z-5 transition-transform duration-300 ease-in-out"
|
||||
>
|
||||
<ProgressSpinner
|
||||
v-if="!workflowTemplatesStore.isLoaded || !isReady"
|
||||
v-if="!isTemplatesLoaded || !isReady"
|
||||
class="absolute w-8 h-full inset-0"
|
||||
/>
|
||||
<TemplateWorkflowsSideNav
|
||||
:tabs="tabs"
|
||||
:selected-tab="selectedTab"
|
||||
:tabs="allTemplateGroups"
|
||||
:selected-tab="selectedTemplate"
|
||||
@update:selected-tab="handleTabSelection"
|
||||
/>
|
||||
</aside>
|
||||
@@ -37,14 +37,14 @@
|
||||
}"
|
||||
>
|
||||
<TemplateWorkflowView
|
||||
v-if="isReady && selectedTab"
|
||||
v-if="isReady && selectedTemplate"
|
||||
class="px-12 py-4"
|
||||
:title="selectedTab.title"
|
||||
:source-module="selectedTab.moduleName"
|
||||
:templates="selectedTab.templates"
|
||||
:loading="workflowLoading"
|
||||
:category-title="selectedTab.title"
|
||||
@load-workflow="loadWorkflow"
|
||||
:title="selectedTemplate.title"
|
||||
:source-module="selectedTemplate.moduleName"
|
||||
:templates="selectedTemplate.templates"
|
||||
:loading="loadingTemplateId"
|
||||
:category-title="selectedTemplate.title"
|
||||
@load-workflow="handleLoadWorkflow"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,47 +56,46 @@ import { useAsyncState } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import ProgressSpinner from 'primevue/progressspinner'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { watch } from 'vue'
|
||||
|
||||
import TemplateWorkflowView from '@/components/templates/TemplateWorkflowView.vue'
|
||||
import TemplateWorkflowsSideNav from '@/components/templates/TemplateWorkflowsSideNav.vue'
|
||||
import { useResponsiveCollapse } from '@/composables/element/useResponsiveCollapse'
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import type { WorkflowTemplates } from '@/types/workflowTemplateTypes'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const {
|
||||
isSmallScreen,
|
||||
isOpen: isSideNavOpen,
|
||||
toggle: toggleSideNav
|
||||
} = useResponsiveCollapse()
|
||||
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const { isReady } = useAsyncState(
|
||||
workflowTemplatesStore.loadWorkflowTemplates,
|
||||
null
|
||||
const {
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
loadWorkflowTemplate
|
||||
} = useTemplateWorkflows()
|
||||
|
||||
const { isReady } = useAsyncState(loadTemplates, null)
|
||||
|
||||
watch(
|
||||
isReady,
|
||||
() => {
|
||||
if (isReady.value) {
|
||||
selectFirstTemplateCategory()
|
||||
}
|
||||
},
|
||||
{ once: true }
|
||||
)
|
||||
|
||||
const selectedTab = ref<WorkflowTemplates | null>(null)
|
||||
const selectFirstTab = () => {
|
||||
const firstTab = workflowTemplatesStore.groupedTemplates[0].modules[0]
|
||||
handleTabSelection(firstTab)
|
||||
}
|
||||
watch(isReady, selectFirstTab, { once: true })
|
||||
|
||||
const workflowLoading = ref<string | null>(null)
|
||||
|
||||
const tabs = computed(() => workflowTemplatesStore.groupedTemplates)
|
||||
|
||||
const handleTabSelection = (selection: WorkflowTemplates | null) => {
|
||||
//Listbox allows deselecting so this special case is ignored here
|
||||
if (selection !== selectedTab.value && selection !== null) {
|
||||
selectedTab.value = selection
|
||||
if (selection !== null) {
|
||||
selectTemplateCategory(selection)
|
||||
|
||||
// On small screens, close the sidebar when a category is selected
|
||||
if (isSmallScreen.value) {
|
||||
@@ -105,30 +104,9 @@ const handleTabSelection = (selection: WorkflowTemplates | null) => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadWorkflow = async (id: string) => {
|
||||
if (!isReady.value) return
|
||||
const handleLoadWorkflow = async (id: string) => {
|
||||
if (!isReady.value || !selectedTemplate.value) return false
|
||||
|
||||
workflowLoading.value = id
|
||||
let json
|
||||
if (selectedTab.value?.moduleName === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
json = await fetch(api.fileURL(`/templates/${id}.json`)).then((r) =>
|
||||
r.json()
|
||||
)
|
||||
} else {
|
||||
json = await fetch(
|
||||
api.apiURL(
|
||||
`/workflow_templates/${selectedTab.value?.moduleName}/${id}.json`
|
||||
)
|
||||
).then((r) => r.json())
|
||||
}
|
||||
useDialogStore().closeDialog()
|
||||
const workflowName =
|
||||
selectedTab.value?.moduleName === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return false
|
||||
return loadWorkflowTemplate(id, selectedTemplate.value.moduleName)
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -63,7 +63,7 @@ describe('CompareSliderThumbnail', () => {
|
||||
it('positions slider based on default value', () => {
|
||||
const wrapper = mountThumbnail()
|
||||
const divider = wrapper.find('.bg-white\\/30')
|
||||
expect(divider.attributes('style')).toContain('left: 21%')
|
||||
expect(divider.attributes('style')).toContain('left: 50%')
|
||||
})
|
||||
|
||||
it('passes isHovered prop to BaseThumbnail', () => {
|
||||
|
||||
@@ -38,7 +38,7 @@ import { ref, watch } from 'vue'
|
||||
|
||||
import BaseThumbnail from '@/components/templates/thumbnails/BaseThumbnail.vue'
|
||||
|
||||
const SLIDER_START_POSITION = 21
|
||||
const SLIDER_START_POSITION = 50
|
||||
|
||||
const { baseImageSrc, overlayImageSrc, isHovered, isVideo } = defineProps<{
|
||||
baseImageSrc: string
|
||||
|
||||
@@ -101,6 +101,7 @@ Composables for sidebar functionality:
|
||||
- `useNodeLibrarySidebarTab` - Manages the node library sidebar tab
|
||||
- `useQueueSidebarTab` - Manages the queue sidebar tab
|
||||
- `useWorkflowsSidebarTab` - Manages the workflows sidebar tab
|
||||
- `useTemplateWorkflows` - Manages template workflow loading, selection, and display
|
||||
|
||||
### Widgets
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import _ from 'lodash'
|
||||
import { computed, onMounted, watch } from 'vue'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useExtensionStore } from '@/stores/extensionStore'
|
||||
import { ComfyNodeDefImpl, useNodeDefStore } from '@/stores/nodeDefStore'
|
||||
@@ -111,10 +112,15 @@ export const useNodeBadge = () => {
|
||||
node.badges.push(() => badge.value)
|
||||
|
||||
if (node.constructor.nodeData?.api_node && showApiPricingBadge.value) {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
// Always add the badge for API nodes, with or without price text
|
||||
const creditsBadge = computed(() => {
|
||||
// Use dynamic background color based on the theme
|
||||
// Get the pricing function to determine if this node has dynamic pricing
|
||||
const pricingConfig = nodePricing.getNodePricingConfig(node)
|
||||
const hasDynamicPricing =
|
||||
typeof pricingConfig?.displayPrice === 'function'
|
||||
|
||||
let creditsBadge
|
||||
const createBadge = () => {
|
||||
const price = nodePricing.getNodeDisplayPrice(node)
|
||||
|
||||
const isLightTheme =
|
||||
colorPaletteStore.completedActivePalette.light_theme
|
||||
return new LGraphBadge({
|
||||
@@ -137,7 +143,24 @@ export const useNodeBadge = () => {
|
||||
? adjustColor('#8D6932', { lightness: 0.5 })
|
||||
: '#8D6932'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
if (hasDynamicPricing) {
|
||||
// For dynamic pricing nodes, use computed that watches widget changes
|
||||
const relevantWidgetNames = nodePricing.getRelevantWidgetNames(
|
||||
node.constructor.nodeData?.name
|
||||
)
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
widgetNames: relevantWidgetNames,
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
creditsBadge = computedWithWidgetWatch(createBadge)
|
||||
} else {
|
||||
// For static pricing nodes, use regular computed
|
||||
creditsBadge = computed(createBadge)
|
||||
}
|
||||
|
||||
node.badges.push(() => creditsBadge.value)
|
||||
}
|
||||
|
||||
85
src/composables/node/useWatchWidget.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { computedWithControl } from '@vueuse/core'
|
||||
import { type ComputedRef, ref } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
|
||||
export interface UseComputedWithWidgetWatchOptions {
|
||||
/**
|
||||
* Names of widgets to observe for changes.
|
||||
* If not provided, all widgets will be observed.
|
||||
*/
|
||||
widgetNames?: string[]
|
||||
|
||||
/**
|
||||
* Whether to trigger a canvas redraw when widget values change.
|
||||
* @default false
|
||||
*/
|
||||
triggerCanvasRedraw?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* A composable that creates a computed that has a node's widget values as a dependencies.
|
||||
* Essentially `computedWithControl` (https://vueuse.org/shared/computedWithControl/) where
|
||||
* the explicitly defined extra dependencies are LGraphNode widgets.
|
||||
*
|
||||
* @param node - The LGraphNode whose widget values are to be watched
|
||||
* @param options - Configuration options for the watcher
|
||||
* @returns A function to create computed that responds to widget changes
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const computedWithWidgetWatch = useComputedWithWidgetWatch(node, {
|
||||
* widgetNames: ['width', 'height'],
|
||||
* triggerCanvasRedraw: true
|
||||
* })
|
||||
*
|
||||
* const dynamicPrice = computedWithWidgetWatch(() => {
|
||||
* return calculatePrice(node)
|
||||
* })
|
||||
* ```
|
||||
*/
|
||||
export const useComputedWithWidgetWatch = (
|
||||
node: LGraphNode,
|
||||
options: UseComputedWithWidgetWatchOptions = {}
|
||||
) => {
|
||||
const { widgetNames, triggerCanvasRedraw = false } = options
|
||||
|
||||
// Create a reactive trigger based on widget values
|
||||
const widgetValues = ref<Record<string, any>>({})
|
||||
|
||||
// Initialize widget observers
|
||||
if (node.widgets) {
|
||||
const widgetsToObserve = widgetNames
|
||||
? node.widgets.filter((widget) => widgetNames.includes(widget.name))
|
||||
: node.widgets
|
||||
|
||||
// Initialize current values
|
||||
const currentValues: Record<string, any> = {}
|
||||
widgetsToObserve.forEach((widget) => {
|
||||
currentValues[widget.name] = widget.value
|
||||
})
|
||||
widgetValues.value = currentValues
|
||||
|
||||
widgetsToObserve.forEach((widget) => {
|
||||
widget.callback = useChainCallback(widget.callback, () => {
|
||||
// Update the reactive widget values
|
||||
widgetValues.value = {
|
||||
...widgetValues.value,
|
||||
[widget.name]: widget.value
|
||||
}
|
||||
|
||||
// Optionally trigger a canvas redraw
|
||||
if (triggerCanvasRedraw) {
|
||||
node.graph?.setDirtyCanvas(true, true)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Returns a function that creates a computed that responds to widget changes.
|
||||
// The computed will be re-evaluated whenever any observed widget changes.
|
||||
return <T>(computeFn: () => T): ComputedRef<T> => {
|
||||
return computedWithControl(widgetValues, computeFn)
|
||||
}
|
||||
}
|
||||
190
src/composables/useTemplateWorkflows.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { api } from '@/scripts/api'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
|
||||
import type {
|
||||
TemplateGroup,
|
||||
TemplateInfo,
|
||||
WorkflowTemplates
|
||||
} from '@/types/workflowTemplateTypes'
|
||||
|
||||
export function useTemplateWorkflows() {
|
||||
const { t } = useI18n()
|
||||
const workflowTemplatesStore = useWorkflowTemplatesStore()
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
// State
|
||||
const selectedTemplate = ref<WorkflowTemplates | null>(null)
|
||||
const loadingTemplateId = ref<string | null>(null)
|
||||
|
||||
// Computed
|
||||
const isTemplatesLoaded = computed(() => workflowTemplatesStore.isLoaded)
|
||||
const allTemplateGroups = computed<TemplateGroup[]>(
|
||||
() => workflowTemplatesStore.groupedTemplates
|
||||
)
|
||||
|
||||
/**
|
||||
* Loads all template workflows from the API
|
||||
*/
|
||||
const loadTemplates = async () => {
|
||||
if (!workflowTemplatesStore.isLoaded) {
|
||||
await workflowTemplatesStore.loadWorkflowTemplates()
|
||||
}
|
||||
return workflowTemplatesStore.isLoaded
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects the first template category as default
|
||||
*/
|
||||
const selectFirstTemplateCategory = () => {
|
||||
if (allTemplateGroups.value.length > 0) {
|
||||
const firstCategory = allTemplateGroups.value[0].modules[0]
|
||||
selectTemplateCategory(firstCategory)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Selects a template category
|
||||
*/
|
||||
const selectTemplateCategory = (category: WorkflowTemplates | null) => {
|
||||
selectedTemplate.value = category
|
||||
return category !== null
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets template thumbnail URL
|
||||
*/
|
||||
const getTemplateThumbnailUrl = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string,
|
||||
index = ''
|
||||
) => {
|
||||
const basePath =
|
||||
sourceModule === 'default'
|
||||
? api.fileURL(`/templates/${template.name}`)
|
||||
: api.apiURL(`/workflow_templates/${sourceModule}/${template.name}`)
|
||||
|
||||
const indexSuffix = sourceModule === 'default' && index ? `-${index}` : ''
|
||||
return `${basePath}${indexSuffix}.${template.mediaSubtype}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template title
|
||||
*/
|
||||
const getTemplateTitle = (template: TemplateInfo, sourceModule: string) => {
|
||||
const fallback =
|
||||
template.title ?? template.name ?? `${sourceModule} Template`
|
||||
return sourceModule === 'default'
|
||||
? template.localizedTitle ?? fallback
|
||||
: fallback
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets formatted template description
|
||||
*/
|
||||
const getTemplateDescription = (
|
||||
template: TemplateInfo,
|
||||
sourceModule: string
|
||||
) => {
|
||||
return sourceModule === 'default'
|
||||
? template.localizedDescription ?? ''
|
||||
: template.description?.replace(/[-_]/g, ' ').trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a workflow template
|
||||
*/
|
||||
const loadWorkflowTemplate = async (id: string, sourceModule: string) => {
|
||||
if (!isTemplatesLoaded.value) return false
|
||||
|
||||
loadingTemplateId.value = id
|
||||
let json
|
||||
|
||||
try {
|
||||
// Handle "All" category as a special case
|
||||
if (sourceModule === 'all') {
|
||||
// Find "All" category in the ComfyUI Examples group
|
||||
const comfyExamplesGroup = allTemplateGroups.value.find(
|
||||
(g) =>
|
||||
g.label ===
|
||||
t('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
||||
)
|
||||
const allCategory = comfyExamplesGroup?.modules.find(
|
||||
(m) => m.moduleName === 'all'
|
||||
)
|
||||
const template = allCategory?.templates.find((t) => t.name === id)
|
||||
|
||||
if (!template || !template.sourceModule) return false
|
||||
|
||||
// Use the stored source module for loading
|
||||
const actualSourceModule = template.sourceModule
|
||||
json = await fetchTemplateJson(id, actualSourceModule)
|
||||
|
||||
// Use source module for name
|
||||
const workflowName =
|
||||
actualSourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Regular case for normal categories
|
||||
json = await fetchTemplateJson(id, sourceModule)
|
||||
|
||||
const workflowName =
|
||||
sourceModule === 'default'
|
||||
? t(`templateWorkflows.template.${id}`, id)
|
||||
: id
|
||||
|
||||
dialogStore.closeDialog()
|
||||
await app.loadGraphData(json, true, true, workflowName)
|
||||
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Error loading workflow template:', error)
|
||||
return false
|
||||
} finally {
|
||||
loadingTemplateId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches template JSON from the appropriate endpoint
|
||||
*/
|
||||
const fetchTemplateJson = async (id: string, sourceModule: string) => {
|
||||
if (sourceModule === 'default') {
|
||||
// Default templates provided by frontend are served on this separate endpoint
|
||||
return fetch(api.fileURL(`/templates/${id}.json`)).then((r) => r.json())
|
||||
} else {
|
||||
return fetch(
|
||||
api.apiURL(`/workflow_templates/${sourceModule}/${id}.json`)
|
||||
).then((r) => r.json())
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
// State
|
||||
selectedTemplate,
|
||||
loadingTemplateId,
|
||||
|
||||
// Computed
|
||||
isTemplatesLoaded,
|
||||
allTemplateGroups,
|
||||
|
||||
// Methods
|
||||
loadTemplates,
|
||||
selectFirstTemplateCategory,
|
||||
selectTemplateCategory,
|
||||
getTemplateThumbnailUrl,
|
||||
getTemplateTitle,
|
||||
getTemplateDescription,
|
||||
loadWorkflowTemplate
|
||||
}
|
||||
}
|
||||
@@ -160,22 +160,48 @@ class Load3d {
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.renderPreviewScene()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
|
||||
this.INITIAL_RENDER_DONE = true
|
||||
}
|
||||
|
||||
renderMainScene(): void {
|
||||
const width = this.renderer.domElement.clientWidth
|
||||
const height = this.renderer.domElement.clientHeight
|
||||
|
||||
this.renderer.setViewport(0, 0, width, height)
|
||||
this.renderer.setScissor(0, 0, width, height)
|
||||
this.renderer.setScissorTest(true)
|
||||
|
||||
this.renderer.clear()
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
}
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
renderPreviewScene(): void {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.updatePreviewRender()
|
||||
}
|
||||
resetViewport(): void {
|
||||
const width = this.renderer.domElement.clientWidth
|
||||
const height = this.renderer.domElement.clientHeight
|
||||
|
||||
this.INITIAL_RENDER_DONE = true
|
||||
this.renderer.setViewport(0, 0, width, height)
|
||||
this.renderer.setScissor(0, 0, width, height)
|
||||
this.renderer.setScissorTest(false)
|
||||
}
|
||||
|
||||
private getActiveCamera(): THREE.Camera {
|
||||
@@ -198,20 +224,17 @@ class Load3d {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.updatePreviewRender()
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderer.clear()
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.renderPreviewScene()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
@@ -304,11 +327,9 @@ class Load3d {
|
||||
async setBackgroundImage(uploadPath: string): Promise<void> {
|
||||
await this.sceneManager.setBackgroundImage(uploadPath)
|
||||
|
||||
if (this.previewManager.previewRenderer) {
|
||||
this.previewManager.updateBackgroundTexture(
|
||||
this.sceneManager.backgroundTexture
|
||||
)
|
||||
}
|
||||
this.previewManager.updateBackgroundTexture(
|
||||
this.sceneManager.backgroundTexture
|
||||
)
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -316,10 +337,7 @@ class Load3d {
|
||||
removeBackgroundImage(): void {
|
||||
this.sceneManager.removeBackgroundImage()
|
||||
|
||||
if (
|
||||
this.previewManager.previewRenderer &&
|
||||
this.previewManager.previewCamera
|
||||
) {
|
||||
if (this.previewManager.previewCamera) {
|
||||
this.previewManager.updateBackgroundTexture(null)
|
||||
}
|
||||
|
||||
|
||||
@@ -42,10 +42,6 @@ class Load3dAnimation extends Load3d {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.updatePreviewRender()
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
|
||||
this.animationManager.update(delta)
|
||||
@@ -54,12 +50,13 @@ class Load3dAnimation extends Load3d {
|
||||
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderer.clear()
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.renderPreviewScene()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { EventManagerInterface, PreviewManagerInterface } from './interfaces'
|
||||
|
||||
export class PreviewManager implements PreviewManagerInterface {
|
||||
previewRenderer: THREE.WebGLRenderer | null = null
|
||||
previewCamera: THREE.Camera
|
||||
previewContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
showPreview: boolean = true
|
||||
@@ -17,7 +16,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
private getControls: () => OrbitControls
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
// @ts-expect-error unused variable
|
||||
private getRenderer: () => THREE.WebGLRenderer
|
||||
|
||||
private previewBackgroundScene: THREE.Scene
|
||||
@@ -25,6 +23,8 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
private previewBackgroundMesh: THREE.Mesh | null = null
|
||||
private previewBackgroundTexture: THREE.Texture | null = null
|
||||
|
||||
private previewBackgroundColor: THREE.Color = new THREE.Color(0x282828)
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
@@ -61,18 +61,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
init(): void {}
|
||||
|
||||
dispose(): void {
|
||||
if (this.previewRenderer) {
|
||||
this.previewRenderer.forceContextLoss()
|
||||
const canvas = this.previewRenderer.domElement
|
||||
const event = new Event('webglcontextlost', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
canvas.dispatchEvent(event)
|
||||
|
||||
this.previewRenderer.dispose()
|
||||
}
|
||||
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
}
|
||||
@@ -84,17 +72,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
}
|
||||
|
||||
createCapturePreview(container: Element | HTMLElement): void {
|
||||
this.previewRenderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true
|
||||
})
|
||||
|
||||
this.previewRenderer.setSize(this.targetWidth, this.targetHeight)
|
||||
this.previewRenderer.setClearColor(0x282828)
|
||||
this.previewRenderer.autoClear = false
|
||||
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
|
||||
this.previewContainer = document.createElement('div')
|
||||
this.previewContainer.style.cssText = `
|
||||
position: absolute;
|
||||
@@ -104,7 +81,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
display: block;
|
||||
transition: border-color 0.1s ease;
|
||||
`
|
||||
this.previewContainer.appendChild(this.previewRenderer.domElement)
|
||||
|
||||
const MIN_PREVIEW_WIDTH = 120
|
||||
const MAX_PREVIEW_WIDTH = 240
|
||||
@@ -131,7 +107,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
}
|
||||
|
||||
this.updatePreviewSize()
|
||||
this.updatePreviewRender()
|
||||
})
|
||||
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
@@ -159,13 +134,48 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
|
||||
const previewHeight =
|
||||
(this.previewWidth * this.targetHeight) / this.targetWidth
|
||||
this.previewRenderer?.setSize(this.previewWidth, previewHeight, false)
|
||||
|
||||
this.previewContainer.style.width = `${this.previewWidth}px`
|
||||
this.previewContainer.style.height = `${previewHeight}px`
|
||||
}
|
||||
|
||||
getPreviewViewport(): {
|
||||
left: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
} | null {
|
||||
if (!this.showPreview || !this.previewContainer) {
|
||||
return null
|
||||
}
|
||||
|
||||
const renderer = this.getRenderer()
|
||||
const canvas = renderer.domElement
|
||||
|
||||
const containerRect = this.previewContainer.getBoundingClientRect()
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
|
||||
if (
|
||||
containerRect.bottom < canvasRect.top ||
|
||||
containerRect.top > canvasRect.bottom ||
|
||||
containerRect.right < canvasRect.left ||
|
||||
containerRect.left > canvasRect.right
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
const width = parseFloat(this.previewContainer.style.width)
|
||||
const height = parseFloat(this.previewContainer.style.height)
|
||||
|
||||
const left = this.getRenderer().domElement.clientWidth - width
|
||||
|
||||
const bottom = 0
|
||||
|
||||
return { left, bottom, width, height }
|
||||
}
|
||||
|
||||
syncWithMainCamera(): void {
|
||||
if (!this.previewRenderer || !this.previewContainer || !this.showPreview) {
|
||||
return
|
||||
}
|
||||
if (!this.showPreview) return
|
||||
|
||||
this.previewCamera = this.getActiveCamera().clone()
|
||||
|
||||
@@ -203,85 +213,73 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
}
|
||||
|
||||
this.previewCamera.lookAt(this.getControls().target)
|
||||
|
||||
this.updatePreviewRender()
|
||||
}
|
||||
|
||||
updatePreviewRender(): void {
|
||||
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
|
||||
return
|
||||
renderPreview(): void {
|
||||
const viewport = this.getPreviewViewport()
|
||||
if (!viewport) return
|
||||
|
||||
if (
|
||||
!this.previewCamera ||
|
||||
(this.getActiveCamera() instanceof THREE.PerspectiveCamera &&
|
||||
!(this.previewCamera instanceof THREE.PerspectiveCamera)) ||
|
||||
(this.getActiveCamera() instanceof THREE.OrthographicCamera &&
|
||||
!(this.previewCamera instanceof THREE.OrthographicCamera))
|
||||
) {
|
||||
this.previewCamera = this.getActiveCamera().clone()
|
||||
}
|
||||
const renderer = this.getRenderer()
|
||||
|
||||
this.previewCamera.position.copy(this.getActiveCamera().position)
|
||||
this.previewCamera.rotation.copy(this.getActiveCamera().rotation)
|
||||
const originalClearColor = renderer.getClearColor(new THREE.Color())
|
||||
const originalClearAlpha = renderer.getClearAlpha()
|
||||
|
||||
const aspect = this.targetWidth / this.targetHeight
|
||||
this.syncWithMainCamera()
|
||||
|
||||
if (this.getActiveCamera() instanceof THREE.OrthographicCamera) {
|
||||
const activeOrtho = this.getActiveCamera() as THREE.OrthographicCamera
|
||||
const previewOrtho = this.previewCamera as THREE.OrthographicCamera
|
||||
renderer.setViewport(
|
||||
viewport.left,
|
||||
viewport.bottom,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
)
|
||||
renderer.setScissor(
|
||||
viewport.left,
|
||||
viewport.bottom,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
)
|
||||
|
||||
const frustumHeight =
|
||||
(activeOrtho.top - activeOrtho.bottom) / activeOrtho.zoom
|
||||
|
||||
const frustumWidth = frustumHeight * aspect
|
||||
|
||||
previewOrtho.top = frustumHeight / 2
|
||||
previewOrtho.left = -frustumWidth / 2
|
||||
previewOrtho.right = frustumWidth / 2
|
||||
previewOrtho.bottom = -frustumHeight / 2
|
||||
previewOrtho.zoom = 1
|
||||
|
||||
previewOrtho.updateProjectionMatrix()
|
||||
} else {
|
||||
;(this.previewCamera as THREE.PerspectiveCamera).aspect = aspect
|
||||
;(this.previewCamera as THREE.PerspectiveCamera).fov = (
|
||||
this.getActiveCamera() as THREE.PerspectiveCamera
|
||||
).fov
|
||||
;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.previewCamera.lookAt(this.getControls().target)
|
||||
|
||||
const previewHeight =
|
||||
(this.previewWidth * this.targetHeight) / this.targetWidth
|
||||
this.previewRenderer.setSize(this.previewWidth, previewHeight, false)
|
||||
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.previewRenderer.clear()
|
||||
renderer.setClearColor(this.previewBackgroundColor, 1.0)
|
||||
renderer.clear()
|
||||
|
||||
if (this.previewBackgroundMesh && this.previewBackgroundTexture) {
|
||||
const material = this.previewBackgroundMesh
|
||||
.material as THREE.MeshBasicMaterial
|
||||
if (material.map) {
|
||||
const currentToneMapping = this.previewRenderer.toneMapping
|
||||
const currentExposure = this.previewRenderer.toneMappingExposure
|
||||
const currentToneMapping = renderer.toneMapping
|
||||
const currentExposure = renderer.toneMappingExposure
|
||||
|
||||
this.previewRenderer.toneMapping = THREE.NoToneMapping
|
||||
this.previewRenderer.render(
|
||||
renderer.toneMapping = THREE.NoToneMapping
|
||||
renderer.render(
|
||||
this.previewBackgroundScene,
|
||||
this.previewBackgroundCamera
|
||||
)
|
||||
|
||||
this.previewRenderer.toneMapping = currentToneMapping
|
||||
this.previewRenderer.toneMappingExposure = currentExposure
|
||||
renderer.toneMapping = currentToneMapping
|
||||
renderer.toneMappingExposure = currentExposure
|
||||
}
|
||||
}
|
||||
|
||||
this.previewRenderer.render(this.scene, this.previewCamera)
|
||||
renderer.render(this.scene, this.previewCamera)
|
||||
|
||||
renderer.setClearColor(originalClearColor, originalClearAlpha)
|
||||
}
|
||||
|
||||
setPreviewBackgroundColor(color: string | number): void {
|
||||
this.previewBackgroundColor.set(color)
|
||||
}
|
||||
|
||||
getPreviewBackgroundColor(): THREE.Color {
|
||||
return this.previewBackgroundColor.clone()
|
||||
}
|
||||
|
||||
updatePreviewRender(): void {
|
||||
this.syncWithMainCamera()
|
||||
}
|
||||
|
||||
togglePreview(showPreview: boolean): void {
|
||||
if (this.previewRenderer) {
|
||||
this.showPreview = showPreview
|
||||
this.showPreview = showPreview
|
||||
if (this.previewContainer) {
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
}
|
||||
|
||||
@@ -306,7 +304,7 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
)
|
||||
}
|
||||
|
||||
if (this.previewRenderer && this.previewCamera) {
|
||||
if (this.previewCamera) {
|
||||
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
|
||||
this.previewCamera.aspect = width / height
|
||||
this.previewCamera.updateProjectionMatrix()
|
||||
@@ -322,7 +320,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
|
||||
handleResize(): void {
|
||||
this.updatePreviewSize()
|
||||
this.updatePreviewRender()
|
||||
}
|
||||
|
||||
updateBackgroundTexture(texture: THREE.Texture | null): void {
|
||||
|
||||
@@ -100,7 +100,6 @@ export interface ViewHelperManagerInterface extends BaseManager {
|
||||
}
|
||||
|
||||
export interface PreviewManagerInterface extends BaseManager {
|
||||
previewRenderer: THREE.WebGLRenderer | null
|
||||
previewCamera: THREE.Camera
|
||||
previewContainer: HTMLDivElement
|
||||
showPreview: boolean
|
||||
@@ -112,6 +111,14 @@ export interface PreviewManagerInterface extends BaseManager {
|
||||
setTargetSize(width: number, height: number): void
|
||||
handleResize(): void
|
||||
updateBackgroundTexture(texture: THREE.Texture | null): void
|
||||
getPreviewViewport(): {
|
||||
left: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
} | null
|
||||
renderPreview(): void
|
||||
syncWithMainCamera(): void
|
||||
}
|
||||
|
||||
export interface EventManagerInterface {
|
||||
|
||||
@@ -506,7 +506,12 @@ export function mergeIfValid(
|
||||
}
|
||||
}
|
||||
|
||||
return { customConfig: customSpec?.[1] ?? {} }
|
||||
return {
|
||||
customConfig:
|
||||
customSpec && Array.isArray(customSpec) && customSpec[1]
|
||||
? customSpec[1]
|
||||
: {}
|
||||
}
|
||||
}
|
||||
|
||||
app.registerExtension({
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
"icon": "Icon",
|
||||
"color": "Color",
|
||||
"error": "Error",
|
||||
"help": "Help",
|
||||
"loading": "Loading",
|
||||
"findIssues": "Find Issues",
|
||||
"reportIssue": "Send Report",
|
||||
@@ -513,7 +514,8 @@
|
||||
"3D": "3D",
|
||||
"Audio": "Audio",
|
||||
"Image API": "Image API",
|
||||
"Video API": "Video API"
|
||||
"Video API": "Video API",
|
||||
"All": "All Templates"
|
||||
},
|
||||
"templateDescription": {
|
||||
"Basics": {
|
||||
@@ -1423,5 +1425,13 @@
|
||||
"cancelEditTooltip": "Cancel edit",
|
||||
"copiedTooltip": "Copied",
|
||||
"copyTooltip": "Copy message to clipboard"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"inputs": "Inputs",
|
||||
"outputs": "Outputs",
|
||||
"type": "Type",
|
||||
"moreHelp": "For more help, visit the",
|
||||
"documentationPage": "documentation page",
|
||||
"loadError": "Failed to load help: {error}"
|
||||
}
|
||||
}
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "Encontrar problemas",
|
||||
"firstTimeUIMessage": "Esta es la primera vez que usas la nueva interfaz. Elige \"Menú > Usar nuevo menú > Desactivado\" para restaurar la antigua interfaz.",
|
||||
"goToNode": "Ir al nodo",
|
||||
"help": "Ayuda",
|
||||
"icon": "Icono",
|
||||
"imageFailedToLoad": "Falló la carga de la imagen",
|
||||
"imageUrl": "URL de la imagen",
|
||||
@@ -836,6 +837,14 @@
|
||||
"video": "video",
|
||||
"video_models": "modelos_de_video"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "página de documentación",
|
||||
"inputs": "Entradas",
|
||||
"loadError": "Error al cargar la ayuda: {error}",
|
||||
"moreHelp": "Para más ayuda, visita la",
|
||||
"outputs": "Salidas",
|
||||
"type": "Tipo"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Introduzca el nombre",
|
||||
"saveAsTemplate": "Guardar como plantilla"
|
||||
@@ -1129,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "Todas las plantillas",
|
||||
"Area Composition": "Composición de Área",
|
||||
"Audio": "Audio",
|
||||
"Basics": "Básicos",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "Trouver des problèmes",
|
||||
"firstTimeUIMessage": "C'est la première fois que vous utilisez la nouvelle interface utilisateur. Choisissez \"Menu > Utiliser le nouveau menu > Désactivé\" pour restaurer l'ancienne interface utilisateur.",
|
||||
"goToNode": "Aller au nœud",
|
||||
"help": "Aide",
|
||||
"icon": "Icône",
|
||||
"imageFailedToLoad": "Échec du chargement de l'image",
|
||||
"imageUrl": "URL de l'image",
|
||||
@@ -836,6 +837,14 @@
|
||||
"video": "vidéo",
|
||||
"video_models": "modèles_vidéo"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "page de documentation",
|
||||
"inputs": "Entrées",
|
||||
"loadError": "Échec du chargement de l’aide : {error}",
|
||||
"moreHelp": "Pour plus d'aide, visitez la",
|
||||
"outputs": "Sorties",
|
||||
"type": "Type"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Entrez le nom",
|
||||
"saveAsTemplate": "Enregistrer comme modèle"
|
||||
@@ -1129,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "Tous les modèles",
|
||||
"Area Composition": "Composition de zone",
|
||||
"Audio": "Audio",
|
||||
"Basics": "Basiques",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "問題を見つける",
|
||||
"firstTimeUIMessage": "新しいUIを初めて使用しています。「メニュー > 新しいメニューを使用 > 無効」を選択することで古いUIに戻すことが可能です。",
|
||||
"goToNode": "ノードに移動",
|
||||
"help": "ヘルプ",
|
||||
"icon": "アイコン",
|
||||
"imageFailedToLoad": "画像の読み込みに失敗しました",
|
||||
"imageUrl": "画像URL",
|
||||
@@ -836,6 +837,14 @@
|
||||
"video": "ビデオ",
|
||||
"video_models": "ビデオモデル"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "ドキュメントページ",
|
||||
"inputs": "入力",
|
||||
"loadError": "ヘルプの読み込みに失敗しました: {error}",
|
||||
"moreHelp": "さらに詳しい情報は、",
|
||||
"outputs": "出力",
|
||||
"type": "タイプ"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "名前を入力",
|
||||
"saveAsTemplate": "テンプレートとして保存"
|
||||
@@ -1129,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "すべてのテンプレート",
|
||||
"Area Composition": "エリア構成",
|
||||
"Audio": "オーディオ",
|
||||
"Basics": "基本",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "문제 찾기",
|
||||
"firstTimeUIMessage": "새 UI를 처음 사용합니다. \"메뉴 > 새 메뉴 사용 > 비활성화\"를 선택하여 이전 UI로 복원하세요.",
|
||||
"goToNode": "노드로 이동",
|
||||
"help": "도움말",
|
||||
"icon": "아이콘",
|
||||
"imageFailedToLoad": "이미지를 로드하지 못했습니다.",
|
||||
"imageUrl": "이미지 URL",
|
||||
@@ -836,6 +837,14 @@
|
||||
"video": "비디오",
|
||||
"video_models": "비디오 모델"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "문서 페이지",
|
||||
"inputs": "입력",
|
||||
"loadError": "도움말을 불러오지 못했습니다: {error}",
|
||||
"moreHelp": "더 많은 도움말은",
|
||||
"outputs": "출력",
|
||||
"type": "유형"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "이름 입력",
|
||||
"saveAsTemplate": "템플릿으로 저장"
|
||||
@@ -1129,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "모든 템플릿",
|
||||
"Area Composition": "영역 구성",
|
||||
"Audio": "오디오",
|
||||
"Basics": "기본",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "Найти проблемы",
|
||||
"firstTimeUIMessage": "Вы впервые используете новый интерфейс. Выберите \"Меню > Использовать новое меню > Отключено\", чтобы восстановить старый интерфейс.",
|
||||
"goToNode": "Перейти к ноде",
|
||||
"help": "Помощь",
|
||||
"icon": "Иконка",
|
||||
"imageFailedToLoad": "Не удалось загрузить изображение",
|
||||
"imageUrl": "URL изображения",
|
||||
@@ -836,6 +837,14 @@
|
||||
"video": "видео",
|
||||
"video_models": "видеомодели"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "страницу документации",
|
||||
"inputs": "Входы",
|
||||
"loadError": "Не удалось загрузить справку: {error}",
|
||||
"moreHelp": "Для получения дополнительной помощи посетите",
|
||||
"outputs": "Выходы",
|
||||
"type": "Тип"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "Введите название",
|
||||
"saveAsTemplate": "Сохранить как шаблон"
|
||||
@@ -1129,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "Все шаблоны",
|
||||
"Area Composition": "Композиция области",
|
||||
"Audio": "Аудио",
|
||||
"Basics": "Основы",
|
||||
|
||||
@@ -294,6 +294,7 @@
|
||||
"findIssues": "查找问题",
|
||||
"firstTimeUIMessage": "这是您第一次使用新界面。选择 \"菜单 > 使用新菜单 > 禁用\" 来恢复旧界面。",
|
||||
"goToNode": "转到节点",
|
||||
"help": "帮助",
|
||||
"icon": "图标",
|
||||
"imageFailedToLoad": "图像加载失败",
|
||||
"imageUrl": "图片网址",
|
||||
@@ -836,6 +837,14 @@
|
||||
"video": "视频",
|
||||
"video_models": "视频模型"
|
||||
},
|
||||
"nodeHelpPage": {
|
||||
"documentationPage": "文档页面",
|
||||
"inputs": "输入",
|
||||
"loadError": "加载帮助失败:{error}",
|
||||
"moreHelp": "如需更多帮助,请访问",
|
||||
"outputs": "输出",
|
||||
"type": "类型"
|
||||
},
|
||||
"nodeTemplates": {
|
||||
"enterName": "输入名称",
|
||||
"saveAsTemplate": "另存为模板"
|
||||
@@ -1129,6 +1138,7 @@
|
||||
"templateWorkflows": {
|
||||
"category": {
|
||||
"3D": "3D",
|
||||
"All": "所有模板",
|
||||
"Area Composition": "区域组成",
|
||||
"Audio": "音频",
|
||||
"Basics": "基础",
|
||||
|
||||
@@ -73,6 +73,7 @@ export const zComfyNodeDef = z.object({
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string(),
|
||||
help: z.string().optional(),
|
||||
category: z.string(),
|
||||
output_node: z.boolean(),
|
||||
python_module: z.string(),
|
||||
|
||||
@@ -219,6 +219,7 @@ export const zComfyNodeDef = z.object({
|
||||
name: z.string(),
|
||||
display_name: z.string(),
|
||||
description: z.string(),
|
||||
help: z.string().optional(),
|
||||
category: z.string(),
|
||||
output_node: z.boolean(),
|
||||
python_module: z.string(),
|
||||
@@ -227,7 +228,7 @@ export const zComfyNodeDef = z.object({
|
||||
/**
|
||||
* Whether the node is an API node. Running API nodes requires login to
|
||||
* Comfy Org account.
|
||||
* https://www.comfy.org/faq
|
||||
* https://docs.comfy.org/tutorials/api-nodes/overview
|
||||
*/
|
||||
api_node: z.boolean().optional()
|
||||
})
|
||||
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
isComboInputSpecV1,
|
||||
isComboInputSpecV2
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { getFromWebmFile } from '@/scripts/metadata/ebml'
|
||||
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
|
||||
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
|
||||
import { getMp3Metadata } from '@/scripts/metadata/mp3'
|
||||
import { getOggMetadata } from '@/scripts/metadata/ogg'
|
||||
import { getSvgMetadata } from '@/scripts/metadata/svg'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -52,7 +58,6 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import { ExtensionManager } from '@/types/extensionTypes'
|
||||
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import { getFileHandler, resolveDragDropFile } from '@/utils/fileHandlers'
|
||||
import {
|
||||
executeWidgetsCallback,
|
||||
fixLinkInputSlots,
|
||||
@@ -68,7 +73,13 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
|
||||
import { type ComfyApi, PromptExecutionError, api } from './api'
|
||||
import { defaultGraph } from './defaultGraph'
|
||||
import { pruneWidgets } from './domWidget'
|
||||
import { importA1111 } from './pnginfo'
|
||||
import {
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata,
|
||||
importA1111
|
||||
} from './pnginfo'
|
||||
import { $el, ComfyUI } from './ui'
|
||||
import { ComfyAppMenu } from './ui/menu/index'
|
||||
import { clone } from './utils'
|
||||
@@ -463,13 +474,9 @@ export class ComfyApp {
|
||||
event.dataTransfer.files.length &&
|
||||
event.dataTransfer.files[0].type !== 'image/bmp'
|
||||
) {
|
||||
const resolvedFile = await resolveDragDropFile(
|
||||
event.dataTransfer.files[0],
|
||||
event.dataTransfer
|
||||
)
|
||||
await this.handleFile(resolvedFile)
|
||||
await this.handleFile(event.dataTransfer.files[0])
|
||||
} else {
|
||||
// Try loading the first URI in the transfer list (handles Chrome->Firefox BMP case)
|
||||
// Try loading the first URI in the transfer list
|
||||
const validTypes = ['text/uri-list', 'text/x-moz-url']
|
||||
const match = [...event.dataTransfer.types].find((t) =>
|
||||
validTypes.find((v) => t === v)
|
||||
@@ -1277,44 +1284,161 @@ export class ComfyApp {
|
||||
return f.substring(0, p)
|
||||
}
|
||||
const fileName = removeExt(file.name)
|
||||
|
||||
// Get the appropriate file handler for this file type
|
||||
const fileHandler = getFileHandler(file)
|
||||
|
||||
if (!fileHandler) {
|
||||
// No handler found for this file type
|
||||
this.showErrorOnFileLoad(file)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Process the file using the handler
|
||||
const { workflow, prompt, parameters, jsonTemplateData } =
|
||||
await fileHandler(file)
|
||||
|
||||
if (workflow) {
|
||||
// We have a workflow, load it
|
||||
await this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
// We have a prompt in API format, load it
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else if (parameters) {
|
||||
// We have A1111 parameters, import them
|
||||
if (file.type === 'image/png') {
|
||||
const pngInfo = await getPngMetadata(file)
|
||||
if (pngInfo?.workflow) {
|
||||
await this.loadGraphData(
|
||||
JSON.parse(pngInfo.workflow),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
)
|
||||
} else if (pngInfo?.prompt) {
|
||||
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
|
||||
} else if (pngInfo?.parameters) {
|
||||
// Note: Not putting this in `importA1111` as it is mostly not used
|
||||
// by external callers, and `importA1111` has no access to `app`.
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
importA1111(this.graph, parameters)
|
||||
importA1111(this.graph, pngInfo.parameters)
|
||||
useWorkflowService().afterLoadNewGraph(
|
||||
fileName,
|
||||
this.graph.serialize() as unknown as ComfyWorkflowJSON
|
||||
)
|
||||
} else if (jsonTemplateData) {
|
||||
// We have template data from JSON
|
||||
this.loadTemplateData(jsonTemplateData)
|
||||
} else {
|
||||
// No usable data found in the file
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error)
|
||||
} else if (file.type === 'image/webp') {
|
||||
const pngInfo = await getWebpMetadata(file)
|
||||
// Support loading workflows from that webp custom node.
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'audio/mpeg') {
|
||||
const { workflow, prompt } = await getMp3Metadata(file)
|
||||
if (workflow) {
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'audio/ogg') {
|
||||
const { workflow, prompt } = await getOggMetadata(file)
|
||||
if (workflow) {
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'audio/flac' || file.type === 'audio/x-flac') {
|
||||
const pngInfo = await getFlacMetadata(file)
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'video/webm') {
|
||||
const webmInfo = await getFromWebmFile(file)
|
||||
if (webmInfo.workflow) {
|
||||
this.loadGraphData(webmInfo.workflow, true, true, fileName)
|
||||
} else if (webmInfo.prompt) {
|
||||
this.loadApiJson(webmInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'video/mp4' ||
|
||||
file.name?.endsWith('.mp4') ||
|
||||
file.name?.endsWith('.mov') ||
|
||||
file.name?.endsWith('.m4v') ||
|
||||
file.type === 'video/quicktime' ||
|
||||
file.type === 'video/x-m4v'
|
||||
) {
|
||||
const mp4Info = await getFromIsobmffFile(file)
|
||||
if (mp4Info.workflow) {
|
||||
this.loadGraphData(mp4Info.workflow, true, true, fileName)
|
||||
} else if (mp4Info.prompt) {
|
||||
this.loadApiJson(mp4Info.prompt, fileName)
|
||||
}
|
||||
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
|
||||
const svgInfo = await getSvgMetadata(file)
|
||||
if (svgInfo.workflow) {
|
||||
this.loadGraphData(svgInfo.workflow, true, true, fileName)
|
||||
} else if (svgInfo.prompt) {
|
||||
this.loadApiJson(svgInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'model/gltf-binary' ||
|
||||
file.name?.endsWith('.glb')
|
||||
) {
|
||||
const gltfInfo = await getGltfBinaryMetadata(file)
|
||||
if (gltfInfo.workflow) {
|
||||
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
|
||||
} else if (gltfInfo.prompt) {
|
||||
this.loadApiJson(gltfInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'application/json' ||
|
||||
file.name?.endsWith('.json')
|
||||
) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const readerResult = reader.result as string
|
||||
const jsonContent = JSON.parse(readerResult)
|
||||
if (jsonContent?.templates) {
|
||||
this.loadTemplateData(jsonContent)
|
||||
} else if (this.isApiJson(jsonContent)) {
|
||||
this.loadApiJson(jsonContent, fileName)
|
||||
} else {
|
||||
await this.loadGraphData(
|
||||
JSON.parse(readerResult),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
} else if (
|
||||
file.name?.endsWith('.latent') ||
|
||||
file.name?.endsWith('.safetensors')
|
||||
) {
|
||||
const info = await getLatentMetadata(file)
|
||||
// TODO define schema to LatentMetadata
|
||||
// @ts-expect-error
|
||||
if (info.workflow) {
|
||||
await this.loadGraphData(
|
||||
// @ts-expect-error
|
||||
JSON.parse(info.workflow),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
)
|
||||
// @ts-expect-error
|
||||
} else if (info.prompt) {
|
||||
// @ts-expect-error
|
||||
this.loadApiJson(JSON.parse(info.prompt))
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
}
|
||||
@@ -1431,7 +1555,6 @@ export class ComfyApp {
|
||||
/**
|
||||
* Registers a Comfy web extension with the app
|
||||
* @param {ComfyExtension} extension
|
||||
* @deprecated Use useExtensionService().registerExtension instead
|
||||
*/
|
||||
registerExtension(extension: ComfyExtension) {
|
||||
useExtensionService().registerExtension(extension)
|
||||
|
||||
@@ -124,7 +124,19 @@ export const useAlgoliaSearchService = (
|
||||
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
|
||||
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
|
||||
} = options
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__, {
|
||||
hosts: [
|
||||
{
|
||||
url: 'search.comfy.org/api/search',
|
||||
accept: 'read',
|
||||
protocol: 'https'
|
||||
}
|
||||
],
|
||||
baseHeaders: {
|
||||
'X-Algolia-Application-Id': __ALGOLIA_APP_ID__,
|
||||
'X-Algolia-API-Key': __ALGOLIA_API_KEY__
|
||||
}
|
||||
})
|
||||
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
||||
maxSize: maxCacheSize
|
||||
})
|
||||
|
||||
59
src/services/nodeHelpService.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { api } from '@/scripts/api'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
|
||||
import { extractCustomNodeName } from '@/utils/nodeHelpUtil'
|
||||
|
||||
export class NodeHelpService {
|
||||
async fetchNodeHelp(node: ComfyNodeDefImpl, locale: string): Promise<string> {
|
||||
const nodeSource = getNodeSource(node.python_module)
|
||||
|
||||
if (nodeSource.type === NodeSourceType.CustomNodes) {
|
||||
return this.fetchCustomNodeHelp(node, locale)
|
||||
} else {
|
||||
return this.fetchCoreNodeHelp(node, locale)
|
||||
}
|
||||
}
|
||||
|
||||
private async fetchCustomNodeHelp(
|
||||
node: ComfyNodeDefImpl,
|
||||
locale: string
|
||||
): Promise<string> {
|
||||
const customNodeName = extractCustomNodeName(node.python_module)
|
||||
if (!customNodeName) {
|
||||
throw new Error('Invalid custom node module')
|
||||
}
|
||||
|
||||
// Try locale-specific path first
|
||||
const localePath = `/extensions/${customNodeName}/docs/${node.name}/${locale}.md`
|
||||
let res = await fetch(api.fileURL(localePath))
|
||||
|
||||
if (!res.ok) {
|
||||
// Fall back to non-locale path
|
||||
const fallbackPath = `/extensions/${customNodeName}/docs/${node.name}.md`
|
||||
res = await fetch(api.fileURL(fallbackPath))
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.statusText)
|
||||
}
|
||||
|
||||
return res.text()
|
||||
}
|
||||
|
||||
private async fetchCoreNodeHelp(
|
||||
node: ComfyNodeDefImpl,
|
||||
locale: string
|
||||
): Promise<string> {
|
||||
const mdUrl = `/docs/${node.name}/${locale}.md`
|
||||
const res = await fetch(api.fileURL(mdUrl))
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(res.statusText)
|
||||
}
|
||||
|
||||
return res.text()
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const nodeHelpService = new NodeHelpService()
|
||||
@@ -38,6 +38,7 @@ export class ComfyNodeDefImpl
|
||||
category: string
|
||||
readonly python_module: string
|
||||
readonly description: string
|
||||
readonly help: string
|
||||
readonly deprecated: boolean
|
||||
readonly experimental: boolean
|
||||
readonly output_node: boolean
|
||||
@@ -118,6 +119,7 @@ export class ComfyNodeDefImpl
|
||||
this.category = obj.category
|
||||
this.python_module = obj.python_module
|
||||
this.description = obj.description
|
||||
this.help = obj.help ?? ''
|
||||
this.deprecated = obj.deprecated ?? obj.category === ''
|
||||
this.experimental =
|
||||
obj.experimental ?? obj.category.startsWith('_for_testing')
|
||||
|
||||
@@ -101,7 +101,51 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
)
|
||||
})
|
||||
|
||||
// Create an "All" category that combines all templates
|
||||
const createAllCategory = () => {
|
||||
// First, get core templates with source module added
|
||||
const coreTemplatesWithSourceModule = coreTemplates.value.flatMap(
|
||||
(category) =>
|
||||
// For each template in each category, add the sourceModule and pass through any localized fields
|
||||
category.templates.map((template) => {
|
||||
// Get localized template with its original category title for i18n lookup
|
||||
const localizedTemplate = addLocalizedFieldsToTemplate(
|
||||
template,
|
||||
category.title
|
||||
)
|
||||
return {
|
||||
...localizedTemplate,
|
||||
sourceModule: category.moduleName
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
// Now handle custom templates
|
||||
const customTemplatesWithSourceModule = Object.entries(
|
||||
customTemplates.value
|
||||
).flatMap(([moduleName, templates]) =>
|
||||
templates.map((name) => ({
|
||||
name,
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: name,
|
||||
sourceModule: moduleName
|
||||
}))
|
||||
)
|
||||
|
||||
return {
|
||||
moduleName: 'all',
|
||||
title: 'All',
|
||||
localizedTitle: st('templateWorkflows.category.All', 'All Templates'),
|
||||
templates: [
|
||||
...coreTemplatesWithSourceModule,
|
||||
...customTemplatesWithSourceModule
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const groupedTemplates = computed<TemplateGroup[]>(() => {
|
||||
// Get regular categories
|
||||
const allTemplates = [
|
||||
...sortCategoryTemplates(coreTemplates.value).map(
|
||||
localizeTemplateCategory
|
||||
@@ -124,7 +168,8 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
)
|
||||
]
|
||||
|
||||
return Object.entries(
|
||||
// Group templates by their main category
|
||||
const groupedByCategory = Object.entries(
|
||||
groupBy(allTemplates, (template) =>
|
||||
template.moduleName === 'default'
|
||||
? st(
|
||||
@@ -134,6 +179,21 @@ export const useWorkflowTemplatesStore = defineStore(
|
||||
: st('templateWorkflows.category.Custom Nodes', 'Custom Nodes')
|
||||
)
|
||||
).map(([label, modules]) => ({ label, modules }))
|
||||
|
||||
// Insert the "All" category at the top of the "ComfyUI Examples" group
|
||||
const comfyExamplesGroupIndex = groupedByCategory.findIndex(
|
||||
(group) =>
|
||||
group.label ===
|
||||
st('templateWorkflows.category.ComfyUI Examples', 'ComfyUI Examples')
|
||||
)
|
||||
|
||||
if (comfyExamplesGroupIndex !== -1) {
|
||||
groupedByCategory[comfyExamplesGroupIndex].modules.unshift(
|
||||
createAllCategory()
|
||||
)
|
||||
}
|
||||
|
||||
return groupedByCategory
|
||||
})
|
||||
|
||||
async function loadWorkflowTemplates() {
|
||||
|
||||
69
src/stores/workspace/nodeHelpStore.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { i18n } from '@/i18n'
|
||||
import { nodeHelpService } from '@/services/nodeHelpService'
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
import { getNodeHelpBaseUrl } from '@/utils/nodeHelpUtil'
|
||||
|
||||
export const useNodeHelpStore = defineStore('nodeHelp', () => {
|
||||
const currentHelpNode = ref<ComfyNodeDefImpl | null>(null)
|
||||
const isHelpOpen = computed(() => currentHelpNode.value !== null)
|
||||
const helpContent = ref<string>('')
|
||||
const isLoading = ref<boolean>(false)
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
function openHelp(nodeDef: ComfyNodeDefImpl) {
|
||||
currentHelpNode.value = nodeDef
|
||||
}
|
||||
|
||||
function closeHelp() {
|
||||
currentHelpNode.value = null
|
||||
}
|
||||
|
||||
// Base URL for relative assets in node docs markdown
|
||||
const baseUrl = computed(() => {
|
||||
const node = currentHelpNode.value
|
||||
if (!node) return ''
|
||||
return getNodeHelpBaseUrl(node)
|
||||
})
|
||||
|
||||
// Watch for help node changes and fetch its docs markdown
|
||||
watch(
|
||||
() => currentHelpNode.value,
|
||||
async (node) => {
|
||||
helpContent.value = ''
|
||||
errorMsg.value = null
|
||||
|
||||
if (node) {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const locale = i18n.global.locale.value || 'en'
|
||||
helpContent.value = await nodeHelpService.fetchNodeHelp(node, locale)
|
||||
} catch (e: any) {
|
||||
errorMsg.value = e.message
|
||||
helpContent.value = node.description || ''
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
const renderedHelpHtml = computed(() => {
|
||||
return renderMarkdownToHtml(helpContent.value, baseUrl.value)
|
||||
})
|
||||
|
||||
return {
|
||||
currentHelpNode,
|
||||
isHelpOpen,
|
||||
openHelp,
|
||||
closeHelp,
|
||||
baseUrl,
|
||||
renderedHelpHtml,
|
||||
isLoading,
|
||||
error: errorMsg
|
||||
}
|
||||
})
|
||||
1942
src/types/generatedManagerTypes.ts
Normal file
@@ -11,6 +11,7 @@ export interface TemplateInfo {
|
||||
description: string
|
||||
localizedTitle?: string
|
||||
localizedDescription?: string
|
||||
sourceModule?: string
|
||||
}
|
||||
|
||||
export interface WorkflowTemplates {
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
/**
|
||||
* Maps MIME types and file extensions to handler functions for extracting
|
||||
* workflow data from various file formats. Uses supportedWorkflowFormats.ts
|
||||
* as the source of truth for supported formats.
|
||||
*/
|
||||
import {
|
||||
AUDIO_WORKFLOW_FORMATS,
|
||||
DATA_WORKFLOW_FORMATS,
|
||||
IMAGE_WORKFLOW_FORMATS,
|
||||
MODEL_WORKFLOW_FORMATS,
|
||||
VIDEO_WORKFLOW_FORMATS
|
||||
} from '@/constants/supportedWorkflowFormats'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import { getFromWebmFile } from '@/scripts/metadata/ebml'
|
||||
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
|
||||
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
|
||||
import { getMp3Metadata } from '@/scripts/metadata/mp3'
|
||||
import { getOggMetadata } from '@/scripts/metadata/ogg'
|
||||
import { getSvgMetadata } from '@/scripts/metadata/svg'
|
||||
import {
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata
|
||||
} from '@/scripts/pnginfo'
|
||||
|
||||
/**
|
||||
* Type for the file handler function
|
||||
*/
|
||||
export type WorkflowFileHandler = (file: File) => Promise<{
|
||||
workflow?: ComfyWorkflowJSON
|
||||
prompt?: ComfyApiWorkflow
|
||||
parameters?: string
|
||||
jsonTemplateData?: any // For template JSON data
|
||||
}>
|
||||
|
||||
/**
|
||||
* Maps MIME types to file handlers for loading workflows from different file formats
|
||||
*/
|
||||
export const mimeTypeHandlers = new Map<string, WorkflowFileHandler>()
|
||||
|
||||
/**
|
||||
* Maps file extensions to file handlers for loading workflows
|
||||
* Used as a fallback when MIME type detection fails
|
||||
*/
|
||||
export const extensionHandlers = new Map<string, WorkflowFileHandler>()
|
||||
|
||||
/**
|
||||
* Handler for PNG files
|
||||
*/
|
||||
const handlePngFile: WorkflowFileHandler = async (file) => {
|
||||
const pngInfo = await getPngMetadata(file)
|
||||
return {
|
||||
workflow: pngInfo?.workflow ? JSON.parse(pngInfo.workflow) : undefined,
|
||||
prompt: pngInfo?.prompt ? JSON.parse(pngInfo.prompt) : undefined,
|
||||
parameters: pngInfo?.parameters
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for WebP files
|
||||
*/
|
||||
const handleWebpFile: WorkflowFileHandler = async (file) => {
|
||||
const pngInfo = await getWebpMetadata(file)
|
||||
// Support loading workflows from that webp custom node.
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
return {
|
||||
workflow: workflow ? JSON.parse(workflow) : undefined,
|
||||
prompt: prompt ? JSON.parse(prompt) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for SVG files
|
||||
*/
|
||||
const handleSvgFile: WorkflowFileHandler = async (file) => {
|
||||
const svgInfo = await getSvgMetadata(file)
|
||||
return {
|
||||
workflow: svgInfo.workflow,
|
||||
prompt: svgInfo.prompt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for MP3 files
|
||||
*/
|
||||
const handleMp3File: WorkflowFileHandler = async (file) => {
|
||||
const { workflow, prompt } = await getMp3Metadata(file)
|
||||
return { workflow, prompt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for OGG files
|
||||
*/
|
||||
const handleOggFile: WorkflowFileHandler = async (file) => {
|
||||
const { workflow, prompt } = await getOggMetadata(file)
|
||||
return { workflow, prompt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for FLAC files
|
||||
*/
|
||||
const handleFlacFile: WorkflowFileHandler = async (file) => {
|
||||
const pngInfo = await getFlacMetadata(file)
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
return {
|
||||
workflow: workflow ? JSON.parse(workflow) : undefined,
|
||||
prompt: prompt ? JSON.parse(prompt) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for WebM files
|
||||
*/
|
||||
const handleWebmFile: WorkflowFileHandler = async (file) => {
|
||||
const webmInfo = await getFromWebmFile(file)
|
||||
return {
|
||||
workflow: webmInfo.workflow,
|
||||
prompt: webmInfo.prompt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for MP4/MOV/M4V files
|
||||
*/
|
||||
const handleMp4File: WorkflowFileHandler = async (file) => {
|
||||
const mp4Info = await getFromIsobmffFile(file)
|
||||
return {
|
||||
workflow: mp4Info.workflow,
|
||||
prompt: mp4Info.prompt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for GLB files
|
||||
*/
|
||||
const handleGlbFile: WorkflowFileHandler = async (file) => {
|
||||
const gltfInfo = await getGltfBinaryMetadata(file)
|
||||
return {
|
||||
workflow: gltfInfo.workflow,
|
||||
prompt: gltfInfo.prompt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for JSON files
|
||||
*/
|
||||
const handleJsonFile: WorkflowFileHandler = async (file) => {
|
||||
// For JSON files, we need to preserve the exact behavior from app.ts
|
||||
// This code intentionally mirrors the original implementation
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const readerResult = reader.result as string
|
||||
const jsonContent = JSON.parse(readerResult)
|
||||
|
||||
if (jsonContent?.templates) {
|
||||
// This case will be handled separately in handleFile
|
||||
resolve({
|
||||
workflow: undefined,
|
||||
prompt: undefined,
|
||||
jsonTemplateData: jsonContent
|
||||
})
|
||||
} else if (isApiJson(jsonContent)) {
|
||||
// API JSON format
|
||||
resolve({ workflow: undefined, prompt: jsonContent })
|
||||
} else {
|
||||
// Regular workflow JSON
|
||||
resolve({ workflow: JSON.parse(readerResult), prompt: undefined })
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for .latent and .safetensors files
|
||||
*/
|
||||
const handleLatentFile: WorkflowFileHandler = async (file) => {
|
||||
// Preserve the exact behavior from app.ts for latent files
|
||||
const info = await getLatentMetadata(file)
|
||||
|
||||
// Direct port of the original code, preserving behavior for TS compatibility
|
||||
if (info && typeof info === 'object' && 'workflow' in info && info.workflow) {
|
||||
return {
|
||||
workflow: JSON.parse(info.workflow as string),
|
||||
prompt: undefined
|
||||
}
|
||||
} else if (
|
||||
info &&
|
||||
typeof info === 'object' &&
|
||||
'prompt' in info &&
|
||||
info.prompt
|
||||
) {
|
||||
return {
|
||||
workflow: undefined,
|
||||
prompt: JSON.parse(info.prompt as string)
|
||||
}
|
||||
} else {
|
||||
return { workflow: undefined, prompt: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine if a JSON object is in the API JSON format
|
||||
*/
|
||||
function isApiJson(data: unknown) {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
Object.values(data as Record<string, any>).every((v) => v.class_type)
|
||||
)
|
||||
}
|
||||
|
||||
// Register image format handlers
|
||||
IMAGE_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'image/png') {
|
||||
mimeTypeHandlers.set(mimeType, handlePngFile)
|
||||
} else if (mimeType === 'image/webp') {
|
||||
mimeTypeHandlers.set(mimeType, handleWebpFile)
|
||||
} else if (mimeType === 'image/svg+xml') {
|
||||
mimeTypeHandlers.set(mimeType, handleSvgFile)
|
||||
}
|
||||
})
|
||||
|
||||
IMAGE_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.png') {
|
||||
extensionHandlers.set(ext, handlePngFile)
|
||||
} else if (ext === '.webp') {
|
||||
extensionHandlers.set(ext, handleWebpFile)
|
||||
} else if (ext === '.svg') {
|
||||
extensionHandlers.set(ext, handleSvgFile)
|
||||
}
|
||||
})
|
||||
|
||||
// Register audio format handlers
|
||||
AUDIO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'audio/mpeg') {
|
||||
mimeTypeHandlers.set(mimeType, handleMp3File)
|
||||
} else if (mimeType === 'audio/ogg') {
|
||||
mimeTypeHandlers.set(mimeType, handleOggFile)
|
||||
} else if (mimeType === 'audio/flac' || mimeType === 'audio/x-flac') {
|
||||
mimeTypeHandlers.set(mimeType, handleFlacFile)
|
||||
}
|
||||
})
|
||||
|
||||
AUDIO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.mp3') {
|
||||
extensionHandlers.set(ext, handleMp3File)
|
||||
} else if (ext === '.ogg') {
|
||||
extensionHandlers.set(ext, handleOggFile)
|
||||
} else if (ext === '.flac') {
|
||||
extensionHandlers.set(ext, handleFlacFile)
|
||||
}
|
||||
})
|
||||
|
||||
// Register video format handlers
|
||||
VIDEO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'video/webm') {
|
||||
mimeTypeHandlers.set(mimeType, handleWebmFile)
|
||||
} else if (
|
||||
mimeType === 'video/mp4' ||
|
||||
mimeType === 'video/quicktime' ||
|
||||
mimeType === 'video/x-m4v'
|
||||
) {
|
||||
mimeTypeHandlers.set(mimeType, handleMp4File)
|
||||
}
|
||||
})
|
||||
|
||||
VIDEO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.webm') {
|
||||
extensionHandlers.set(ext, handleWebmFile)
|
||||
} else if (ext === '.mp4' || ext === '.mov' || ext === '.m4v') {
|
||||
extensionHandlers.set(ext, handleMp4File)
|
||||
}
|
||||
})
|
||||
|
||||
// Register 3D model format handlers
|
||||
MODEL_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'model/gltf-binary') {
|
||||
mimeTypeHandlers.set(mimeType, handleGlbFile)
|
||||
}
|
||||
})
|
||||
|
||||
MODEL_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.glb') {
|
||||
extensionHandlers.set(ext, handleGlbFile)
|
||||
}
|
||||
})
|
||||
|
||||
// Register data format handlers
|
||||
DATA_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'application/json') {
|
||||
mimeTypeHandlers.set(mimeType, handleJsonFile)
|
||||
}
|
||||
})
|
||||
|
||||
DATA_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.json') {
|
||||
extensionHandlers.set(ext, handleJsonFile)
|
||||
} else if (ext === '.latent' || ext === '.safetensors') {
|
||||
extensionHandlers.set(ext, handleLatentFile)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Gets the appropriate file handler for a given file based on mime type or extension
|
||||
*/
|
||||
export function getFileHandler(file: File): WorkflowFileHandler | null {
|
||||
// First try to match by MIME type
|
||||
if (file.type && mimeTypeHandlers.has(file.type)) {
|
||||
return mimeTypeHandlers.get(file.type) || null
|
||||
}
|
||||
|
||||
// If no MIME type match, try to match by file extension
|
||||
if (file.name) {
|
||||
const extension = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||
if (extension && extensionHandlers.has(extension)) {
|
||||
return extensionHandlers.get(extension) || null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves the correct file from drag and drop events with URI fallback
|
||||
* when direct file processing doesn't yield workflow data
|
||||
*/
|
||||
export async function resolveDragDropFile(
|
||||
file: File,
|
||||
dataTransfer: DataTransfer
|
||||
): Promise<File> {
|
||||
const fileHandler = getFileHandler(file)
|
||||
if (!fileHandler) {
|
||||
return file
|
||||
}
|
||||
|
||||
// First try direct file processing
|
||||
const directResult = await fileHandler(file)
|
||||
|
||||
// If we got workflow data, return the original file
|
||||
if (
|
||||
directResult.workflow ||
|
||||
directResult.prompt ||
|
||||
directResult.parameters ||
|
||||
directResult.jsonTemplateData
|
||||
) {
|
||||
return file
|
||||
}
|
||||
|
||||
// No workflow data found, try URI approach as fallback
|
||||
const validTypes = ['text/uri-list', 'text/x-moz-url']
|
||||
const match = [...dataTransfer.types].find((t) =>
|
||||
validTypes.find((v) => t === v)
|
||||
)
|
||||
|
||||
if (match) {
|
||||
const uri = dataTransfer.getData(match)?.split('\n')?.[0]
|
||||
if (uri) {
|
||||
try {
|
||||
const blob = await (await fetch(uri)).blob()
|
||||
return new File([blob], file.name, { type: blob.type })
|
||||
} catch (error) {
|
||||
console.warn('URI fetch failed, using original file:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return original file as fallback
|
||||
return file
|
||||
}
|
||||
53
src/utils/markdownRendererUtil.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import DOMPurify from 'dompurify'
|
||||
import { Renderer, marked } from 'marked'
|
||||
|
||||
const ALLOWED_TAGS = ['video', 'source']
|
||||
const ALLOWED_ATTRS = [
|
||||
'controls',
|
||||
'autoplay',
|
||||
'loop',
|
||||
'muted',
|
||||
'preload',
|
||||
'poster'
|
||||
]
|
||||
|
||||
// Matches relative src attributes in img, source, and video HTML tags
|
||||
// Captures: 1) opening tag with src=", 2) relative path, 3) closing quote
|
||||
// Excludes absolute paths (starting with /) and URLs (http:// or https://)
|
||||
const MEDIA_SRC_REGEX =
|
||||
/(<(?:img|source|video)[^>]*\ssrc=['"])(?!(?:\/|https?:\/\/))([^'"\s>]+)(['"])/gi
|
||||
|
||||
// Create a marked Renderer that prefixes relative URLs with base
|
||||
export function createMarkdownRenderer(baseUrl?: string): Renderer {
|
||||
const normalizedBase = baseUrl ? baseUrl.replace(/\/+$/, '') : ''
|
||||
const renderer = new Renderer()
|
||||
renderer.image = ({ href, title, text }) => {
|
||||
let src = href
|
||||
if (normalizedBase && !/^(?:\/|https?:\/\/)/.test(href)) {
|
||||
src = `${normalizedBase}/${href}`
|
||||
}
|
||||
const titleAttr = title ? ` title="${title}"` : ''
|
||||
return `<img src="${src}" alt="${text}"${titleAttr} />`
|
||||
}
|
||||
return renderer
|
||||
}
|
||||
|
||||
export function renderMarkdownToHtml(
|
||||
markdown: string,
|
||||
baseUrl?: string
|
||||
): string {
|
||||
if (!markdown) return ''
|
||||
|
||||
let html = marked.parse(markdown, {
|
||||
renderer: createMarkdownRenderer(baseUrl)
|
||||
}) as string
|
||||
|
||||
if (baseUrl) {
|
||||
html = html.replace(MEDIA_SRC_REGEX, `$1${baseUrl}$2$3`)
|
||||
}
|
||||
|
||||
return DOMPurify.sanitize(html, {
|
||||
ADD_TAGS: ALLOWED_TAGS,
|
||||
ADD_ATTR: ALLOWED_ATTRS
|
||||
})
|
||||
}
|
||||
23
src/utils/nodeHelpUtil.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
|
||||
import { NodeSourceType, getNodeSource } from '@/types/nodeSource'
|
||||
|
||||
export function extractCustomNodeName(
|
||||
pythonModule: string | undefined
|
||||
): string | null {
|
||||
const modules = pythonModule?.split('.') || []
|
||||
if (modules.length >= 2 && modules[0] === 'custom_nodes') {
|
||||
return modules[1].split('@')[0]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
export function getNodeHelpBaseUrl(node: ComfyNodeDefImpl): string {
|
||||
const nodeSource = getNodeSource(node.python_module)
|
||||
if (nodeSource.type === NodeSourceType.CustomNodes) {
|
||||
const customNodeName = extractCustomNodeName(node.python_module)
|
||||
if (customNodeName) {
|
||||
return `/extensions/${customNodeName}/docs/`
|
||||
}
|
||||
}
|
||||
return `/docs/${node.name}/`
|
||||
}
|
||||
802
tests-ui/tests/composables/node/useNodePricing.test.ts
Normal file
@@ -0,0 +1,802 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { useNodePricing } from '@/composables/node/useNodePricing'
|
||||
|
||||
// Helper function to create a mock node
|
||||
function createMockNode(
|
||||
nodeTypeName: string,
|
||||
widgets: Array<{ name: string; value: any }> = [],
|
||||
isApiNode = true
|
||||
): LGraphNode {
|
||||
const mockWidgets = widgets.map(({ name, value }) => ({
|
||||
name,
|
||||
value,
|
||||
type: 'combo'
|
||||
})) as IComboWidget[]
|
||||
|
||||
return {
|
||||
id: Math.random().toString(),
|
||||
widgets: mockWidgets,
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: nodeTypeName,
|
||||
api_node: isApiNode
|
||||
}
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
}
|
||||
|
||||
describe('useNodePricing', () => {
|
||||
describe('static pricing', () => {
|
||||
it('should return static price for FluxProCannyNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('FluxProCannyNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.05/Run')
|
||||
})
|
||||
|
||||
it('should return static price for StabilityStableImageUltraNode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('StabilityStableImageUltraNode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return empty string for non-API nodes', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RegularNode', [], false)
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('')
|
||||
})
|
||||
|
||||
it('should return empty string for unknown node types', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('UnknownAPINode')
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - KlingTextToVideoNode', () => {
|
||||
it('should return high price for kling-v2-master model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode', [
|
||||
{ name: 'mode', value: 'standard / 5s / v2-master' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.40/Run')
|
||||
})
|
||||
|
||||
it('should return standard price for kling-v1-6 model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode', [
|
||||
{ name: 'mode', value: 'standard / 5s / v1-6' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28/Run')
|
||||
})
|
||||
|
||||
it('should return range when mode widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - KlingImage2VideoNode', () => {
|
||||
it('should return high price for kling-v2-master model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingImage2VideoNode', [
|
||||
{ name: 'model_name', value: 'v2-master' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.40/Run')
|
||||
})
|
||||
|
||||
it('should return standard price for kling-v1-6 model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingImage2VideoNode', [
|
||||
{ name: 'model_name', value: 'v1-6' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28/Run')
|
||||
})
|
||||
|
||||
it('should return range when model_name widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingImage2VideoNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - OpenAIDalle3', () => {
|
||||
it('should return $0.04 for 1024x1024 standard quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1024' },
|
||||
{ name: 'quality', value: 'standard' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04/Run')
|
||||
})
|
||||
|
||||
it('should return $0.08 for 1024x1024 hd quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1024' },
|
||||
{ name: 'quality', value: 'hd' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return $0.08 for 1792x1024 standard quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1792x1024' },
|
||||
{ name: 'quality', value: 'standard' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return $0.16 for 1792x1024 hd quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1792x1024' },
|
||||
{ name: 'quality', value: 'hd' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.12/Run')
|
||||
})
|
||||
|
||||
it('should return $0.08 for 1024x1792 standard quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1792' },
|
||||
{ name: 'quality', value: 'standard' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return $0.16 for 1024x1792 hd quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1792' },
|
||||
{ name: 'quality', value: 'hd' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.12/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
|
||||
})
|
||||
|
||||
it('should return range when size widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'quality', value: 'hd' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
|
||||
})
|
||||
|
||||
it('should return range when quality widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle3', [
|
||||
{ name: 'size', value: '1024x1024' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - OpenAIDalle2', () => {
|
||||
it('should return $0.02 for 1024x1024 size', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle2', [
|
||||
{ name: 'size', value: '1024x1024' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.02/Run')
|
||||
})
|
||||
|
||||
it('should return $0.018 for 512x512 size', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle2', [
|
||||
{ name: 'size', value: '512x512' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.018/Run')
|
||||
})
|
||||
|
||||
it('should return $0.016 for 256x256 size', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle2', [
|
||||
{ name: 'size', value: '256x256' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.016/Run')
|
||||
})
|
||||
|
||||
it('should return range when size widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIDalle2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.016-0.02/Run (varies with size)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - OpenAIGPTImage1', () => {
|
||||
it('should return high price range for high quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIGPTImage1', [
|
||||
{ name: 'quality', value: 'high' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.167-0.30/Run')
|
||||
})
|
||||
|
||||
it('should return medium price range for medium quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIGPTImage1', [
|
||||
{ name: 'quality', value: 'medium' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.046-0.07/Run')
|
||||
})
|
||||
|
||||
it('should return low price range for low quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIGPTImage1', [
|
||||
{ name: 'quality', value: 'low' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.011-0.02/Run')
|
||||
})
|
||||
|
||||
it('should return range when quality widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('OpenAIGPTImage1', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.011-0.30/Run (varies with quality)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - IdeogramV3', () => {
|
||||
it('should return $0.08 for Quality rendering speed', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [
|
||||
{ name: 'rendering_speed', value: 'Quality' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.08/Run')
|
||||
})
|
||||
|
||||
it('should return $0.06 for Balanced rendering speed', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [
|
||||
{ name: 'rendering_speed', value: 'Balanced' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.06/Run')
|
||||
})
|
||||
|
||||
it('should return $0.03 for Turbo rendering speed', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [
|
||||
{ name: 'rendering_speed', value: 'Turbo' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.03/Run')
|
||||
})
|
||||
|
||||
it('should return range when rendering_speed widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('IdeogramV3', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.03-0.08/Run (varies with rendering speed)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - VeoVideoGenerationNode', () => {
|
||||
it('should return $5.00 for 10s duration', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('VeoVideoGenerationNode', [
|
||||
{ name: 'duration_seconds', value: '10' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$5.00/Run')
|
||||
})
|
||||
|
||||
it('should return $2.50 for 5s duration', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('VeoVideoGenerationNode', [
|
||||
{ name: 'duration_seconds', value: '5' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$2.50/Run')
|
||||
})
|
||||
|
||||
it('should return range when duration widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('VeoVideoGenerationNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$2.50-5.0/Run (varies with duration)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - LumaVideoNode', () => {
|
||||
it('should return $2.19 for ray-flash-2 4K 5s', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('LumaVideoNode', [
|
||||
{ name: 'model', value: 'ray-flash-2' },
|
||||
{ name: 'resolution', value: '4K' },
|
||||
{ name: 'duration', value: '5s' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$2.19/Run')
|
||||
})
|
||||
|
||||
it('should return $6.37 for ray-2 4K 5s', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('LumaVideoNode', [
|
||||
{ name: 'model', value: 'ray-2' },
|
||||
{ name: 'resolution', value: '4K' },
|
||||
{ name: 'duration', value: '5s' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$6.37/Run')
|
||||
})
|
||||
|
||||
it('should return $0.35 for ray-1-6 model', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('LumaVideoNode', [
|
||||
{ name: 'model', value: 'ray-1-6' },
|
||||
{ name: 'resolution', value: '1080p' },
|
||||
{ name: 'duration', value: '5s' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.35/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('LumaVideoNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.14-11.47/Run (varies with model, resolution & duration)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - PixverseTextToVideoNode', () => {
|
||||
it('should return range for 5s 1080p quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PixverseTextToVideoNode', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'quality', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.45-1.2/Run (varies with duration, quality & motion mode)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return range for 5s 540p normal quality', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PixverseTextToVideoNode', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'quality', value: '540p' },
|
||||
{ name: 'motion_mode', value: 'normal' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.45-1.2/Run (varies with duration, quality & motion mode)'
|
||||
)
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PixverseTextToVideoNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe(
|
||||
'$0.45-1.2/Run (varies with duration, quality & motion mode)'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - KlingDualCharacterVideoEffectNode', () => {
|
||||
it('should return range for v2-master 5s mode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingDualCharacterVideoEffectNode', [
|
||||
{ name: 'mode', value: 'standard / 5s / v2-master' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
|
||||
it('should return range for v1-6 5s mode', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingDualCharacterVideoEffectNode', [
|
||||
{ name: 'mode', value: 'standard / 5s / v1-6' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
|
||||
it('should return range when mode widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingDualCharacterVideoEffectNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - KlingSingleImageVideoEffectNode', () => {
|
||||
it('should return $0.28 for fuzzyfuzzy effect', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingSingleImageVideoEffectNode', [
|
||||
{ name: 'effect_scene', value: 'fuzzyfuzzy' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28/Run')
|
||||
})
|
||||
|
||||
it('should return $0.49 for dizzydizzy effect', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingSingleImageVideoEffectNode', [
|
||||
{ name: 'effect_scene', value: 'dizzydizzy' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.49/Run')
|
||||
})
|
||||
|
||||
it('should return range when effect_scene widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('KlingSingleImageVideoEffectNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.28-0.49/Run (varies with effect scene)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - PikaImageToVideoNode2_2', () => {
|
||||
it('should return $0.45 for 5s 1080p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaImageToVideoNode2_2', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'resolution', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.45/Run')
|
||||
})
|
||||
|
||||
it('should return $0.2 for 5s 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaImageToVideoNode2_2', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2/Run')
|
||||
})
|
||||
|
||||
it('should return $1.0 for 10s 1080p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaImageToVideoNode2_2', [
|
||||
{ name: 'duration', value: '10s' },
|
||||
{ name: 'resolution', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.0/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaImageToVideoNode2_2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - PikaScenesV2_2', () => {
|
||||
it('should return $0.3 for 5s 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaScenesV2_2', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.3/Run')
|
||||
})
|
||||
|
||||
it('should return $0.25 for 10s 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaScenesV2_2', [
|
||||
{ name: 'duration', value: '10s' },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.25/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaScenesV2_2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dynamic pricing - PikaStartEndFrameNode2_2', () => {
|
||||
it('should return $0.2 for 5s 720p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaStartEndFrameNode2_2', [
|
||||
{ name: 'duration', value: '5s' },
|
||||
{ name: 'resolution', value: '720p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2/Run')
|
||||
})
|
||||
|
||||
it('should return $1.0 for 10s 1080p', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaStartEndFrameNode2_2', [
|
||||
{ name: 'duration', value: '10s' },
|
||||
{ name: 'resolution', value: '1080p' }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$1.0/Run')
|
||||
})
|
||||
|
||||
it('should return range when widgets are missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('PikaStartEndFrameNode2_2', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.2-1.0/Run (varies with duration & resolution)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('error handling', () => {
|
||||
it('should gracefully handle errors in dynamic pricing functions', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
// Create a node with malformed widget data that could cause errors
|
||||
const node = {
|
||||
id: 'test-node',
|
||||
widgets: null, // This could cause errors when accessing .find()
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: 'KlingTextToVideoNode',
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
|
||||
// Should not throw an error and return empty string as fallback
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.14-2.80/Run (varies with model, mode & duration)')
|
||||
})
|
||||
|
||||
it('should handle completely broken widget structure', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
// Create a node with no widgets property at all
|
||||
const node = {
|
||||
id: 'test-node',
|
||||
// No widgets property
|
||||
constructor: {
|
||||
nodeData: {
|
||||
name: 'OpenAIDalle3',
|
||||
api_node: true
|
||||
}
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
|
||||
// Should gracefully fall back to the default range
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04-0.12/Run (varies with size & quality)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('helper methods', () => {
|
||||
describe('getNodePricingConfig', () => {
|
||||
it('should return pricing config for known API nodes', () => {
|
||||
const { getNodePricingConfig } = useNodePricing()
|
||||
const node = createMockNode('KlingTextToVideoNode')
|
||||
|
||||
const config = getNodePricingConfig(node)
|
||||
expect(config).toBeDefined()
|
||||
expect(typeof config.displayPrice).toBe('function')
|
||||
})
|
||||
|
||||
it('should return undefined for unknown nodes', () => {
|
||||
const { getNodePricingConfig } = useNodePricing()
|
||||
const node = createMockNode('UnknownNode')
|
||||
|
||||
const config = getNodePricingConfig(node)
|
||||
expect(config).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('getRelevantWidgetNames', () => {
|
||||
it('should return correct widget names for KlingTextToVideoNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('KlingTextToVideoNode')
|
||||
expect(widgetNames).toEqual(['mode', 'model_name', 'duration'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for KlingImage2VideoNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('KlingImage2VideoNode')
|
||||
expect(widgetNames).toEqual(['mode', 'model_name', 'duration'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for OpenAIDalle3', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('OpenAIDalle3')
|
||||
expect(widgetNames).toEqual(['size', 'quality'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for VeoVideoGenerationNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('VeoVideoGenerationNode')
|
||||
expect(widgetNames).toEqual(['duration_seconds'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for LumaVideoNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('LumaVideoNode')
|
||||
expect(widgetNames).toEqual(['model', 'resolution', 'duration'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for KlingSingleImageVideoEffectNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames(
|
||||
'KlingSingleImageVideoEffectNode'
|
||||
)
|
||||
expect(widgetNames).toEqual(['effect_scene'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for PikaImageToVideoNode2_2', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('PikaImageToVideoNode2_2')
|
||||
expect(widgetNames).toEqual(['duration', 'resolution'])
|
||||
})
|
||||
|
||||
it('should return empty array for unknown node types', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('UnknownNode')
|
||||
expect(widgetNames).toEqual([])
|
||||
})
|
||||
|
||||
describe('Recraft nodes with n parameter', () => {
|
||||
it('should return correct widget names for RecraftTextToImageNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('RecraftTextToImageNode')
|
||||
expect(widgetNames).toEqual(['n'])
|
||||
})
|
||||
|
||||
it('should return correct widget names for RecraftTextToVectorNode', () => {
|
||||
const { getRelevantWidgetNames } = useNodePricing()
|
||||
|
||||
const widgetNames = getRelevantWidgetNames('RecraftTextToVectorNode')
|
||||
expect(widgetNames).toEqual(['n'])
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Recraft nodes dynamic pricing', () => {
|
||||
it('should calculate dynamic pricing for RecraftTextToImageNode based on n value', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RecraftTextToImageNode', [
|
||||
{ name: 'n', value: 3 }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.12/Run') // 0.04 * 3
|
||||
})
|
||||
|
||||
it('should calculate dynamic pricing for RecraftTextToVectorNode based on n value', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RecraftTextToVectorNode', [
|
||||
{ name: 'n', value: 2 }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.16/Run') // 0.08 * 2
|
||||
})
|
||||
|
||||
it('should fall back to static display when n widget is missing', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RecraftTextToImageNode', [])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04 x n/Run')
|
||||
})
|
||||
|
||||
it('should handle edge case when n value is 1', () => {
|
||||
const { getNodeDisplayPrice } = useNodePricing()
|
||||
const node = createMockNode('RecraftImageInpaintingNode', [
|
||||
{ name: 'n', value: 1 }
|
||||
])
|
||||
|
||||
const price = getNodeDisplayPrice(node)
|
||||
expect(price).toBe('$0.04/Run') // 0.04 * 1
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
301
tests-ui/tests/composables/useTemplateWorkflows.test.ts
Normal file
@@ -0,0 +1,301 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useTemplateWorkflows } from '@/composables/useTemplateWorkflows'
|
||||
import { useWorkflowTemplatesStore } from '@/stores/workflowTemplatesStore'
|
||||
|
||||
// Mock the store
|
||||
vi.mock('@/stores/workflowTemplatesStore', () => ({
|
||||
useWorkflowTemplatesStore: vi.fn()
|
||||
}))
|
||||
|
||||
// Mock the API
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: vi.fn((path) => `mock-file-url${path}`),
|
||||
apiURL: vi.fn((path) => `mock-api-url${path}`)
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock the app
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: {
|
||||
loadGraphData: vi.fn()
|
||||
}
|
||||
}))
|
||||
|
||||
// Mock Vue I18n
|
||||
vi.mock('vue-i18n', () => ({
|
||||
useI18n: () => ({
|
||||
t: vi.fn((key, fallback) => fallback || key)
|
||||
})
|
||||
}))
|
||||
|
||||
// Mock the dialog store
|
||||
vi.mock('@/stores/dialogStore', () => ({
|
||||
useDialogStore: vi.fn(() => ({
|
||||
closeDialog: vi.fn()
|
||||
}))
|
||||
}))
|
||||
|
||||
// Mock fetch
|
||||
global.fetch = vi.fn()
|
||||
|
||||
describe('useTemplateWorkflows', () => {
|
||||
let mockWorkflowTemplatesStore: any
|
||||
|
||||
beforeEach(() => {
|
||||
mockWorkflowTemplatesStore = {
|
||||
isLoaded: false,
|
||||
loadWorkflowTemplates: vi.fn().mockResolvedValue(true),
|
||||
groupedTemplates: [
|
||||
{
|
||||
label: 'ComfyUI Examples',
|
||||
modules: [
|
||||
{
|
||||
moduleName: 'all',
|
||||
title: 'All',
|
||||
localizedTitle: 'All Templates',
|
||||
templates: [
|
||||
{
|
||||
name: 'template1',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
sourceModule: 'default',
|
||||
localizedTitle: 'Template 1'
|
||||
},
|
||||
{
|
||||
name: 'template2',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
sourceModule: 'custom-module',
|
||||
description: 'A custom template'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
moduleName: 'default',
|
||||
title: 'Default',
|
||||
localizedTitle: 'Default Templates',
|
||||
templates: [
|
||||
{
|
||||
name: 'template1',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
localizedTitle: 'Template 1',
|
||||
localizedDescription: 'A default template'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
vi.mocked(useWorkflowTemplatesStore).mockReturnValue(
|
||||
mockWorkflowTemplatesStore
|
||||
)
|
||||
|
||||
// Mock fetch response
|
||||
vi.mocked(fetch).mockResolvedValue({
|
||||
json: vi.fn().mockResolvedValue({ workflow: 'data' })
|
||||
} as unknown as Response)
|
||||
})
|
||||
|
||||
it('should load templates from store', async () => {
|
||||
const { loadTemplates, isTemplatesLoaded } = useTemplateWorkflows()
|
||||
|
||||
expect(isTemplatesLoaded.value).toBe(false)
|
||||
|
||||
await loadTemplates()
|
||||
|
||||
expect(mockWorkflowTemplatesStore.loadWorkflowTemplates).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should select the first template category', () => {
|
||||
const { selectFirstTemplateCategory, selectedTemplate } =
|
||||
useTemplateWorkflows()
|
||||
|
||||
selectFirstTemplateCategory()
|
||||
|
||||
expect(selectedTemplate.value).toEqual(
|
||||
mockWorkflowTemplatesStore.groupedTemplates[0].modules[0]
|
||||
)
|
||||
})
|
||||
|
||||
it('should select a template category', () => {
|
||||
const { selectTemplateCategory, selectedTemplate } = useTemplateWorkflows()
|
||||
const category = mockWorkflowTemplatesStore.groupedTemplates[0].modules[1] // Default category
|
||||
|
||||
const result = selectTemplateCategory(category)
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(selectedTemplate.value).toEqual(category)
|
||||
})
|
||||
|
||||
it('should format template thumbnails correctly for default templates', () => {
|
||||
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
|
||||
const template = {
|
||||
name: 'test-template',
|
||||
mediaSubtype: 'jpg',
|
||||
mediaType: 'image',
|
||||
description: 'Test template'
|
||||
}
|
||||
|
||||
const url = getTemplateThumbnailUrl(template, 'default', '1')
|
||||
|
||||
expect(url).toBe('mock-file-url/templates/test-template-1.jpg')
|
||||
})
|
||||
|
||||
it('should format template thumbnails correctly for custom templates', () => {
|
||||
const { getTemplateThumbnailUrl } = useTemplateWorkflows()
|
||||
const template = {
|
||||
name: 'test-template',
|
||||
mediaSubtype: 'jpg',
|
||||
mediaType: 'image',
|
||||
description: 'Test template'
|
||||
}
|
||||
|
||||
const url = getTemplateThumbnailUrl(template, 'custom-module')
|
||||
|
||||
expect(url).toBe(
|
||||
'mock-api-url/workflow_templates/custom-module/test-template.jpg'
|
||||
)
|
||||
})
|
||||
|
||||
it('should format template titles correctly', () => {
|
||||
const { getTemplateTitle } = useTemplateWorkflows()
|
||||
|
||||
// Default template with localized title
|
||||
const titleWithLocalized = getTemplateTitle(
|
||||
{
|
||||
name: 'test',
|
||||
localizedTitle: 'Localized Title',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
},
|
||||
'default'
|
||||
)
|
||||
expect(titleWithLocalized).toBe('Localized Title')
|
||||
|
||||
// Default template without localized title
|
||||
const titleWithFallback = getTemplateTitle(
|
||||
{
|
||||
name: 'test',
|
||||
title: 'Title',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
},
|
||||
'default'
|
||||
)
|
||||
expect(titleWithFallback).toBe('Title')
|
||||
|
||||
// Custom template
|
||||
const customTitle = getTemplateTitle(
|
||||
{
|
||||
name: 'test-template',
|
||||
title: 'Custom Title',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
},
|
||||
'custom-module'
|
||||
)
|
||||
expect(customTitle).toBe('Custom Title')
|
||||
|
||||
// Fallback to name
|
||||
const nameOnly = getTemplateTitle(
|
||||
{
|
||||
name: 'name-only',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
},
|
||||
'custom-module'
|
||||
)
|
||||
expect(nameOnly).toBe('name-only')
|
||||
})
|
||||
|
||||
it('should format template descriptions correctly', () => {
|
||||
const { getTemplateDescription } = useTemplateWorkflows()
|
||||
|
||||
// Default template with localized description
|
||||
const descWithLocalized = getTemplateDescription(
|
||||
{
|
||||
name: 'test',
|
||||
localizedDescription: 'Localized Description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg',
|
||||
description: 'Test'
|
||||
},
|
||||
'default'
|
||||
)
|
||||
expect(descWithLocalized).toBe('Localized Description')
|
||||
|
||||
// Custom template with description
|
||||
const customDesc = getTemplateDescription(
|
||||
{
|
||||
name: 'test',
|
||||
description: 'custom-template_description',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'jpg'
|
||||
},
|
||||
'custom-module'
|
||||
)
|
||||
expect(customDesc).toBe('custom template description')
|
||||
})
|
||||
|
||||
it('should load a template from the "All" category', async () => {
|
||||
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
|
||||
|
||||
// Set the store as loaded
|
||||
mockWorkflowTemplatesStore.isLoaded = true
|
||||
|
||||
// Load a template from the "All" category
|
||||
const result = await loadWorkflowTemplate('template1', 'all')
|
||||
await flushPromises()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
|
||||
expect(loadingTemplateId.value).toBe(null) // Should reset after loading
|
||||
})
|
||||
|
||||
it('should load a template from a regular category', async () => {
|
||||
const { loadWorkflowTemplate } = useTemplateWorkflows()
|
||||
|
||||
// Set the store as loaded
|
||||
mockWorkflowTemplatesStore.isLoaded = true
|
||||
|
||||
// Load a template from the default category
|
||||
const result = await loadWorkflowTemplate('template1', 'default')
|
||||
await flushPromises()
|
||||
|
||||
expect(result).toBe(true)
|
||||
expect(fetch).toHaveBeenCalledWith('mock-file-url/templates/template1.json')
|
||||
})
|
||||
|
||||
it('should handle errors when loading templates', async () => {
|
||||
const { loadWorkflowTemplate, loadingTemplateId } = useTemplateWorkflows()
|
||||
|
||||
// Set the store as loaded
|
||||
mockWorkflowTemplatesStore.isLoaded = true
|
||||
|
||||
// Mock fetch to throw an error
|
||||
vi.mocked(fetch).mockRejectedValueOnce(new Error('Failed to fetch'))
|
||||
|
||||
// Spy on console.error
|
||||
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
// Load a template that will fail
|
||||
const result = await loadWorkflowTemplate('error-template', 'default')
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(consoleSpy).toHaveBeenCalled()
|
||||
expect(loadingTemplateId.value).toBe(null) // Should reset even after error
|
||||
|
||||
// Restore console.error
|
||||
consoleSpy.mockRestore()
|
||||
})
|
||||
})
|
||||
183
tests-ui/tests/composables/useWatchWidget.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
import type { LGraphNode } from '@comfyorg/litegraph'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useComputedWithWidgetWatch } from '@/composables/node/useWatchWidget'
|
||||
|
||||
// Mock useChainCallback
|
||||
vi.mock('@/composables/functional/useChainCallback', () => ({
|
||||
useChainCallback: vi.fn((original, newCallback) => {
|
||||
return function (this: any, ...args: any[]) {
|
||||
original?.call(this, ...args)
|
||||
newCallback.call(this, ...args)
|
||||
}
|
||||
})
|
||||
}))
|
||||
|
||||
describe('useComputedWithWidgetWatch', () => {
|
||||
const createMockNode = (
|
||||
widgets: Array<{
|
||||
name: string
|
||||
value: any
|
||||
callback?: (...args: any[]) => void
|
||||
}> = []
|
||||
) => {
|
||||
const mockNode = {
|
||||
widgets: widgets.map((widget) => ({
|
||||
name: widget.name,
|
||||
value: widget.value,
|
||||
callback: widget.callback
|
||||
})),
|
||||
graph: {
|
||||
setDirtyCanvas: vi.fn()
|
||||
}
|
||||
} as unknown as LGraphNode
|
||||
|
||||
return mockNode
|
||||
}
|
||||
|
||||
it('should create a reactive computed that responds to widget changes', async () => {
|
||||
const mockNode = createMockNode([
|
||||
{ name: 'width', value: 100 },
|
||||
{ name: 'height', value: 200 }
|
||||
])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
|
||||
|
||||
const computedValue = computedWithWidgetWatch(() => {
|
||||
const width =
|
||||
(mockNode.widgets?.find((w) => w.name === 'width')?.value as number) ||
|
||||
0
|
||||
const height =
|
||||
(mockNode.widgets?.find((w) => w.name === 'height')?.value as number) ||
|
||||
0
|
||||
return width * height
|
||||
})
|
||||
|
||||
// Initial value should be computed correctly
|
||||
expect(computedValue.value).toBe(20000)
|
||||
|
||||
// Change widget value and trigger callback
|
||||
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
|
||||
if (widthWidget) {
|
||||
widthWidget.value = 150
|
||||
;(widthWidget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Computed should update
|
||||
expect(computedValue.value).toBe(30000)
|
||||
})
|
||||
|
||||
it('should only observe specific widgets when widgetNames is provided', async () => {
|
||||
const mockNode = createMockNode([
|
||||
{ name: 'width', value: 100 },
|
||||
{ name: 'height', value: 200 },
|
||||
{ name: 'depth', value: 50 }
|
||||
])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
widgetNames: ['width', 'height']
|
||||
})
|
||||
|
||||
const computedValue = computedWithWidgetWatch(() => {
|
||||
return mockNode.widgets?.map((w) => w.value).join('-') || ''
|
||||
})
|
||||
|
||||
expect(computedValue.value).toBe('100-200-50')
|
||||
|
||||
// Change observed widget
|
||||
const widthWidget = mockNode.widgets?.find((w) => w.name === 'width')
|
||||
if (widthWidget) {
|
||||
widthWidget.value = 150
|
||||
;(widthWidget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
expect(computedValue.value).toBe('150-200-50')
|
||||
|
||||
// Change non-observed widget - should not trigger update
|
||||
const depthWidget = mockNode.widgets?.find((w) => w.name === 'depth')
|
||||
if (depthWidget) {
|
||||
depthWidget.value = 75
|
||||
// Depth widget callback should not have been modified
|
||||
expect(depthWidget.callback).toBeUndefined()
|
||||
}
|
||||
})
|
||||
|
||||
it('should trigger canvas redraw when triggerCanvasRedraw is true', async () => {
|
||||
const mockNode = createMockNode([{ name: 'value', value: 10 }])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
triggerCanvasRedraw: true
|
||||
})
|
||||
|
||||
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
|
||||
|
||||
// Change widget value
|
||||
const widget = mockNode.widgets?.[0]
|
||||
if (widget) {
|
||||
widget.value = 20
|
||||
;(widget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Canvas redraw should have been triggered
|
||||
expect(mockNode.graph?.setDirtyCanvas).toHaveBeenCalledWith(true, true)
|
||||
})
|
||||
|
||||
it('should not trigger canvas redraw when triggerCanvasRedraw is false', async () => {
|
||||
const mockNode = createMockNode([{ name: 'value', value: 10 }])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode, {
|
||||
triggerCanvasRedraw: false
|
||||
})
|
||||
|
||||
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
|
||||
|
||||
// Change widget value
|
||||
const widget = mockNode.widgets?.[0]
|
||||
if (widget) {
|
||||
widget.value = 20
|
||||
;(widget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Canvas redraw should not have been triggered
|
||||
expect(mockNode.graph?.setDirtyCanvas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should handle nodes without widgets gracefully', () => {
|
||||
const mockNode = createMockNode([])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
|
||||
|
||||
const computedValue = computedWithWidgetWatch(() => 'no widgets')
|
||||
|
||||
expect(computedValue.value).toBe('no widgets')
|
||||
})
|
||||
|
||||
it('should chain with existing widget callbacks', async () => {
|
||||
const existingCallback = vi.fn()
|
||||
const mockNode = createMockNode([
|
||||
{ name: 'value', value: 10, callback: existingCallback }
|
||||
])
|
||||
|
||||
const computedWithWidgetWatch = useComputedWithWidgetWatch(mockNode)
|
||||
computedWithWidgetWatch(() => mockNode.widgets?.[0]?.value || 0)
|
||||
|
||||
// Trigger widget callback
|
||||
const widget = mockNode.widgets?.[0]
|
||||
if (widget) {
|
||||
;(widget.callback as any)?.()
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
|
||||
// Both existing callback and our callback should have been called
|
||||
expect(existingCallback).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
399
tests-ui/tests/store/nodeHelpStore.test.ts
Normal file
@@ -0,0 +1,399 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import { useNodeHelpStore } from '@/stores/workspace/nodeHelpStore'
|
||||
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
fileURL: vi.fn((url) => url)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/i18n', () => ({
|
||||
i18n: {
|
||||
global: {
|
||||
locale: {
|
||||
value: 'en'
|
||||
}
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/types/nodeSource', () => ({
|
||||
NodeSourceType: {
|
||||
Core: 'core',
|
||||
CustomNodes: 'custom_nodes'
|
||||
},
|
||||
getNodeSource: vi.fn((pythonModule) => {
|
||||
if (pythonModule?.startsWith('custom_nodes.')) {
|
||||
return { type: 'custom_nodes' }
|
||||
}
|
||||
return { type: 'core' }
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock('dompurify', () => ({
|
||||
default: {
|
||||
sanitize: vi.fn((html) => html)
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('marked', () => ({
|
||||
marked: {
|
||||
parse: vi.fn((markdown, options) => {
|
||||
if (options?.renderer) {
|
||||
if (markdown.includes('![')) {
|
||||
const matches = markdown.match(/!\[(.*?)\]\((.*?)\)/)
|
||||
if (matches) {
|
||||
const [, text, href] = matches
|
||||
return options.renderer.image({ href, text, title: '' })
|
||||
}
|
||||
}
|
||||
}
|
||||
return `<p>${markdown}</p>`
|
||||
})
|
||||
},
|
||||
Renderer: class Renderer {
|
||||
image = vi.fn(
|
||||
({ href, title, text }) =>
|
||||
`<img src="${href}" alt="${text}"${title ? ` title="${title}"` : ''} />`
|
||||
)
|
||||
link = vi.fn(
|
||||
({ href, title, text }) =>
|
||||
`<a href="${href}"${title ? ` title="${title}"` : ''}>${text}</a>`
|
||||
)
|
||||
}
|
||||
}))
|
||||
|
||||
describe('nodeHelpStore', () => {
|
||||
// Define a mock node for testing
|
||||
const mockCoreNode = {
|
||||
name: 'TestNode',
|
||||
display_name: 'Test Node',
|
||||
description: 'A test node',
|
||||
inputs: {},
|
||||
outputs: [],
|
||||
python_module: 'comfy.test_node'
|
||||
}
|
||||
|
||||
const mockCustomNode = {
|
||||
name: 'CustomNode',
|
||||
display_name: 'Custom Node',
|
||||
description: 'A custom node',
|
||||
inputs: {},
|
||||
outputs: [],
|
||||
python_module: 'custom_nodes.test_module.custom@1.0.0'
|
||||
}
|
||||
|
||||
// Mock fetch responses
|
||||
const mockFetch = vi.fn()
|
||||
global.fetch = mockFetch
|
||||
|
||||
beforeEach(() => {
|
||||
// Setup Pinia
|
||||
setActivePinia(createPinia())
|
||||
mockFetch.mockReset()
|
||||
})
|
||||
|
||||
it('should initialize with empty state', () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
expect(nodeHelpStore.currentHelpNode).toBeNull()
|
||||
expect(nodeHelpStore.isHelpOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('should open help for a node', () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
|
||||
expect(nodeHelpStore.currentHelpNode).toStrictEqual(mockCoreNode)
|
||||
expect(nodeHelpStore.isHelpOpen).toBe(true)
|
||||
})
|
||||
|
||||
it('should close help', () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
expect(nodeHelpStore.isHelpOpen).toBe(true)
|
||||
|
||||
nodeHelpStore.closeHelp()
|
||||
expect(nodeHelpStore.currentHelpNode).toBeNull()
|
||||
expect(nodeHelpStore.isHelpOpen).toBe(false)
|
||||
})
|
||||
|
||||
it('should generate correct baseUrl for core nodes', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
await nextTick()
|
||||
|
||||
expect(nodeHelpStore.baseUrl).toBe(`/docs/${mockCoreNode.name}/`)
|
||||
})
|
||||
|
||||
it('should generate correct baseUrl for custom nodes', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
nodeHelpStore.openHelp(mockCustomNode as any)
|
||||
await nextTick()
|
||||
|
||||
expect(nodeHelpStore.baseUrl).toBe('/extensions/test_module/docs/')
|
||||
})
|
||||
|
||||
it('should render markdown content correctly', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test Help\nThis is test help content'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
await flushPromises()
|
||||
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
'This is test help content'
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle fetch errors and fall back to description', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Not Found'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
await flushPromises()
|
||||
|
||||
expect(nodeHelpStore.error).toBe('Not Found')
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(mockCoreNode.description)
|
||||
})
|
||||
|
||||
it('should include alt attribute for images', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => ''
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCustomNode as any)
|
||||
await flushPromises()
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="image"')
|
||||
})
|
||||
|
||||
it('should prefix relative video src in custom nodes', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '<video src="video.mp4"></video>'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCustomNode as any)
|
||||
await flushPromises()
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
'src="/extensions/test_module/docs/video.mp4"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative video src for core nodes with node-specific base URL', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '<video src="video.mp4"></video>'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
await flushPromises()
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video.mp4"`
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative source src in custom nodes', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'<video><source src="video.mp4" type="video/mp4" /></video>'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCustomNode as any)
|
||||
await flushPromises()
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
'src="/extensions/test_module/docs/video.mp4"'
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative source src for core nodes with node-specific base URL', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'<video><source src="video.webm" type="video/webm" /></video>'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
await flushPromises()
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video.webm"`
|
||||
)
|
||||
})
|
||||
|
||||
it('should handle loading state', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockImplementationOnce(() => new Promise(() => {})) // Never resolves
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
await nextTick()
|
||||
|
||||
expect(nodeHelpStore.isLoading).toBe(true)
|
||||
})
|
||||
|
||||
it('should try fallback URL for custom nodes', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
statusText: 'Not Found'
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Fallback content'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCustomNode as any)
|
||||
await flushPromises()
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/extensions/test_module/docs/CustomNode/en.md'
|
||||
)
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
'/extensions/test_module/docs/CustomNode.md'
|
||||
)
|
||||
})
|
||||
|
||||
it('should prefix relative img src in raw HTML for custom nodes', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test\n<img src="image.png" alt="Test image">'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCustomNode as any)
|
||||
await flushPromises()
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
'src="/extensions/test_module/docs/image.png"'
|
||||
)
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"')
|
||||
})
|
||||
|
||||
it('should prefix relative img src in raw HTML for core nodes', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '# Test\n<img src="image.png" alt="Test image">'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
await flushPromises()
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/image.png"`
|
||||
)
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Test image"')
|
||||
})
|
||||
|
||||
it('should not prefix absolute img src in raw HTML', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => '<img src="/absolute/image.png" alt="Absolute">'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCustomNode as any)
|
||||
await flushPromises()
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
'src="/absolute/image.png"'
|
||||
)
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="Absolute"')
|
||||
})
|
||||
|
||||
it('should not prefix external img src in raw HTML', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () =>
|
||||
'<img src="https://example.com/image.png" alt="External">'
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCustomNode as any)
|
||||
await flushPromises()
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
'src="https://example.com/image.png"'
|
||||
)
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain('alt="External"')
|
||||
})
|
||||
|
||||
it('should handle various quote styles in media src attributes', async () => {
|
||||
const nodeHelpStore = useNodeHelpStore()
|
||||
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
text: async () => `# Media Test
|
||||
|
||||
Testing quote styles in properly formed HTML:
|
||||
|
||||
<video src="video1.mp4" controls></video>
|
||||
<video src='video2.mp4' controls></video>
|
||||
<img src="image1.png" alt="Double quotes">
|
||||
<img src='image2.png' alt='Single quotes'>
|
||||
|
||||
<video controls>
|
||||
<source src="video3.mp4" type="video/mp4">
|
||||
<source src='video3.webm' type='video/webm'>
|
||||
</video>
|
||||
|
||||
The MEDIA_SRC_REGEX handles both single and double quotes in img, video and source tags.`
|
||||
})
|
||||
|
||||
nodeHelpStore.openHelp(mockCoreNode as any)
|
||||
await flushPromises()
|
||||
|
||||
// Check that all media elements with different quote styles are prefixed correctly
|
||||
// Double quotes remain as double quotes
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video1.mp4"`
|
||||
)
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/image1.png"`
|
||||
)
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
`src="/docs/${mockCoreNode.name}/video3.mp4"`
|
||||
)
|
||||
|
||||
// Single quotes remain as single quotes in the output
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
`src='/docs/${mockCoreNode.name}/video2.mp4'`
|
||||
)
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
`src='/docs/${mockCoreNode.name}/image2.png'`
|
||||
)
|
||||
expect(nodeHelpStore.renderedHelpHtml).toContain(
|
||||
`src='/docs/${mockCoreNode.name}/video3.webm'`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,127 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
AUDIO_WORKFLOW_FORMATS,
|
||||
DATA_WORKFLOW_FORMATS,
|
||||
IMAGE_WORKFLOW_FORMATS,
|
||||
MODEL_WORKFLOW_FORMATS,
|
||||
VIDEO_WORKFLOW_FORMATS
|
||||
} from '../../../src/constants/supportedWorkflowFormats'
|
||||
import {
|
||||
extensionHandlers,
|
||||
getFileHandler,
|
||||
mimeTypeHandlers
|
||||
} from '../../../src/utils/fileHandlers'
|
||||
|
||||
describe('fileHandlers', () => {
|
||||
describe('handler registrations', () => {
|
||||
it('should register handlers for all image MIME types', () => {
|
||||
IMAGE_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
|
||||
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should register handlers for all image extensions', () => {
|
||||
IMAGE_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
expect(extensionHandlers.has(ext)).toBe(true)
|
||||
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should register handlers for all audio MIME types', () => {
|
||||
AUDIO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
|
||||
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should register handlers for all audio extensions', () => {
|
||||
AUDIO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
expect(extensionHandlers.has(ext)).toBe(true)
|
||||
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should register handlers for all video MIME types', () => {
|
||||
VIDEO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
|
||||
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should register handlers for all video extensions', () => {
|
||||
VIDEO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
expect(extensionHandlers.has(ext)).toBe(true)
|
||||
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should register handlers for all 3D model MIME types', () => {
|
||||
MODEL_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
|
||||
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should register handlers for all 3D model extensions', () => {
|
||||
MODEL_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
expect(extensionHandlers.has(ext)).toBe(true)
|
||||
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should register handlers for all data MIME types', () => {
|
||||
DATA_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
expect(mimeTypeHandlers.has(mimeType)).toBe(true)
|
||||
expect(mimeTypeHandlers.get(mimeType)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
|
||||
it('should register handlers for all data extensions', () => {
|
||||
DATA_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
expect(extensionHandlers.has(ext)).toBe(true)
|
||||
expect(extensionHandlers.get(ext)).toBeTypeOf('function')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('getFileHandler', () => {
|
||||
it('should return a handler when a file with a recognized MIME type is provided', () => {
|
||||
const file = new File(['test content'], 'test.png', { type: 'image/png' })
|
||||
const handler = getFileHandler(file)
|
||||
expect(handler).not.toBeNull()
|
||||
expect(handler).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('should return a handler when a file with a recognized extension but no MIME type is provided', () => {
|
||||
// File with empty MIME type but recognizable extension
|
||||
const file = new File(['test content'], 'test.webp', { type: '' })
|
||||
const handler = getFileHandler(file)
|
||||
expect(handler).not.toBeNull()
|
||||
expect(handler).toBeTypeOf('function')
|
||||
})
|
||||
|
||||
it('should return null when no handler is found for the file type', () => {
|
||||
const file = new File(['test content'], 'test.txt', {
|
||||
type: 'text/plain'
|
||||
})
|
||||
const handler = getFileHandler(file)
|
||||
expect(handler).toBeNull()
|
||||
})
|
||||
|
||||
it('should prioritize MIME type over extension when both are present and different', () => {
|
||||
// A file with a JSON MIME type but SVG extension
|
||||
const file = new File(['{}'], 'test.svg', { type: 'application/json' })
|
||||
const handler = getFileHandler(file)
|
||||
|
||||
// Make a shadow copy of the handlers for comparison
|
||||
const jsonHandler = mimeTypeHandlers.get('application/json')
|
||||
const svgHandler = extensionHandlers.get('.svg')
|
||||
|
||||
// The handler should match the JSON handler, not the SVG handler
|
||||
expect(handler).toBe(jsonHandler)
|
||||
expect(handler).not.toBe(svgHandler)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,8 @@
|
||||
"*.mts",
|
||||
"*.config.js",
|
||||
"browser_tests/**/*.ts",
|
||||
"scripts/**/*.js",
|
||||
"scripts/**/*.ts",
|
||||
"tests-ui/**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,11 @@ import { createHtmlPlugin } from 'vite-plugin-html'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import type { UserConfigExport } from 'vitest/config'
|
||||
|
||||
import { comfyAPIPlugin, generateImportMapPlugin } from './build/plugins'
|
||||
import {
|
||||
addElementVnodeExportPlugin,
|
||||
comfyAPIPlugin,
|
||||
generateImportMapPlugin
|
||||
} from './build/plugins'
|
||||
|
||||
dotenv.config()
|
||||
|
||||
@@ -52,6 +56,18 @@ export default defineConfig({
|
||||
target: DEV_SERVER_COMFYUI_URL
|
||||
},
|
||||
|
||||
// Proxy extension assets (images/videos) under /extensions to the ComfyUI backend
|
||||
'/extensions': {
|
||||
target: DEV_SERVER_COMFYUI_URL,
|
||||
changeOrigin: true
|
||||
},
|
||||
|
||||
// Proxy docs markdown from backend
|
||||
'/docs': {
|
||||
target: DEV_SERVER_COMFYUI_URL,
|
||||
changeOrigin: true
|
||||
},
|
||||
|
||||
...(!DISABLE_TEMPLATES_PROXY
|
||||
? {
|
||||
'/templates': {
|
||||
@@ -73,40 +89,11 @@ export default defineConfig({
|
||||
: [vue()]),
|
||||
comfyAPIPlugin(IS_DEV),
|
||||
generateImportMapPlugin([
|
||||
{
|
||||
name: 'vue',
|
||||
pattern: 'vue',
|
||||
entry: './dist/vue.esm-browser.prod.js'
|
||||
},
|
||||
{
|
||||
name: 'vue-i18n',
|
||||
pattern: 'vue-i18n',
|
||||
entry: './dist/vue-i18n.esm-browser.prod.js'
|
||||
},
|
||||
{
|
||||
name: 'primevue',
|
||||
pattern: /^primevue\/?.*/,
|
||||
entry: './index.mjs',
|
||||
recursiveDependence: true
|
||||
},
|
||||
{
|
||||
name: '@primevue/themes',
|
||||
pattern: /^@primevue\/themes\/?.*/,
|
||||
entry: './index.mjs',
|
||||
recursiveDependence: true
|
||||
},
|
||||
{
|
||||
name: '@primevue/forms',
|
||||
pattern: /^@primevue\/forms\/?.*/,
|
||||
entry: './index.mjs',
|
||||
recursiveDependence: true,
|
||||
override: {
|
||||
'@primeuix/forms': {
|
||||
entry: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
{ name: 'vue', pattern: /[\\/]node_modules[\\/]vue[\\/]/ },
|
||||
{ name: 'primevue', pattern: /[\\/]node_modules[\\/]primevue[\\/]/ },
|
||||
{ name: 'vue-i18n', pattern: /[\\/]node_modules[\\/]vue-i18n[\\/]/ }
|
||||
]),
|
||||
addElementVnodeExportPlugin(),
|
||||
|
||||
Icons({
|
||||
compiler: 'vue3'
|
||||
|
||||