Merge branch 'main' into feat/template-version-setting
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
|
||||
|
||||
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 }}'
|
||||
|
||||
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 }}'
|
||||
|
||||
8
.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"playwright": {
|
||||
"command": "npx",
|
||||
"args": ["-y", "@executeautomation/playwright-mcp-server"]
|
||||
}
|
||||
}
|
||||
}
|
||||
22
CLAUDE.md
@@ -4,7 +4,7 @@
|
||||
- When trying to set tailwind classes for dark theme, use "dark-theme:" prefix rather than "dark:"
|
||||
- Never add lines to PR descriptions that say "Generated with Claude Code"
|
||||
- When making PR names and commit messages, if you are going to add a prefix like "docs:", "feat:", "bugfix:", use square brackets around the prefix term and do not use a colon (e.g., should be "[docs]" rather than "docs:").
|
||||
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading speicifc branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
|
||||
- When I reference GitHub Repos related to Comfy-Org, you should proactively fetch or read the associated information in the repo. To do so, you should exhaust all options: (1) Check if we have a local copy of the repo, (2) Use the GitHub API to fetch the information; you may want to do this IN ADDITION to the other options, especially for reading specific branches/PRs/comments/reviews/metadata, and (3) curl the GitHub website and parse the html or json responses
|
||||
- For information about ComfyUI, ComfyUI_frontend, or ComfyUI-Manager, you can web search or download these wikis: https://deepwiki.com/Comfy-Org/ComfyUI-Manager, https://deepwiki.com/Comfy-Org/ComfyUI_frontend/1-overview, https://deepwiki.com/comfyanonymous/ComfyUI/2-core-architecture
|
||||
- If a question/project is related to Comfy-Org, Comfy, or ComfyUI ecosystem, you should proactively use the Comfy docs to answer the question. The docs may be referenced with URLs like https://docs.comfy.org
|
||||
- When operating inside a repo, check for README files at key locations in the repo detailing info about the contents of that folder. E.g., top-level key folders like tests-ui, browser_tests, composables, extensions/core, stores, services often have their own README.md files. When writing code, make sure to frequently reference these README files to understand the overall architecture and design of the project. Pay close attention to the snippets to learn particular patterns that seem to be there for a reason, as you should emulate those.
|
||||
@@ -12,7 +12,7 @@
|
||||
- If using a lesser known or complex CLI tool, run the --help to see the documentation before deciding what to run, even if just for double-checking or verifying things.
|
||||
- IMPORTANT: the most important goal when writing code is to create clean, best-practices, sustainable, and scalable public APIs and interfaces. Our app is used by thousands of users and we have thousands of mods/extensions that are constantly changing and updating; and we are also always updating. That's why it is IMPORTANT that we design systems and write code that follows practices of domain-driven design, object-oriented design, and design patterns (such that you can assure stability while allowing for all components around you to change and evolve). We ABSOLUTELY prioritize clean APIs and public interfaces that clearly define and restrict how/what the mods/extensions can access.
|
||||
- If any of these technologies are referenced, you can proactively read their docs at these locations: https://primevue.org/theming, https://primevue.org/forms/, https://www.electronjs.org/docs/latest/api/browser-window, https://vitest.dev/guide/browser/, https://atlassian.design/components/pragmatic-drag-and-drop/core-package/drop-targets/, https://playwright.dev/docs/api/class-test, https://playwright.dev/docs/api/class-electron, https://www.algolia.com/doc/api-reference/rest-api/, https://pyav.org/docs/develop/cookbook/basics.html
|
||||
- IMPORTANT: Never add Co-Authored by Claude or any refrence to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
|
||||
- IMPORTANT: Never add Co-Authored by Claude or any reference to Claude or Claude Code in commit messages, PR descriptions, titles, or any documentation whatsoever
|
||||
- The npm script to type check is called "typecheck" NOT "type check"
|
||||
- Use the Vue 3 Composition API instead of the Options API when writing Vue components. An exception is when overriding or extending a PrimeVue component for compatibility, you may use the Options API.
|
||||
- when we are solving an issue we know the link/number for, we should add "Fixes #n" (where n is the issue number) to the PR description.
|
||||
@@ -36,3 +36,21 @@
|
||||
- Use Vite for fast development and building
|
||||
- Use vue-i18n in composition API for any string literals. Place new translation entries in src/locales/en/main.json.
|
||||
- Avoid using `@ts-expect-error` to work around type issues. We needed to employ it to migrate to TypeScript, but it should not be viewed as an accepted practice or standard.
|
||||
- DO NOT use deprecated PrimeVue components. Use these replacements instead:
|
||||
* `Dropdown` → Use `Select` (import from 'primevue/select')
|
||||
* `OverlayPanel` → Use `Popover` (import from 'primevue/popover')
|
||||
* `Calendar` → Use `DatePicker` (import from 'primevue/datepicker')
|
||||
* `InputSwitch` → Use `ToggleSwitch` (import from 'primevue/toggleswitch')
|
||||
* `Sidebar` → Use `Drawer` (import from 'primevue/drawer')
|
||||
* `Chips` → Use `AutoComplete` with multiple enabled and typeahead disabled
|
||||
* `TabMenu` → Use `Tabs` without panels
|
||||
* `Steps` → Use `Stepper` without panels
|
||||
* `InlineMessage` → Use `Message` component
|
||||
* Use `api.apiURL()` for all backend API calls and routes
|
||||
- Actual API endpoints like /prompt, /queue, /view, etc.
|
||||
- Image previews: `api.apiURL('/view?...')`
|
||||
- Any backend-generated content or dynamic routes
|
||||
* Use `api.fileURL()` for static files served from the public folder:
|
||||
- Templates: `api.fileURL('/templates/default.json')`
|
||||
- Extensions: `api.fileURL(extensionPath)` for loading JS modules
|
||||
- Any static assets that exist in the public directory
|
||||
|
||||
62
README.md
@@ -609,6 +609,68 @@ This project includes `.vscode/launch.json.default` and `.vscode/settings.json.d
|
||||
|
||||
We've also included a list of recommended extensions in `.vscode/extensions.json`. Your editor should detect this file and show a human friendly list in the Extensions panel, linking each entry to its marketplace page.
|
||||
|
||||
### Recommended MCP Integrations
|
||||
|
||||
#### Playwright Browser Automation
|
||||
|
||||
The Playwright MCP server enables LLM coding assistants (like Copilot, Claude Code, etc.) to control and inspect web browsers programmatically. This allows assistants to navigate websites, take screenshots, and interact with web pages on your behalf.
|
||||
|
||||
For ComfyUI_frontend development, you can ask coding assistants to use Playwright screenshots to continuously verify that your code changes produce the expected visual results.
|
||||
|
||||
##### Setup for Claude Code
|
||||
|
||||
After installing dependencies with `npm i`, the Playwright MCP server will be automatically available when you start Claude Code locally.
|
||||
|
||||
Here's how Claude Code can use the Playwright MCP server to inspect the interface of the local development server (assuming you're running the dev server at `localhost:5173`):
|
||||
|
||||
|
||||
```
|
||||
> navigate to localhost:5173 and take a screenshot.
|
||||
tell me the items in the menubar
|
||||
|
||||
● I'll navigate to localhost:5173 and take a
|
||||
screenshot to see the menubar items.
|
||||
|
||||
● playwright:playwright_navigate (MCP)(url: …
|
||||
"http://local
|
||||
host:5173")
|
||||
⎿ Navigated to http://localhost:5173
|
||||
|
||||
● playwright:playwright_screenshot (MCP)(name: "lo…
|
||||
alhost_5173
|
||||
_menubar")
|
||||
|
||||
⎿ Screenshot saved to: ../../../Downloads/localho
|
||||
st_5173_menubar-2025-05-30T17-23-00-942Z.png
|
||||
⎿ Screenshot also stored in memory with name:
|
||||
'localhost_5173_menubar'
|
||||
|
||||
● Read(../../../Downloads/localhost_5173_menubar-2…
|
||||
25-05-30T17-23-00-942Z.png)
|
||||
⎿ Read image (94.3KB)
|
||||
|
||||
● Based on the screenshot, I can see the menubar at
|
||||
the top of the ComfyUI interface contains the
|
||||
following items:
|
||||
|
||||
Main Menubar Items:
|
||||
- Workflow - Menu for workflow-related actions
|
||||
- Edit - Menu for editing operations
|
||||
- Help - Menu for help and documentation
|
||||
|
||||
Additional UI Elements:
|
||||
- ComfyUI Logo - On the far left
|
||||
- Workflow Tab - Shows "Unsaved Workflow" with a
|
||||
dropdown and close button
|
||||
- Layout Controls - On the far right (grid view
|
||||
and hamburger menu icons)
|
||||
|
||||
The interface shows a typical ComfyUI workflow
|
||||
graph with nodes like "Load Checkpoint", "CLIP
|
||||
Text Encode (Prompt)", "KSampler", and "Empty
|
||||
Latent Image" connected with colored cables.
|
||||
```
|
||||
|
||||
### Unit Test
|
||||
|
||||
- `npm i` to install all dependencies
|
||||
|
||||
@@ -762,7 +762,7 @@ export class ComfyPage {
|
||||
y: 625
|
||||
}
|
||||
})
|
||||
this.page.mouse.move(10, 10)
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -774,7 +774,7 @@ export class ComfyPage {
|
||||
},
|
||||
button: 'right'
|
||||
})
|
||||
this.page.mouse.move(10, 10)
|
||||
await this.page.mouse.move(10, 10)
|
||||
await this.nextFrame()
|
||||
}
|
||||
|
||||
@@ -1046,6 +1046,8 @@ export class ComfyPage {
|
||||
}
|
||||
}
|
||||
|
||||
export const testComfySnapToGridGridSize = 50
|
||||
|
||||
export const comfyPageFixture = base.extend<{
|
||||
comfyPage: ComfyPage
|
||||
comfyMouse: ComfyMouse
|
||||
@@ -1072,7 +1074,8 @@ export const comfyPageFixture = base.extend<{
|
||||
'Comfy.EnableTooltips': false,
|
||||
'Comfy.userId': userId,
|
||||
// Set tutorial completed to true to avoid loading the tutorial workflow.
|
||||
'Comfy.TutorialCompleted': true
|
||||
'Comfy.TutorialCompleted': true,
|
||||
'Comfy.SnapToGrid.GridSize': testComfySnapToGridGridSize
|
||||
})
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 83 KiB |
|
Before Width: | Height: | Size: 99 KiB After Width: | Height: | Size: 98 KiB |
@@ -1,6 +1,12 @@
|
||||
import { expect } from '@playwright/test'
|
||||
import { Position } from '@vueuse/core'
|
||||
|
||||
import { type ComfyPage, comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import {
|
||||
type ComfyPage,
|
||||
comfyPageFixture as test,
|
||||
testComfySnapToGridGridSize
|
||||
} from '../fixtures/ComfyPage'
|
||||
import { type NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
@@ -57,8 +63,10 @@ test.describe('Node Interaction', () => {
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('selected-node2.png')
|
||||
})
|
||||
|
||||
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
const dragSelectNodes = async (
|
||||
comfyPage: ComfyPage,
|
||||
clipNodes: NodeReference[]
|
||||
) => {
|
||||
const clipNode1Pos = await clipNodes[0].getPosition()
|
||||
const clipNode2Pos = await clipNodes[1].getPosition()
|
||||
const offset = 64
|
||||
@@ -74,10 +82,67 @@ test.describe('Node Interaction', () => {
|
||||
}
|
||||
)
|
||||
await comfyPage.page.keyboard.up('Meta')
|
||||
}
|
||||
|
||||
test('Can drag-select nodes with Meta (mac)', async ({ comfyPage }) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
await dragSelectNodes(comfyPage, clipNodes)
|
||||
expect(await comfyPage.getSelectedGraphNodesCount()).toBe(
|
||||
clipNodes.length
|
||||
)
|
||||
})
|
||||
|
||||
test('Can move selected nodes using the Comfy.Canvas.MoveSelectedNodes.{Up|Down|Left|Right} commands', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const clipNodes = await comfyPage.getNodeRefsByType('CLIPTextEncode')
|
||||
const getPositions = () =>
|
||||
Promise.all(clipNodes.map((node) => node.getPosition()))
|
||||
const testDirection = async ({
|
||||
direction,
|
||||
expectedPosition
|
||||
}: {
|
||||
direction: string
|
||||
expectedPosition: (originalPosition: Position) => Position
|
||||
}) => {
|
||||
const originalPositions = await getPositions()
|
||||
await dragSelectNodes(comfyPage, clipNodes)
|
||||
await comfyPage.executeCommand(
|
||||
`Comfy.Canvas.MoveSelectedNodes.${direction}`
|
||||
)
|
||||
await comfyPage.canvas.press(`Control+Arrow${direction}`)
|
||||
const newPositions = await getPositions()
|
||||
expect(newPositions).toEqual(originalPositions.map(expectedPosition))
|
||||
}
|
||||
await testDirection({
|
||||
direction: 'Down',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
y: originalPosition.y + testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
await testDirection({
|
||||
direction: 'Right',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
x: originalPosition.x + testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
await testDirection({
|
||||
direction: 'Up',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
y: originalPosition.y - testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
await testDirection({
|
||||
direction: 'Left',
|
||||
expectedPosition: (originalPosition) => ({
|
||||
...originalPosition,
|
||||
x: originalPosition.x - testComfySnapToGridGridSize
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Can drag node', async ({ comfyPage }) => {
|
||||
|
||||
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 67 KiB |
|
Before Width: | Height: | Size: 101 KiB After Width: | Height: | Size: 98 KiB |
1848
package-lock.json
generated
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.22.0",
|
||||
"version": "1.22.1",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -29,10 +29,11 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.8.0",
|
||||
"@executeautomation/playwright-mcp-server": "^1.0.5",
|
||||
"@iconify/json": "^2.2.245",
|
||||
"@lobehub/i18n-cli": "^1.20.0",
|
||||
"@pinia/testing": "^0.1.5",
|
||||
"@playwright/test": "^1.44.1",
|
||||
"@playwright/test": "^1.52.0",
|
||||
"@trivago/prettier-plugin-sort-imports": "^5.2.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<!-- The main global dialog to show various things -->
|
||||
<template>
|
||||
<Dialog
|
||||
v-for="(item, index) in dialogStore.dialogStack"
|
||||
v-for="item in dialogStore.dialogStack"
|
||||
:key="item.key"
|
||||
v-model:visible="item.visible"
|
||||
class="global-dialog"
|
||||
v-bind="item.dialogComponentProps"
|
||||
:auto-z-index="false"
|
||||
:pt="item.dialogComponentProps.pt"
|
||||
:pt:mask:style="{ zIndex: baseZIndex + index + 1 }"
|
||||
:aria-labelledby="item.key"
|
||||
>
|
||||
<template #header>
|
||||
@@ -35,25 +33,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { usePrimeVue } from '@primevue/core'
|
||||
import Dialog from 'primevue/dialog'
|
||||
import { computed, onMounted } from 'vue'
|
||||
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
|
||||
const dialogStore = useDialogStore()
|
||||
|
||||
const primevue = usePrimeVue()
|
||||
|
||||
const baseZIndex = computed(() => {
|
||||
return primevue?.config?.zIndex?.modal ?? 1100
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
const mask = document.createElement('div')
|
||||
ZIndex.set('model', mask, baseZIndex.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style>
|
||||
|
||||
@@ -1,11 +1,37 @@
|
||||
<template>
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
class="object-cover"
|
||||
:style="{ width: cssWidth, height: cssHeight }"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
<div :style="{ width: cssWidth, height: cssHeight }" class="overflow-hidden">
|
||||
<!-- default banner show -->
|
||||
<div v-if="showDefaultBanner" class="w-full h-full">
|
||||
<img
|
||||
:src="DEFAULT_BANNER"
|
||||
alt="default banner"
|
||||
class="w-full h-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
<!-- banner_url or icon show -->
|
||||
<div v-else class="relative w-full h-full">
|
||||
<!-- blur background -->
|
||||
<div
|
||||
v-if="imgSrc"
|
||||
class="absolute inset-0 bg-cover bg-center bg-no-repeat opacity-30"
|
||||
:style="{
|
||||
backgroundImage: `url(${imgSrc})`,
|
||||
filter: 'blur(10px)'
|
||||
}"
|
||||
></div>
|
||||
<!-- image -->
|
||||
<img
|
||||
:src="isImageError ? DEFAULT_BANNER : imgSrc"
|
||||
:alt="nodePack.name + ' banner'"
|
||||
:class="
|
||||
isImageError
|
||||
? 'relative w-full h-full object-cover z-10'
|
||||
: 'relative w-full h-full object-contain z-10'
|
||||
"
|
||||
@error="isImageError = true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -26,12 +52,9 @@ const {
|
||||
}>()
|
||||
|
||||
const isImageError = ref(false)
|
||||
const shouldShowFallback = computed(
|
||||
() => !nodePack.banner || nodePack.banner.trim() === '' || isImageError.value
|
||||
)
|
||||
const imgSrc = computed(() =>
|
||||
shouldShowFallback.value ? DEFAULT_BANNER : nodePack.banner
|
||||
)
|
||||
|
||||
const showDefaultBanner = computed(() => !nodePack.banner_url && !nodePack.icon)
|
||||
const imgSrc = computed(() => nodePack.banner_url || nodePack.icon)
|
||||
|
||||
const convertToCssValue = (value: string | number) =>
|
||||
typeof value === 'number' ? `${value}rem` : value
|
||||
|
||||
@@ -54,4 +54,21 @@ describe('SettingItem', () => {
|
||||
{ text: 'Correctly Translated', value: 'Correctly Translated' }
|
||||
])
|
||||
})
|
||||
|
||||
it('handles tooltips with @ symbols without errors', () => {
|
||||
const wrapper = mountComponent({
|
||||
setting: {
|
||||
id: 'TestSetting',
|
||||
name: 'Test Setting',
|
||||
type: 'boolean',
|
||||
tooltip:
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
}
|
||||
})
|
||||
|
||||
// Should not throw an error and tooltip should be preserved as-is
|
||||
expect(wrapper.vm.formItem.tooltip).toBe(
|
||||
'This will load a larger version of @mtb/markdown-parser that bundles shiki'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -28,6 +28,7 @@ import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import FormItem from '@/components/common/FormItem.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { useSettingStore } from '@/stores/settingStore'
|
||||
import type { SettingOption, SettingParams } from '@/types/settingTypes'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
@@ -64,7 +65,7 @@ const formItem = computed(() => {
|
||||
...props.setting,
|
||||
name: t(`settings.${normalizedId}.name`, props.setting.name),
|
||||
tooltip: props.setting.tooltip
|
||||
? t(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
|
||||
? st(`settings.${normalizedId}.tooltip`, props.setting.tooltip)
|
||||
: undefined,
|
||||
options: props.setting.options
|
||||
? translateOptions(props.setting.options)
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
LGraphNode,
|
||||
LiteGraph
|
||||
} from '@comfyorg/litegraph'
|
||||
import { Point } from '@comfyorg/litegraph'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import {
|
||||
@@ -27,6 +28,8 @@ import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
import { useSearchBoxStore } from '@/stores/workspace/searchBoxStore'
|
||||
import { useWorkspaceStore } from '@/stores/workspaceStore'
|
||||
|
||||
const moveSelectedNodesVersionAdded = '1.22.2'
|
||||
|
||||
export function useCoreCommands(): ComfyCommand[] {
|
||||
const workflowService = useWorkflowService()
|
||||
const workflowStore = useWorkflowStore()
|
||||
@@ -58,6 +61,20 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
})
|
||||
}
|
||||
|
||||
const moveSelectedNodes = (
|
||||
positionUpdater: (pos: Point, gridSize: number) => Point
|
||||
) => {
|
||||
const selectedNodes = getSelectedNodes()
|
||||
if (selectedNodes.length === 0) return
|
||||
|
||||
const gridSize = useSettingStore().get('Comfy.SnapToGrid.GridSize')
|
||||
selectedNodes.forEach((node) => {
|
||||
node.pos = positionUpdater(node.pos, gridSize)
|
||||
})
|
||||
app.canvas.state.selectionChanged = true
|
||||
app.canvas.setDirty(true, true)
|
||||
}
|
||||
|
||||
const commands = [
|
||||
{
|
||||
id: 'Comfy.NewBlankWorkflow',
|
||||
@@ -673,6 +690,34 @@ export function useCoreCommands(): ComfyCommand[] {
|
||||
function: async () => {
|
||||
await firebaseAuthActions.logout()
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Up',
|
||||
icon: 'pi pi-arrow-up',
|
||||
label: 'Move Selected Nodes Up',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y - gridSize])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Down',
|
||||
icon: 'pi pi-arrow-down',
|
||||
label: 'Move Selected Nodes Down',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x, y + gridSize])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Left',
|
||||
icon: 'pi pi-arrow-left',
|
||||
label: 'Move Selected Nodes Left',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x - gridSize, y])
|
||||
},
|
||||
{
|
||||
id: 'Comfy.Canvas.MoveSelectedNodes.Right',
|
||||
icon: 'pi pi-arrow-right',
|
||||
label: 'Move Selected Nodes Right',
|
||||
versionAdded: moveSelectedNodesVersionAdded,
|
||||
function: () => moveSelectedNodes(([x, y], gridSize) => [x + gridSize, y])
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@@ -160,24 +160,45 @@ class Load3d {
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderer.clear()
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
}
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.updatePreviewRender()
|
||||
}
|
||||
|
||||
this.INITIAL_RENDER_DONE = true
|
||||
}
|
||||
|
||||
renderMainScene(): void {
|
||||
const width = this.renderer.domElement.clientWidth
|
||||
const height = this.renderer.domElement.clientHeight
|
||||
|
||||
this.renderer.setViewport(0, 0, width, height)
|
||||
this.renderer.setScissor(0, 0, width, height)
|
||||
this.renderer.setScissorTest(true)
|
||||
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
}
|
||||
|
||||
resetViewport(): void {
|
||||
const width = this.renderer.domElement.clientWidth
|
||||
const height = this.renderer.domElement.clientHeight
|
||||
|
||||
this.renderer.setViewport(0, 0, width, height)
|
||||
this.renderer.setScissor(0, 0, width, height)
|
||||
this.renderer.setScissorTest(false)
|
||||
}
|
||||
|
||||
private getActiveCamera(): THREE.Camera {
|
||||
return this.cameraManager.activeCamera
|
||||
}
|
||||
@@ -198,20 +219,17 @@ class Load3d {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.updatePreviewRender()
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
this.viewHelperManager.update(delta)
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderer.clear()
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
@@ -298,17 +316,18 @@ class Load3d {
|
||||
|
||||
setBackgroundColor(color: string): void {
|
||||
this.sceneManager.setBackgroundColor(color)
|
||||
|
||||
this.previewManager.setPreviewBackgroundColor(color)
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
async setBackgroundImage(uploadPath: string): Promise<void> {
|
||||
await this.sceneManager.setBackgroundImage(uploadPath)
|
||||
|
||||
if (this.previewManager.previewRenderer) {
|
||||
this.previewManager.updateBackgroundTexture(
|
||||
this.sceneManager.backgroundTexture
|
||||
)
|
||||
}
|
||||
this.previewManager.updateBackgroundTexture(
|
||||
this.sceneManager.backgroundTexture
|
||||
)
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -316,12 +335,9 @@ class Load3d {
|
||||
removeBackgroundImage(): void {
|
||||
this.sceneManager.removeBackgroundImage()
|
||||
|
||||
if (
|
||||
this.previewManager.previewRenderer &&
|
||||
this.previewManager.previewCamera
|
||||
) {
|
||||
this.previewManager.updateBackgroundTexture(null)
|
||||
}
|
||||
this.previewManager.setPreviewBackgroundColor(
|
||||
this.sceneManager.currentBackgroundColor
|
||||
)
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
@@ -348,10 +364,6 @@ class Load3d {
|
||||
setCameraState(state: CameraState): void {
|
||||
this.cameraManager.setCameraState(state)
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.syncWithMainCamera()
|
||||
}
|
||||
|
||||
this.forceRender()
|
||||
}
|
||||
|
||||
|
||||
@@ -42,10 +42,6 @@ class Load3dAnimation extends Load3d {
|
||||
return
|
||||
}
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.updatePreviewRender()
|
||||
}
|
||||
|
||||
const delta = this.clock.getDelta()
|
||||
|
||||
this.animationManager.update(delta)
|
||||
@@ -54,12 +50,13 @@ class Load3dAnimation extends Load3d {
|
||||
|
||||
this.controlsManager.update()
|
||||
|
||||
this.renderer.clear()
|
||||
this.sceneManager.renderBackground()
|
||||
this.renderer.render(
|
||||
this.sceneManager.scene,
|
||||
this.cameraManager.activeCamera
|
||||
)
|
||||
this.renderMainScene()
|
||||
|
||||
if (this.previewManager.showPreview) {
|
||||
this.previewManager.renderPreview()
|
||||
}
|
||||
|
||||
this.resetViewport()
|
||||
|
||||
if (this.viewHelperManager.viewHelper.render) {
|
||||
this.viewHelperManager.viewHelper.render(this.renderer)
|
||||
|
||||
@@ -4,7 +4,6 @@ import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls'
|
||||
import { EventManagerInterface, PreviewManagerInterface } from './interfaces'
|
||||
|
||||
export class PreviewManager implements PreviewManagerInterface {
|
||||
previewRenderer: THREE.WebGLRenderer | null = null
|
||||
previewCamera: THREE.Camera
|
||||
previewContainer: HTMLDivElement = {} as HTMLDivElement
|
||||
showPreview: boolean = true
|
||||
@@ -17,7 +16,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
private getControls: () => OrbitControls
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
// @ts-expect-error unused variable
|
||||
private getRenderer: () => THREE.WebGLRenderer
|
||||
|
||||
private previewBackgroundScene: THREE.Scene
|
||||
@@ -25,6 +23,9 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
private previewBackgroundMesh: THREE.Mesh | null = null
|
||||
private previewBackgroundTexture: THREE.Texture | null = null
|
||||
|
||||
private previewBackgroundColorMaterial: THREE.MeshBasicMaterial | null = null
|
||||
private currentBackgroundColor: THREE.Color = new THREE.Color(0x282828)
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
@@ -45,15 +46,24 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
this.previewBackgroundScene = backgroundScene.clone()
|
||||
this.previewBackgroundCamera = backgroundCamera.clone()
|
||||
|
||||
this.initPreviewBackgroundScene()
|
||||
}
|
||||
|
||||
private initPreviewBackgroundScene(): void {
|
||||
const planeGeometry = new THREE.PlaneGeometry(2, 2)
|
||||
const planeMaterial = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
|
||||
this.previewBackgroundColorMaterial = new THREE.MeshBasicMaterial({
|
||||
color: this.currentBackgroundColor.clone(),
|
||||
transparent: false,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
this.previewBackgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
|
||||
this.previewBackgroundMesh = new THREE.Mesh(
|
||||
planeGeometry,
|
||||
this.previewBackgroundColorMaterial
|
||||
)
|
||||
this.previewBackgroundMesh.position.set(0, 0, 0)
|
||||
this.previewBackgroundScene.add(this.previewBackgroundMesh)
|
||||
}
|
||||
@@ -61,40 +71,23 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
init(): void {}
|
||||
|
||||
dispose(): void {
|
||||
if (this.previewRenderer) {
|
||||
this.previewRenderer.forceContextLoss()
|
||||
const canvas = this.previewRenderer.domElement
|
||||
const event = new Event('webglcontextlost', {
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
})
|
||||
canvas.dispatchEvent(event)
|
||||
|
||||
this.previewRenderer.dispose()
|
||||
}
|
||||
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
}
|
||||
|
||||
if (this.previewBackgroundColorMaterial) {
|
||||
this.previewBackgroundColorMaterial.dispose()
|
||||
}
|
||||
|
||||
if (this.previewBackgroundMesh) {
|
||||
this.previewBackgroundMesh.geometry.dispose()
|
||||
;(this.previewBackgroundMesh.material as THREE.Material).dispose()
|
||||
if (this.previewBackgroundMesh.material instanceof THREE.Material) {
|
||||
this.previewBackgroundMesh.material.dispose()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
createCapturePreview(container: Element | HTMLElement): void {
|
||||
this.previewRenderer = new THREE.WebGLRenderer({
|
||||
alpha: true,
|
||||
antialias: true,
|
||||
preserveDrawingBuffer: true
|
||||
})
|
||||
|
||||
this.previewRenderer.setSize(this.targetWidth, this.targetHeight)
|
||||
this.previewRenderer.setClearColor(0x282828)
|
||||
this.previewRenderer.autoClear = false
|
||||
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
|
||||
this.previewContainer = document.createElement('div')
|
||||
this.previewContainer.style.cssText = `
|
||||
position: absolute;
|
||||
@@ -104,7 +97,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
display: block;
|
||||
transition: border-color 0.1s ease;
|
||||
`
|
||||
this.previewContainer.appendChild(this.previewRenderer.domElement)
|
||||
|
||||
const MIN_PREVIEW_WIDTH = 120
|
||||
const MAX_PREVIEW_WIDTH = 240
|
||||
@@ -131,7 +123,6 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
}
|
||||
|
||||
this.updatePreviewSize()
|
||||
this.updatePreviewRender()
|
||||
})
|
||||
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
@@ -159,57 +150,54 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
|
||||
const previewHeight =
|
||||
(this.previewWidth * this.targetHeight) / this.targetWidth
|
||||
this.previewRenderer?.setSize(this.previewWidth, previewHeight, false)
|
||||
|
||||
this.previewContainer.style.width = `${this.previewWidth}px`
|
||||
this.previewContainer.style.height = `${previewHeight}px`
|
||||
}
|
||||
|
||||
syncWithMainCamera(): void {
|
||||
if (!this.previewRenderer || !this.previewContainer || !this.showPreview) {
|
||||
return
|
||||
getPreviewViewport(): {
|
||||
left: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
} | null {
|
||||
if (!this.showPreview || !this.previewContainer) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.previewCamera = this.getActiveCamera().clone()
|
||||
const renderer = this.getRenderer()
|
||||
const canvas = renderer.domElement
|
||||
|
||||
this.previewCamera.position.copy(this.getActiveCamera().position)
|
||||
this.previewCamera.rotation.copy(this.getActiveCamera().rotation)
|
||||
const containerRect = this.previewContainer.getBoundingClientRect()
|
||||
const canvasRect = canvas.getBoundingClientRect()
|
||||
|
||||
const aspect = this.targetWidth / this.targetHeight
|
||||
|
||||
if (this.getActiveCamera() instanceof THREE.OrthographicCamera) {
|
||||
const activeOrtho = this.getActiveCamera() as THREE.OrthographicCamera
|
||||
const previewOrtho = this.previewCamera as THREE.OrthographicCamera
|
||||
|
||||
previewOrtho.zoom = activeOrtho.zoom
|
||||
|
||||
const frustumHeight =
|
||||
(activeOrtho.top - activeOrtho.bottom) / activeOrtho.zoom
|
||||
const frustumWidth = frustumHeight * aspect
|
||||
|
||||
previewOrtho.top = frustumHeight / 2
|
||||
previewOrtho.left = -frustumWidth / 2
|
||||
previewOrtho.right = frustumWidth / 2
|
||||
previewOrtho.bottom = -frustumHeight / 2
|
||||
|
||||
previewOrtho.updateProjectionMatrix()
|
||||
} else {
|
||||
const activePerspective =
|
||||
this.getActiveCamera() as THREE.PerspectiveCamera
|
||||
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
|
||||
|
||||
previewPerspective.fov = activePerspective.fov
|
||||
previewPerspective.zoom = activePerspective.zoom
|
||||
previewPerspective.aspect = aspect
|
||||
|
||||
previewPerspective.updateProjectionMatrix()
|
||||
if (
|
||||
containerRect.bottom < canvasRect.top ||
|
||||
containerRect.top > canvasRect.bottom ||
|
||||
containerRect.right < canvasRect.left ||
|
||||
containerRect.left > canvasRect.right
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
this.previewCamera.lookAt(this.getControls().target)
|
||||
const width = parseFloat(this.previewContainer.style.width)
|
||||
const height = parseFloat(this.previewContainer.style.height)
|
||||
|
||||
this.updatePreviewRender()
|
||||
const left = this.getRenderer().domElement.clientWidth - width
|
||||
|
||||
const bottom = 0
|
||||
|
||||
return { left, bottom, width, height }
|
||||
}
|
||||
|
||||
updatePreviewRender(): void {
|
||||
if (!this.previewRenderer || !this.previewContainer || !this.showPreview)
|
||||
return
|
||||
renderPreview(): void {
|
||||
const viewport = this.getPreviewViewport()
|
||||
if (!viewport) return
|
||||
|
||||
const renderer = this.getRenderer()
|
||||
|
||||
const originalClearColor = renderer.getClearColor(new THREE.Color())
|
||||
const originalClearAlpha = renderer.getClearAlpha()
|
||||
|
||||
if (
|
||||
!this.previewCamera ||
|
||||
@@ -243,45 +231,77 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
|
||||
previewOrtho.updateProjectionMatrix()
|
||||
} else {
|
||||
;(this.previewCamera as THREE.PerspectiveCamera).aspect = aspect
|
||||
;(this.previewCamera as THREE.PerspectiveCamera).fov = (
|
||||
const activePerspective =
|
||||
this.getActiveCamera() as THREE.PerspectiveCamera
|
||||
).fov
|
||||
;(this.previewCamera as THREE.PerspectiveCamera).updateProjectionMatrix()
|
||||
const previewPerspective = this.previewCamera as THREE.PerspectiveCamera
|
||||
|
||||
previewPerspective.fov = activePerspective.fov
|
||||
previewPerspective.zoom = activePerspective.zoom
|
||||
previewPerspective.aspect = aspect
|
||||
|
||||
previewPerspective.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
this.previewCamera.lookAt(this.getControls().target)
|
||||
|
||||
const previewHeight =
|
||||
(this.previewWidth * this.targetHeight) / this.targetWidth
|
||||
this.previewRenderer.setSize(this.previewWidth, previewHeight, false)
|
||||
this.previewRenderer.outputColorSpace = THREE.SRGBColorSpace
|
||||
this.previewRenderer.clear()
|
||||
renderer.setViewport(
|
||||
viewport.left,
|
||||
viewport.bottom,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
)
|
||||
renderer.setScissor(
|
||||
viewport.left,
|
||||
viewport.bottom,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
)
|
||||
|
||||
if (this.previewBackgroundMesh && this.previewBackgroundTexture) {
|
||||
const material = this.previewBackgroundMesh
|
||||
.material as THREE.MeshBasicMaterial
|
||||
if (material.map) {
|
||||
const currentToneMapping = this.previewRenderer.toneMapping
|
||||
const currentExposure = this.previewRenderer.toneMappingExposure
|
||||
renderer.setClearColor(0x000000, 0)
|
||||
renderer.clear()
|
||||
|
||||
this.previewRenderer.toneMapping = THREE.NoToneMapping
|
||||
this.previewRenderer.render(
|
||||
this.previewBackgroundScene,
|
||||
this.previewBackgroundCamera
|
||||
)
|
||||
this.renderPreviewBackground(renderer)
|
||||
|
||||
this.previewRenderer.toneMapping = currentToneMapping
|
||||
this.previewRenderer.toneMappingExposure = currentExposure
|
||||
}
|
||||
renderer.render(this.scene, this.previewCamera)
|
||||
|
||||
renderer.setClearColor(originalClearColor, originalClearAlpha)
|
||||
}
|
||||
|
||||
private renderPreviewBackground(renderer: THREE.WebGLRenderer): void {
|
||||
if (this.previewBackgroundMesh) {
|
||||
const currentToneMapping = renderer.toneMapping
|
||||
const currentExposure = renderer.toneMappingExposure
|
||||
|
||||
renderer.toneMapping = THREE.NoToneMapping
|
||||
renderer.render(this.previewBackgroundScene, this.previewBackgroundCamera)
|
||||
|
||||
renderer.toneMapping = currentToneMapping
|
||||
renderer.toneMappingExposure = currentExposure
|
||||
}
|
||||
}
|
||||
|
||||
setPreviewBackgroundColor(color: string | number | THREE.Color): void {
|
||||
this.currentBackgroundColor.set(color)
|
||||
|
||||
if (!this.previewBackgroundMesh || !this.previewBackgroundColorMaterial) {
|
||||
this.initPreviewBackgroundScene()
|
||||
}
|
||||
|
||||
this.previewRenderer.render(this.scene, this.previewCamera)
|
||||
this.previewBackgroundColorMaterial!.color.copy(this.currentBackgroundColor)
|
||||
|
||||
if (this.previewBackgroundMesh) {
|
||||
this.previewBackgroundMesh.material = this.previewBackgroundColorMaterial!
|
||||
}
|
||||
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
this.previewBackgroundTexture = null
|
||||
}
|
||||
}
|
||||
|
||||
togglePreview(showPreview: boolean): void {
|
||||
if (this.previewRenderer) {
|
||||
this.showPreview = showPreview
|
||||
this.showPreview = showPreview
|
||||
if (this.previewContainer) {
|
||||
this.previewContainer.style.display = this.showPreview ? 'block' : 'none'
|
||||
}
|
||||
|
||||
@@ -306,7 +326,7 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
)
|
||||
}
|
||||
|
||||
if (this.previewRenderer && this.previewCamera) {
|
||||
if (this.previewCamera) {
|
||||
if (this.previewCamera instanceof THREE.PerspectiveCamera) {
|
||||
this.previewCamera.aspect = width / height
|
||||
this.previewCamera.updateProjectionMatrix()
|
||||
@@ -322,30 +342,45 @@ export class PreviewManager implements PreviewManagerInterface {
|
||||
|
||||
handleResize(): void {
|
||||
this.updatePreviewSize()
|
||||
this.updatePreviewRender()
|
||||
}
|
||||
|
||||
updateBackgroundTexture(texture: THREE.Texture | null): void {
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
}
|
||||
if (texture) {
|
||||
if (this.previewBackgroundTexture) {
|
||||
this.previewBackgroundTexture.dispose()
|
||||
}
|
||||
|
||||
this.previewBackgroundTexture = texture
|
||||
this.previewBackgroundTexture = texture
|
||||
|
||||
if (texture && this.previewBackgroundMesh) {
|
||||
const material2 = this.previewBackgroundMesh
|
||||
.material as THREE.MeshBasicMaterial
|
||||
material2.map = texture
|
||||
material2.needsUpdate = true
|
||||
if (this.previewBackgroundMesh) {
|
||||
const imageMaterial = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
this.previewBackgroundMesh.position.set(0, 0, 0)
|
||||
if (
|
||||
this.previewBackgroundMesh.material instanceof THREE.Material &&
|
||||
this.previewBackgroundMesh.material !==
|
||||
this.previewBackgroundColorMaterial
|
||||
) {
|
||||
this.previewBackgroundMesh.material.dispose()
|
||||
}
|
||||
|
||||
this.updateBackgroundSize(
|
||||
this.previewBackgroundTexture,
|
||||
this.previewBackgroundMesh,
|
||||
this.targetWidth,
|
||||
this.targetHeight
|
||||
)
|
||||
this.previewBackgroundMesh.material = imageMaterial
|
||||
this.previewBackgroundMesh.position.set(0, 0, 0)
|
||||
|
||||
this.updateBackgroundSize(
|
||||
this.previewBackgroundTexture,
|
||||
this.previewBackgroundMesh,
|
||||
this.targetWidth,
|
||||
this.targetHeight
|
||||
)
|
||||
}
|
||||
} else {
|
||||
this.setPreviewBackgroundColor(this.currentBackgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,10 @@ export class SceneManager implements SceneManagerInterface {
|
||||
backgroundMesh: THREE.Mesh | null = null
|
||||
backgroundTexture: THREE.Texture | null = null
|
||||
|
||||
backgroundColorMaterial: THREE.MeshBasicMaterial | null = null
|
||||
currentBackgroundType: 'color' | 'image' = 'color'
|
||||
currentBackgroundColor: string = '#282828'
|
||||
|
||||
private eventManager: EventManagerInterface
|
||||
private renderer: THREE.WebGLRenderer
|
||||
|
||||
@@ -40,17 +44,28 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.backgroundScene = new THREE.Scene()
|
||||
this.backgroundCamera = new THREE.OrthographicCamera(-1, 1, 1, -1, -1, 1)
|
||||
|
||||
this.initBackgroundScene()
|
||||
}
|
||||
|
||||
private initBackgroundScene(): void {
|
||||
const planeGeometry = new THREE.PlaneGeometry(2, 2)
|
||||
const planeMaterial = new THREE.MeshBasicMaterial({
|
||||
transparent: true,
|
||||
|
||||
this.backgroundColorMaterial = new THREE.MeshBasicMaterial({
|
||||
color: new THREE.Color(this.currentBackgroundColor),
|
||||
transparent: false,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
this.backgroundMesh = new THREE.Mesh(planeGeometry, planeMaterial)
|
||||
this.backgroundMesh = new THREE.Mesh(
|
||||
planeGeometry,
|
||||
this.backgroundColorMaterial
|
||||
)
|
||||
this.backgroundMesh.position.set(0, 0, 0)
|
||||
this.backgroundScene.add(this.backgroundMesh)
|
||||
|
||||
this.renderer.setClearColor(0x000000, 0)
|
||||
}
|
||||
|
||||
init(): void {}
|
||||
@@ -60,9 +75,15 @@ export class SceneManager implements SceneManagerInterface {
|
||||
this.backgroundTexture.dispose()
|
||||
}
|
||||
|
||||
if (this.backgroundColorMaterial) {
|
||||
this.backgroundColorMaterial.dispose()
|
||||
}
|
||||
|
||||
if (this.backgroundMesh) {
|
||||
this.backgroundMesh.geometry.dispose()
|
||||
;(this.backgroundMesh.material as THREE.Material).dispose()
|
||||
if (this.backgroundMesh.material instanceof THREE.Material) {
|
||||
this.backgroundMesh.material.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
this.scene.clear()
|
||||
@@ -77,18 +98,39 @@ export class SceneManager implements SceneManagerInterface {
|
||||
}
|
||||
|
||||
setBackgroundColor(color: string): void {
|
||||
this.renderer.setClearColor(new THREE.Color(color))
|
||||
this.currentBackgroundColor = color
|
||||
this.currentBackgroundType = 'color'
|
||||
|
||||
if (!this.backgroundMesh || !this.backgroundColorMaterial) {
|
||||
this.initBackgroundScene()
|
||||
}
|
||||
|
||||
this.backgroundColorMaterial!.color.set(color)
|
||||
this.backgroundColorMaterial!.map = null
|
||||
this.backgroundColorMaterial!.transparent = false
|
||||
this.backgroundColorMaterial!.needsUpdate = true
|
||||
|
||||
if (this.backgroundMesh) {
|
||||
this.backgroundMesh.material = this.backgroundColorMaterial!
|
||||
}
|
||||
|
||||
if (this.backgroundTexture) {
|
||||
this.backgroundTexture.dispose()
|
||||
this.backgroundTexture = null
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('backgroundColorChange', color)
|
||||
}
|
||||
|
||||
async setBackgroundImage(uploadPath: string): Promise<void> {
|
||||
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
|
||||
|
||||
if (uploadPath === '') {
|
||||
this.removeBackgroundImage()
|
||||
this.setBackgroundColor(this.currentBackgroundColor)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
this.eventManager.emitEvent('backgroundImageLoadingStart', null)
|
||||
|
||||
let imageUrl = Load3dUtils.getResourceURL(
|
||||
...Load3dUtils.splitFilePath(uploadPath)
|
||||
)
|
||||
@@ -110,12 +152,31 @@ export class SceneManager implements SceneManagerInterface {
|
||||
texture.colorSpace = THREE.SRGBColorSpace
|
||||
|
||||
this.backgroundTexture = texture
|
||||
this.currentBackgroundType = 'image'
|
||||
|
||||
const material = this.backgroundMesh?.material as THREE.MeshBasicMaterial
|
||||
material.map = texture
|
||||
material.needsUpdate = true
|
||||
if (!this.backgroundMesh) {
|
||||
this.initBackgroundScene()
|
||||
}
|
||||
|
||||
this.backgroundMesh?.position.set(0, 0, 0)
|
||||
const imageMaterial = new THREE.MeshBasicMaterial({
|
||||
map: texture,
|
||||
transparent: true,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
side: THREE.DoubleSide
|
||||
})
|
||||
|
||||
if (this.backgroundMesh) {
|
||||
if (
|
||||
this.backgroundMesh.material !== this.backgroundColorMaterial &&
|
||||
this.backgroundMesh.material instanceof THREE.Material
|
||||
) {
|
||||
this.backgroundMesh.material.dispose()
|
||||
}
|
||||
|
||||
this.backgroundMesh.material = imageMaterial
|
||||
this.backgroundMesh.position.set(0, 0, 0)
|
||||
}
|
||||
|
||||
this.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
@@ -129,20 +190,12 @@ export class SceneManager implements SceneManagerInterface {
|
||||
} catch (error) {
|
||||
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
|
||||
console.error('Error loading background image:', error)
|
||||
this.setBackgroundColor(this.currentBackgroundColor)
|
||||
}
|
||||
}
|
||||
|
||||
removeBackgroundImage(): void {
|
||||
if (this.backgroundMesh) {
|
||||
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
|
||||
material.map = null
|
||||
material.needsUpdate = true
|
||||
}
|
||||
|
||||
if (this.backgroundTexture) {
|
||||
this.backgroundTexture.dispose()
|
||||
this.backgroundTexture = null
|
||||
}
|
||||
this.setBackgroundColor(this.currentBackgroundColor)
|
||||
this.eventManager.emitEvent('backgroundImageLoadingEnd', null)
|
||||
}
|
||||
|
||||
@@ -172,7 +225,11 @@ export class SceneManager implements SceneManagerInterface {
|
||||
}
|
||||
|
||||
handleResize(width: number, height: number): void {
|
||||
if (this.backgroundTexture && this.backgroundMesh) {
|
||||
if (
|
||||
this.backgroundTexture &&
|
||||
this.backgroundMesh &&
|
||||
this.currentBackgroundType === 'image'
|
||||
) {
|
||||
this.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
this.backgroundMesh,
|
||||
@@ -183,18 +240,25 @@ export class SceneManager implements SceneManagerInterface {
|
||||
}
|
||||
|
||||
renderBackground(): void {
|
||||
if (this.backgroundMesh && this.backgroundTexture) {
|
||||
const material = this.backgroundMesh.material as THREE.MeshBasicMaterial
|
||||
if (material.map) {
|
||||
const currentToneMapping = this.renderer.toneMapping
|
||||
const currentExposure = this.renderer.toneMappingExposure
|
||||
if (this.backgroundMesh) {
|
||||
const currentToneMapping = this.renderer.toneMapping
|
||||
const currentExposure = this.renderer.toneMappingExposure
|
||||
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.render(this.backgroundScene, this.backgroundCamera)
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.render(this.backgroundScene, this.backgroundCamera)
|
||||
|
||||
this.renderer.toneMapping = currentToneMapping
|
||||
this.renderer.toneMappingExposure = currentExposure
|
||||
}
|
||||
this.renderer.toneMapping = currentToneMapping
|
||||
this.renderer.toneMappingExposure = currentExposure
|
||||
}
|
||||
}
|
||||
|
||||
getCurrentBackgroundInfo(): { type: 'color' | 'image'; value: string } {
|
||||
return {
|
||||
type: this.currentBackgroundType,
|
||||
value:
|
||||
this.currentBackgroundType === 'color'
|
||||
? this.currentBackgroundColor
|
||||
: ''
|
||||
}
|
||||
}
|
||||
|
||||
@@ -210,8 +274,6 @@ export class SceneManager implements SceneManagerInterface {
|
||||
new THREE.Color()
|
||||
)
|
||||
const originalClearAlpha = this.renderer.getClearAlpha()
|
||||
const originalToneMapping = this.renderer.toneMapping
|
||||
const originalExposure = this.renderer.toneMappingExposure
|
||||
const originalOutputColorSpace = this.renderer.outputColorSpace
|
||||
|
||||
this.renderer.setSize(width, height)
|
||||
@@ -237,7 +299,11 @@ export class SceneManager implements SceneManagerInterface {
|
||||
orthographicCamera.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
if (this.backgroundTexture && this.backgroundMesh) {
|
||||
if (
|
||||
this.backgroundTexture &&
|
||||
this.backgroundMesh &&
|
||||
this.currentBackgroundType === 'image'
|
||||
) {
|
||||
this.updateBackgroundSize(
|
||||
this.backgroundTexture,
|
||||
this.backgroundMesh,
|
||||
@@ -252,19 +318,7 @@ export class SceneManager implements SceneManagerInterface {
|
||||
>()
|
||||
|
||||
this.renderer.clear()
|
||||
|
||||
if (this.backgroundMesh && this.backgroundTexture) {
|
||||
const material = this.backgroundMesh
|
||||
.material as THREE.MeshBasicMaterial
|
||||
|
||||
if (material.map) {
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.render(this.backgroundScene, this.backgroundCamera)
|
||||
this.renderer.toneMapping = originalToneMapping
|
||||
this.renderer.toneMappingExposure = originalExposure
|
||||
}
|
||||
}
|
||||
|
||||
this.renderBackground()
|
||||
this.renderer.render(this.scene, this.getActiveCamera())
|
||||
const sceneData = this.renderer.domElement.toDataURL('image/png')
|
||||
|
||||
|
||||
@@ -100,18 +100,23 @@ export interface ViewHelperManagerInterface extends BaseManager {
|
||||
}
|
||||
|
||||
export interface PreviewManagerInterface extends BaseManager {
|
||||
previewRenderer: THREE.WebGLRenderer | null
|
||||
previewCamera: THREE.Camera
|
||||
previewContainer: HTMLDivElement
|
||||
showPreview: boolean
|
||||
previewWidth: number
|
||||
createCapturePreview(container: Element | HTMLElement): void
|
||||
updatePreviewSize(): void
|
||||
updatePreviewRender(): void
|
||||
togglePreview(showPreview: boolean): void
|
||||
setTargetSize(width: number, height: number): void
|
||||
handleResize(): void
|
||||
updateBackgroundTexture(texture: THREE.Texture | null): void
|
||||
getPreviewViewport(): {
|
||||
left: number
|
||||
bottom: number
|
||||
width: number
|
||||
height: number
|
||||
} | null
|
||||
renderPreview(): void
|
||||
}
|
||||
|
||||
export interface EventManagerInterface {
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "Fit view to selected nodes"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "Move Selected Nodes Down"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "Move Selected Nodes Left"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "Move Selected Nodes Right"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "Move Selected Nodes Up"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "Reset View"
|
||||
},
|
||||
|
||||
@@ -794,6 +794,10 @@
|
||||
"Browse Templates": "Browse Templates",
|
||||
"Delete Selected Items": "Delete Selected Items",
|
||||
"Fit view to selected nodes": "Fit view to selected nodes",
|
||||
"Move Selected Nodes Down": "Move Selected Nodes Down",
|
||||
"Move Selected Nodes Left": "Move Selected Nodes Left",
|
||||
"Move Selected Nodes Right": "Move Selected Nodes Right",
|
||||
"Move Selected Nodes Up": "Move Selected Nodes Up",
|
||||
"Reset View": "Reset View",
|
||||
"Resize Selected Nodes": "Resize Selected Nodes",
|
||||
"Canvas Toggle Link Visibility": "Canvas Toggle Link Visibility",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "Ajustar vista a los nodos seleccionados"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "Mover nodos seleccionados hacia abajo"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "Mover nodos seleccionados a la izquierda"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "Mover nodos seleccionados a la derecha"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "Mover nodos seleccionados hacia arriba"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "Restablecer vista"
|
||||
},
|
||||
|
||||
@@ -709,6 +709,10 @@
|
||||
"Interrupt": "Interrumpir",
|
||||
"Load Default Workflow": "Cargar flujo de trabajo predeterminado",
|
||||
"Manage group nodes": "Gestionar nodos de grupo",
|
||||
"Move Selected Nodes Down": "Mover nodos seleccionados hacia abajo",
|
||||
"Move Selected Nodes Left": "Mover nodos seleccionados hacia la izquierda",
|
||||
"Move Selected Nodes Right": "Mover nodos seleccionados hacia la derecha",
|
||||
"Move Selected Nodes Up": "Mover nodos seleccionados hacia arriba",
|
||||
"Mute/Unmute Selected Nodes": "Silenciar/Activar sonido de nodos seleccionados",
|
||||
"New": "Nuevo",
|
||||
"Next Opened Workflow": "Siguiente flujo de trabajo abierto",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "Ajuster la vue aux nœuds sélectionnés"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "Déplacer les nœuds sélectionnés vers le bas"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "Déplacer les nœuds sélectionnés vers la gauche"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "Déplacer les nœuds sélectionnés vers la droite"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "Déplacer les nœuds sélectionnés vers le haut"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "Réinitialiser la vue"
|
||||
},
|
||||
|
||||
@@ -709,6 +709,10 @@
|
||||
"Interrupt": "Interrompre",
|
||||
"Load Default Workflow": "Charger le flux de travail par défaut",
|
||||
"Manage group nodes": "Gérer les nœuds de groupe",
|
||||
"Move Selected Nodes Down": "Déplacer les nœuds sélectionnés vers le bas",
|
||||
"Move Selected Nodes Left": "Déplacer les nœuds sélectionnés vers la gauche",
|
||||
"Move Selected Nodes Right": "Déplacer les nœuds sélectionnés vers la droite",
|
||||
"Move Selected Nodes Up": "Déplacer les nœuds sélectionnés vers le haut",
|
||||
"Mute/Unmute Selected Nodes": "Mettre en sourdine/Activer le son des nœuds sélectionnés",
|
||||
"New": "Nouveau",
|
||||
"Next Opened Workflow": "Prochain flux de travail ouvert",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "選択したノードにビューを合わせる"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "選択したノードを下に移動"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "選択したノードを左に移動"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "選択したノードを右に移動"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "選択したノードを上に移動"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "ビューをリセット"
|
||||
},
|
||||
|
||||
@@ -709,6 +709,10 @@
|
||||
"Interrupt": "中断",
|
||||
"Load Default Workflow": "デフォルトワークフローを読み込む",
|
||||
"Manage group nodes": "グループノードを管理",
|
||||
"Move Selected Nodes Down": "選択したノードを下へ移動",
|
||||
"Move Selected Nodes Left": "選択したノードを左へ移動",
|
||||
"Move Selected Nodes Right": "選択したノードを右へ移動",
|
||||
"Move Selected Nodes Up": "選択したノードを上へ移動",
|
||||
"Mute/Unmute Selected Nodes": "選択したノードのミュート/ミュート解除",
|
||||
"New": "新規",
|
||||
"Next Opened Workflow": "次に開いたワークフロー",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "선택한 노드에 뷰 맞추기"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "선택한 노드 아래로 이동"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "선택한 노드 왼쪽으로 이동"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "선택한 노드 오른쪽으로 이동"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "선택한 노드 위로 이동"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "뷰 재설정"
|
||||
},
|
||||
|
||||
@@ -709,6 +709,10 @@
|
||||
"Interrupt": "중단",
|
||||
"Load Default Workflow": "기본 워크플로 불러오기",
|
||||
"Manage group nodes": "그룹 노드 관리",
|
||||
"Move Selected Nodes Down": "선택한 노드 아래로 이동",
|
||||
"Move Selected Nodes Left": "선택한 노드 왼쪽으로 이동",
|
||||
"Move Selected Nodes Right": "선택한 노드 오른쪽으로 이동",
|
||||
"Move Selected Nodes Up": "선택한 노드 위로 이동",
|
||||
"Mute/Unmute Selected Nodes": "선택한 노드 활성화/비활성화",
|
||||
"New": "새로 만들기",
|
||||
"Next Opened Workflow": "다음 열린 워크플로",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "Подогнать вид к выбранным нодам"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "Переместить выбранные узлы вниз"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "Переместить выбранные узлы влево"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "Переместить выбранные узлы вправо"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "Переместить выбранные узлы вверх"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "Сбросить вид"
|
||||
},
|
||||
|
||||
@@ -709,6 +709,10 @@
|
||||
"Interrupt": "Прервать",
|
||||
"Load Default Workflow": "Загрузить стандартный рабочий процесс",
|
||||
"Manage group nodes": "Управление групповыми нодами",
|
||||
"Move Selected Nodes Down": "Переместить выбранные узлы вниз",
|
||||
"Move Selected Nodes Left": "Переместить выбранные узлы влево",
|
||||
"Move Selected Nodes Right": "Переместить выбранные узлы вправо",
|
||||
"Move Selected Nodes Up": "Переместить выбранные узлы вверх",
|
||||
"Mute/Unmute Selected Nodes": "Отключить/включить звук для выбранных нод",
|
||||
"New": "Новый",
|
||||
"Next Opened Workflow": "Следующий открытый рабочий процесс",
|
||||
|
||||
@@ -44,6 +44,18 @@
|
||||
"Comfy_Canvas_FitView": {
|
||||
"label": "适应视图到选中节点"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Down": {
|
||||
"label": "下移选中的节点"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Left": {
|
||||
"label": "向左移动选中节点"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Right": {
|
||||
"label": "向右移动选中节点"
|
||||
},
|
||||
"Comfy_Canvas_MoveSelectedNodes_Up": {
|
||||
"label": "上移选中的节点"
|
||||
},
|
||||
"Comfy_Canvas_ResetView": {
|
||||
"label": "重置视图"
|
||||
},
|
||||
|
||||
@@ -709,6 +709,10 @@
|
||||
"Interrupt": "中断",
|
||||
"Load Default Workflow": "加载默认工作流",
|
||||
"Manage group nodes": "管理组节点",
|
||||
"Move Selected Nodes Down": "下移所选节点",
|
||||
"Move Selected Nodes Left": "左移所选节点",
|
||||
"Move Selected Nodes Right": "右移所选节点",
|
||||
"Move Selected Nodes Up": "上移所选节点",
|
||||
"Mute/Unmute Selected Nodes": "静音/取消静音选定节点",
|
||||
"New": "新建",
|
||||
"Next Opened Workflow": "下一个打开的工作流",
|
||||
|
||||
@@ -30,6 +30,12 @@ import {
|
||||
isComboInputSpecV1,
|
||||
isComboInputSpecV2
|
||||
} from '@/schemas/nodeDefSchema'
|
||||
import { getFromWebmFile } from '@/scripts/metadata/ebml'
|
||||
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
|
||||
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
|
||||
import { getMp3Metadata } from '@/scripts/metadata/mp3'
|
||||
import { getOggMetadata } from '@/scripts/metadata/ogg'
|
||||
import { getSvgMetadata } from '@/scripts/metadata/svg'
|
||||
import { useDialogService } from '@/services/dialogService'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLitegraphService } from '@/services/litegraphService'
|
||||
@@ -52,7 +58,6 @@ import type { ComfyExtension, MissingNodeType } from '@/types/comfy'
|
||||
import { ExtensionManager } from '@/types/extensionTypes'
|
||||
import { ColorAdjustOptions, adjustColor } from '@/utils/colorUtil'
|
||||
import { graphToPrompt } from '@/utils/executionUtil'
|
||||
import { getFileHandler } from '@/utils/fileHandlers'
|
||||
import {
|
||||
executeWidgetsCallback,
|
||||
fixLinkInputSlots,
|
||||
@@ -68,7 +73,13 @@ import { deserialiseAndCreate } from '@/utils/vintageClipboard'
|
||||
import { type ComfyApi, PromptExecutionError, api } from './api'
|
||||
import { defaultGraph } from './defaultGraph'
|
||||
import { pruneWidgets } from './domWidget'
|
||||
import { importA1111 } from './pnginfo'
|
||||
import {
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata,
|
||||
importA1111
|
||||
} from './pnginfo'
|
||||
import { $el, ComfyUI } from './ui'
|
||||
import { ComfyAppMenu } from './ui/menu/index'
|
||||
import { clone } from './utils'
|
||||
@@ -1273,44 +1284,161 @@ export class ComfyApp {
|
||||
return f.substring(0, p)
|
||||
}
|
||||
const fileName = removeExt(file.name)
|
||||
|
||||
// Get the appropriate file handler for this file type
|
||||
const fileHandler = getFileHandler(file)
|
||||
|
||||
if (!fileHandler) {
|
||||
// No handler found for this file type
|
||||
this.showErrorOnFileLoad(file)
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Process the file using the handler
|
||||
const { workflow, prompt, parameters, jsonTemplateData } =
|
||||
await fileHandler(file)
|
||||
|
||||
if (workflow) {
|
||||
// We have a workflow, load it
|
||||
await this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
// We have a prompt in API format, load it
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else if (parameters) {
|
||||
// We have A1111 parameters, import them
|
||||
if (file.type === 'image/png') {
|
||||
const pngInfo = await getPngMetadata(file)
|
||||
if (pngInfo?.workflow) {
|
||||
await this.loadGraphData(
|
||||
JSON.parse(pngInfo.workflow),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
)
|
||||
} else if (pngInfo?.prompt) {
|
||||
this.loadApiJson(JSON.parse(pngInfo.prompt), fileName)
|
||||
} else if (pngInfo?.parameters) {
|
||||
// Note: Not putting this in `importA1111` as it is mostly not used
|
||||
// by external callers, and `importA1111` has no access to `app`.
|
||||
useWorkflowService().beforeLoadNewGraph()
|
||||
importA1111(this.graph, parameters)
|
||||
importA1111(this.graph, pngInfo.parameters)
|
||||
useWorkflowService().afterLoadNewGraph(
|
||||
fileName,
|
||||
this.graph.serialize() as unknown as ComfyWorkflowJSON
|
||||
)
|
||||
} else if (jsonTemplateData) {
|
||||
// We have template data from JSON
|
||||
this.loadTemplateData(jsonTemplateData)
|
||||
} else {
|
||||
// No usable data found in the file
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error processing file:', error)
|
||||
} else if (file.type === 'image/webp') {
|
||||
const pngInfo = await getWebpMetadata(file)
|
||||
// Support loading workflows from that webp custom node.
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'audio/mpeg') {
|
||||
const { workflow, prompt } = await getMp3Metadata(file)
|
||||
if (workflow) {
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'audio/ogg') {
|
||||
const { workflow, prompt } = await getOggMetadata(file)
|
||||
if (workflow) {
|
||||
this.loadGraphData(workflow, true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'audio/flac' || file.type === 'audio/x-flac') {
|
||||
const pngInfo = await getFlacMetadata(file)
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
if (workflow) {
|
||||
this.loadGraphData(JSON.parse(workflow), true, true, fileName)
|
||||
} else if (prompt) {
|
||||
this.loadApiJson(JSON.parse(prompt), fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (file.type === 'video/webm') {
|
||||
const webmInfo = await getFromWebmFile(file)
|
||||
if (webmInfo.workflow) {
|
||||
this.loadGraphData(webmInfo.workflow, true, true, fileName)
|
||||
} else if (webmInfo.prompt) {
|
||||
this.loadApiJson(webmInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'video/mp4' ||
|
||||
file.name?.endsWith('.mp4') ||
|
||||
file.name?.endsWith('.mov') ||
|
||||
file.name?.endsWith('.m4v') ||
|
||||
file.type === 'video/quicktime' ||
|
||||
file.type === 'video/x-m4v'
|
||||
) {
|
||||
const mp4Info = await getFromIsobmffFile(file)
|
||||
if (mp4Info.workflow) {
|
||||
this.loadGraphData(mp4Info.workflow, true, true, fileName)
|
||||
} else if (mp4Info.prompt) {
|
||||
this.loadApiJson(mp4Info.prompt, fileName)
|
||||
}
|
||||
} else if (file.type === 'image/svg+xml' || file.name?.endsWith('.svg')) {
|
||||
const svgInfo = await getSvgMetadata(file)
|
||||
if (svgInfo.workflow) {
|
||||
this.loadGraphData(svgInfo.workflow, true, true, fileName)
|
||||
} else if (svgInfo.prompt) {
|
||||
this.loadApiJson(svgInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'model/gltf-binary' ||
|
||||
file.name?.endsWith('.glb')
|
||||
) {
|
||||
const gltfInfo = await getGltfBinaryMetadata(file)
|
||||
if (gltfInfo.workflow) {
|
||||
this.loadGraphData(gltfInfo.workflow, true, true, fileName)
|
||||
} else if (gltfInfo.prompt) {
|
||||
this.loadApiJson(gltfInfo.prompt, fileName)
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else if (
|
||||
file.type === 'application/json' ||
|
||||
file.name?.endsWith('.json')
|
||||
) {
|
||||
const reader = new FileReader()
|
||||
reader.onload = async () => {
|
||||
const readerResult = reader.result as string
|
||||
const jsonContent = JSON.parse(readerResult)
|
||||
if (jsonContent?.templates) {
|
||||
this.loadTemplateData(jsonContent)
|
||||
} else if (this.isApiJson(jsonContent)) {
|
||||
this.loadApiJson(jsonContent, fileName)
|
||||
} else {
|
||||
await this.loadGraphData(
|
||||
JSON.parse(readerResult),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
} else if (
|
||||
file.name?.endsWith('.latent') ||
|
||||
file.name?.endsWith('.safetensors')
|
||||
) {
|
||||
const info = await getLatentMetadata(file)
|
||||
// TODO define schema to LatentMetadata
|
||||
// @ts-expect-error
|
||||
if (info.workflow) {
|
||||
await this.loadGraphData(
|
||||
// @ts-expect-error
|
||||
JSON.parse(info.workflow),
|
||||
true,
|
||||
true,
|
||||
fileName
|
||||
)
|
||||
// @ts-expect-error
|
||||
} else if (info.prompt) {
|
||||
// @ts-expect-error
|
||||
this.loadApiJson(JSON.parse(info.prompt))
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
} else {
|
||||
this.showErrorOnFileLoad(file)
|
||||
}
|
||||
}
|
||||
@@ -1427,7 +1555,6 @@ export class ComfyApp {
|
||||
/**
|
||||
* Registers a Comfy web extension with the app
|
||||
* @param {ComfyExtension} extension
|
||||
* @deprecated Use useExtensionService().registerExtension instead
|
||||
*/
|
||||
registerExtension(extension: ComfyExtension) {
|
||||
useExtensionService().registerExtension(extension)
|
||||
|
||||
@@ -124,19 +124,7 @@ export const useAlgoliaSearchService = (
|
||||
maxCacheSize = DEFAULT_MAX_CACHE_SIZE,
|
||||
minCharsForSuggestions = DEFAULT_MIN_CHARS_FOR_SUGGESTIONS
|
||||
} = options
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__, {
|
||||
hosts: [
|
||||
{
|
||||
url: 'search.comfy.org/api/search',
|
||||
accept: 'read',
|
||||
protocol: 'https'
|
||||
}
|
||||
],
|
||||
baseHeaders: {
|
||||
'X-Algolia-Application-Id': __ALGOLIA_APP_ID__,
|
||||
'X-Algolia-API-Key': __ALGOLIA_API_KEY__
|
||||
}
|
||||
})
|
||||
const searchClient = algoliasearch(__ALGOLIA_APP_ID__, __ALGOLIA_API_KEY__)
|
||||
const searchPacksCache = new QuickLRU<string, SearchPacksResult>({
|
||||
maxSize: maxCacheSize
|
||||
})
|
||||
|
||||
@@ -1,336 +0,0 @@
|
||||
/**
|
||||
* Maps MIME types and file extensions to handler functions for extracting
|
||||
* workflow data from various file formats. Uses supportedWorkflowFormats.ts
|
||||
* as the source of truth for supported formats.
|
||||
*/
|
||||
import {
|
||||
AUDIO_WORKFLOW_FORMATS,
|
||||
DATA_WORKFLOW_FORMATS,
|
||||
IMAGE_WORKFLOW_FORMATS,
|
||||
MODEL_WORKFLOW_FORMATS,
|
||||
VIDEO_WORKFLOW_FORMATS
|
||||
} from '@/constants/supportedWorkflowFormats'
|
||||
import type {
|
||||
ComfyApiWorkflow,
|
||||
ComfyWorkflowJSON
|
||||
} from '@/schemas/comfyWorkflowSchema'
|
||||
import { getFromWebmFile } from '@/scripts/metadata/ebml'
|
||||
import { getGltfBinaryMetadata } from '@/scripts/metadata/gltf'
|
||||
import { getFromIsobmffFile } from '@/scripts/metadata/isobmff'
|
||||
import { getMp3Metadata } from '@/scripts/metadata/mp3'
|
||||
import { getOggMetadata } from '@/scripts/metadata/ogg'
|
||||
import { getSvgMetadata } from '@/scripts/metadata/svg'
|
||||
import {
|
||||
getFlacMetadata,
|
||||
getLatentMetadata,
|
||||
getPngMetadata,
|
||||
getWebpMetadata
|
||||
} from '@/scripts/pnginfo'
|
||||
|
||||
/**
|
||||
* Type for the file handler function
|
||||
*/
|
||||
export type WorkflowFileHandler = (file: File) => Promise<{
|
||||
workflow?: ComfyWorkflowJSON
|
||||
prompt?: ComfyApiWorkflow
|
||||
parameters?: string
|
||||
jsonTemplateData?: any // For template JSON data
|
||||
}>
|
||||
|
||||
/**
|
||||
* Maps MIME types to file handlers for loading workflows from different file formats
|
||||
*/
|
||||
export const mimeTypeHandlers = new Map<string, WorkflowFileHandler>()
|
||||
|
||||
/**
|
||||
* Maps file extensions to file handlers for loading workflows
|
||||
* Used as a fallback when MIME type detection fails
|
||||
*/
|
||||
export const extensionHandlers = new Map<string, WorkflowFileHandler>()
|
||||
|
||||
/**
|
||||
* Handler for PNG files
|
||||
*/
|
||||
const handlePngFile: WorkflowFileHandler = async (file) => {
|
||||
const pngInfo = await getPngMetadata(file)
|
||||
return {
|
||||
workflow: pngInfo?.workflow ? JSON.parse(pngInfo.workflow) : undefined,
|
||||
prompt: pngInfo?.prompt ? JSON.parse(pngInfo.prompt) : undefined,
|
||||
parameters: pngInfo?.parameters
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for WebP files
|
||||
*/
|
||||
const handleWebpFile: WorkflowFileHandler = async (file) => {
|
||||
const pngInfo = await getWebpMetadata(file)
|
||||
// Support loading workflows from that webp custom node.
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
return {
|
||||
workflow: workflow ? JSON.parse(workflow) : undefined,
|
||||
prompt: prompt ? JSON.parse(prompt) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for SVG files
|
||||
*/
|
||||
const handleSvgFile: WorkflowFileHandler = async (file) => {
|
||||
const svgInfo = await getSvgMetadata(file)
|
||||
return {
|
||||
workflow: svgInfo.workflow,
|
||||
prompt: svgInfo.prompt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for MP3 files
|
||||
*/
|
||||
const handleMp3File: WorkflowFileHandler = async (file) => {
|
||||
const { workflow, prompt } = await getMp3Metadata(file)
|
||||
return { workflow, prompt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for OGG files
|
||||
*/
|
||||
const handleOggFile: WorkflowFileHandler = async (file) => {
|
||||
const { workflow, prompt } = await getOggMetadata(file)
|
||||
return { workflow, prompt }
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for FLAC files
|
||||
*/
|
||||
const handleFlacFile: WorkflowFileHandler = async (file) => {
|
||||
const pngInfo = await getFlacMetadata(file)
|
||||
const workflow = pngInfo?.workflow || pngInfo?.Workflow
|
||||
const prompt = pngInfo?.prompt || pngInfo?.Prompt
|
||||
|
||||
return {
|
||||
workflow: workflow ? JSON.parse(workflow) : undefined,
|
||||
prompt: prompt ? JSON.parse(prompt) : undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for WebM files
|
||||
*/
|
||||
const handleWebmFile: WorkflowFileHandler = async (file) => {
|
||||
const webmInfo = await getFromWebmFile(file)
|
||||
return {
|
||||
workflow: webmInfo.workflow,
|
||||
prompt: webmInfo.prompt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for MP4/MOV/M4V files
|
||||
*/
|
||||
const handleMp4File: WorkflowFileHandler = async (file) => {
|
||||
const mp4Info = await getFromIsobmffFile(file)
|
||||
return {
|
||||
workflow: mp4Info.workflow,
|
||||
prompt: mp4Info.prompt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for GLB files
|
||||
*/
|
||||
const handleGlbFile: WorkflowFileHandler = async (file) => {
|
||||
const gltfInfo = await getGltfBinaryMetadata(file)
|
||||
return {
|
||||
workflow: gltfInfo.workflow,
|
||||
prompt: gltfInfo.prompt
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for JSON files
|
||||
*/
|
||||
const handleJsonFile: WorkflowFileHandler = async (file) => {
|
||||
// For JSON files, we need to preserve the exact behavior from app.ts
|
||||
// This code intentionally mirrors the original implementation
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
try {
|
||||
const readerResult = reader.result as string
|
||||
const jsonContent = JSON.parse(readerResult)
|
||||
|
||||
if (jsonContent?.templates) {
|
||||
// This case will be handled separately in handleFile
|
||||
resolve({
|
||||
workflow: undefined,
|
||||
prompt: undefined,
|
||||
jsonTemplateData: jsonContent
|
||||
})
|
||||
} else if (isApiJson(jsonContent)) {
|
||||
// API JSON format
|
||||
resolve({ workflow: undefined, prompt: jsonContent })
|
||||
} else {
|
||||
// Regular workflow JSON
|
||||
resolve({ workflow: JSON.parse(readerResult), prompt: undefined })
|
||||
}
|
||||
} catch (error) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
reader.onerror = () => reject(reader.error)
|
||||
reader.readAsText(file)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler for .latent and .safetensors files
|
||||
*/
|
||||
const handleLatentFile: WorkflowFileHandler = async (file) => {
|
||||
// Preserve the exact behavior from app.ts for latent files
|
||||
const info = await getLatentMetadata(file)
|
||||
|
||||
// Direct port of the original code, preserving behavior for TS compatibility
|
||||
if (info && typeof info === 'object' && 'workflow' in info && info.workflow) {
|
||||
return {
|
||||
workflow: JSON.parse(info.workflow as string),
|
||||
prompt: undefined
|
||||
}
|
||||
} else if (
|
||||
info &&
|
||||
typeof info === 'object' &&
|
||||
'prompt' in info &&
|
||||
info.prompt
|
||||
) {
|
||||
return {
|
||||
workflow: undefined,
|
||||
prompt: JSON.parse(info.prompt as string)
|
||||
}
|
||||
} else {
|
||||
return { workflow: undefined, prompt: undefined }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to determine if a JSON object is in the API JSON format
|
||||
*/
|
||||
function isApiJson(data: unknown) {
|
||||
return (
|
||||
typeof data === 'object' &&
|
||||
data !== null &&
|
||||
Object.values(data as Record<string, any>).every((v) => v.class_type)
|
||||
)
|
||||
}
|
||||
|
||||
// Register image format handlers
|
||||
IMAGE_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'image/png') {
|
||||
mimeTypeHandlers.set(mimeType, handlePngFile)
|
||||
} else if (mimeType === 'image/webp') {
|
||||
mimeTypeHandlers.set(mimeType, handleWebpFile)
|
||||
} else if (mimeType === 'image/svg+xml') {
|
||||
mimeTypeHandlers.set(mimeType, handleSvgFile)
|
||||
}
|
||||
})
|
||||
|
||||
IMAGE_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.png') {
|
||||
extensionHandlers.set(ext, handlePngFile)
|
||||
} else if (ext === '.webp') {
|
||||
extensionHandlers.set(ext, handleWebpFile)
|
||||
} else if (ext === '.svg') {
|
||||
extensionHandlers.set(ext, handleSvgFile)
|
||||
}
|
||||
})
|
||||
|
||||
// Register audio format handlers
|
||||
AUDIO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'audio/mpeg') {
|
||||
mimeTypeHandlers.set(mimeType, handleMp3File)
|
||||
} else if (mimeType === 'audio/ogg') {
|
||||
mimeTypeHandlers.set(mimeType, handleOggFile)
|
||||
} else if (mimeType === 'audio/flac' || mimeType === 'audio/x-flac') {
|
||||
mimeTypeHandlers.set(mimeType, handleFlacFile)
|
||||
}
|
||||
})
|
||||
|
||||
AUDIO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.mp3') {
|
||||
extensionHandlers.set(ext, handleMp3File)
|
||||
} else if (ext === '.ogg') {
|
||||
extensionHandlers.set(ext, handleOggFile)
|
||||
} else if (ext === '.flac') {
|
||||
extensionHandlers.set(ext, handleFlacFile)
|
||||
}
|
||||
})
|
||||
|
||||
// Register video format handlers
|
||||
VIDEO_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'video/webm') {
|
||||
mimeTypeHandlers.set(mimeType, handleWebmFile)
|
||||
} else if (
|
||||
mimeType === 'video/mp4' ||
|
||||
mimeType === 'video/quicktime' ||
|
||||
mimeType === 'video/x-m4v'
|
||||
) {
|
||||
mimeTypeHandlers.set(mimeType, handleMp4File)
|
||||
}
|
||||
})
|
||||
|
||||
VIDEO_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.webm') {
|
||||
extensionHandlers.set(ext, handleWebmFile)
|
||||
} else if (ext === '.mp4' || ext === '.mov' || ext === '.m4v') {
|
||||
extensionHandlers.set(ext, handleMp4File)
|
||||
}
|
||||
})
|
||||
|
||||
// Register 3D model format handlers
|
||||
MODEL_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'model/gltf-binary') {
|
||||
mimeTypeHandlers.set(mimeType, handleGlbFile)
|
||||
}
|
||||
})
|
||||
|
||||
MODEL_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.glb') {
|
||||
extensionHandlers.set(ext, handleGlbFile)
|
||||
}
|
||||
})
|
||||
|
||||
// Register data format handlers
|
||||
DATA_WORKFLOW_FORMATS.mimeTypes.forEach((mimeType) => {
|
||||
if (mimeType === 'application/json') {
|
||||
mimeTypeHandlers.set(mimeType, handleJsonFile)
|
||||
}
|
||||
})
|
||||
|
||||
DATA_WORKFLOW_FORMATS.extensions.forEach((ext) => {
|
||||
if (ext === '.json') {
|
||||
extensionHandlers.set(ext, handleJsonFile)
|
||||
} else if (ext === '.latent' || ext === '.safetensors') {
|
||||
extensionHandlers.set(ext, handleLatentFile)
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Gets the appropriate file handler for a given file based on mime type or extension
|
||||
*/
|
||||
export function getFileHandler(file: File): WorkflowFileHandler | null {
|
||||
// First try to match by MIME type
|
||||
if (file.type && mimeTypeHandlers.has(file.type)) {
|
||||
return mimeTypeHandlers.get(file.type) || null
|
||||
}
|
||||
|
||||
// If no MIME type match, try to match by file extension
|
||||
if (file.name) {
|
||||
const extension = '.' + file.name.split('.').pop()?.toLowerCase()
|
||||
if (extension && extensionHandlers.has(extension)) {
|
||||
return extensionHandlers.get(extension) || null
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
})
|
||||