Compare commits
6 Commits
v1.28.2
...
remove-flo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8e1f494fa | ||
|
|
c82c3c24f7 | ||
|
|
fffa81c9b5 | ||
|
|
db35e0b7d2 | ||
|
|
0c6eeb0632 | ||
|
|
fca95ad07e |
9
.github/workflows/i18n-custom-nodes.yaml
vendored
@@ -32,10 +32,11 @@ jobs:
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI_frontend
|
||||
path: ComfyUI_frontend
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
- name: Checkout ComfyUI_devtools
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI_devtools
|
||||
path: ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
- name: Checkout custom node repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
10
.github/workflows/test-ui.yaml
vendored
@@ -27,10 +27,12 @@ jobs:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
- name: Checkout ComfyUI_devtools
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_devtools'
|
||||
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
|
||||
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
3
.gitignore
vendored
@@ -22,8 +22,6 @@ dist-ssr
|
||||
*.local
|
||||
# Claude configuration
|
||||
.claude/*.local.json
|
||||
.claude/*.local.md
|
||||
.claude/*.local.txt
|
||||
CLAUDE.local.md
|
||||
|
||||
# Editor directories and files
|
||||
@@ -46,7 +44,6 @@ components.d.ts
|
||||
tests-ui/data/*
|
||||
tests-ui/ComfyUI_examples
|
||||
tests-ui/workflows/examples
|
||||
coverage/
|
||||
|
||||
# Browser tests
|
||||
/test-results/
|
||||
|
||||
70
CODEOWNERS
@@ -1,61 +1,17 @@
|
||||
# Desktop/Electron
|
||||
/src/types/desktop/ @webfiltered
|
||||
/src/constants/desktopDialogs.ts @webfiltered
|
||||
/src/constants/desktopMaintenanceTasks.ts @webfiltered
|
||||
/src/stores/electronDownloadStore.ts @webfiltered
|
||||
/src/extensions/core/electronAdapter.ts @webfiltered
|
||||
/src/views/DesktopDialogView.vue @webfiltered
|
||||
/src/components/install/ @webfiltered
|
||||
/src/components/maintenance/ @webfiltered
|
||||
/vite.electron.config.mts @webfiltered
|
||||
# Admins
|
||||
* @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
# Common UI Components
|
||||
/src/components/chip/ @viva-jinyi
|
||||
/src/components/card/ @viva-jinyi
|
||||
/src/components/button/ @viva-jinyi
|
||||
/src/components/input/ @viva-jinyi
|
||||
# Maintainers
|
||||
*.md @Comfy-Org/comfy_maintainer
|
||||
/tests-ui/ @Comfy-Org/comfy_maintainer
|
||||
/browser_tests/ @Comfy-Org/comfy_maintainer
|
||||
/.env_example @Comfy-Org/comfy_maintainer
|
||||
|
||||
# Topbar
|
||||
/src/components/topbar/ @pythongosssss
|
||||
# Translations (AIGODLIKE team + shinshin86)
|
||||
/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
|
||||
# Thumbnail
|
||||
/src/renderer/core/thumbnail/ @pythongosssss
|
||||
# Load 3D extension
|
||||
/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
# Legacy UI
|
||||
/scripts/ui/ @pythongosssss
|
||||
|
||||
# Link rendering
|
||||
/src/renderer/core/canvas/links/ @benceruleanlu
|
||||
|
||||
# Node help system
|
||||
/src/utils/nodeHelpUtil.ts @benceruleanlu
|
||||
/src/stores/workspace/nodeHelpStore.ts @benceruleanlu
|
||||
/src/services/nodeHelpService.ts @benceruleanlu
|
||||
|
||||
# Selection toolbox
|
||||
/src/components/graph/selectionToolbox/ @Myestery
|
||||
|
||||
# Minimap
|
||||
/src/renderer/extensions/minimap/ @jtydhr88
|
||||
|
||||
# Assets
|
||||
/src/platform/assets/ @arjansingh
|
||||
|
||||
# Workflow Templates
|
||||
/src/platform/workflow/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
/src/components/templates/ @Myestery @christian-byrne @comfyui-wiki
|
||||
|
||||
# Mask Editor
|
||||
/src/extensions/core/maskeditor.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorLayerFilenames.ts @trsommer @brucew4yn3rp
|
||||
/src/extensions/core/maskEditorOld.ts @trsommer @brucew4yn3rp
|
||||
|
||||
# 3D
|
||||
/src/extensions/core/load3d.ts @jtydhr88
|
||||
/src/components/load3d/ @jtydhr88
|
||||
|
||||
# Manager
|
||||
/src/workbench/extensions/manager/ @viva-jinyi @christian-byrne @ltdrdata
|
||||
|
||||
# Translations
|
||||
/src/locales/ @Yorha4D @KarryCharon @shinshin86 @Comfy-Org/comfy_maintainer
|
||||
# Mask Editor extension
|
||||
/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @Comfy-Org/comfy_frontend_devs
|
||||
|
||||
@@ -16,14 +16,9 @@ Without this flag, parallel tests will conflict and fail randomly.
|
||||
|
||||
### ComfyUI devtools
|
||||
|
||||
ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `custom_nodes` directory.
|
||||
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
|
||||
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
|
||||
|
||||
For local development, copy the devtools files to your ComfyUI installation:
|
||||
```bash
|
||||
cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
```
|
||||
|
||||
### Node.js & Playwright Prerequisites
|
||||
|
||||
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
|
||||
@@ -56,6 +51,14 @@ TEST_COMFYUI_DIR=/path/to/your/ComfyUI
|
||||
|
||||
### Common Setup Issues
|
||||
|
||||
**Most tests require the new menu system** - Add to your test:
|
||||
|
||||
```typescript
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Top')
|
||||
})
|
||||
```
|
||||
|
||||
### Release API Mocking
|
||||
|
||||
By default, all tests mock the release API (`api.comfy.org/releases`) to prevent release notification popups from interfering with test execution. This is necessary because the release notifications can appear over UI elements and block test interactions.
|
||||
|
||||
@@ -1643,7 +1643,7 @@ export const comfyPageFixture = base.extend<{
|
||||
|
||||
try {
|
||||
await comfyPage.setupSettings({
|
||||
'Comfy.UseNewMenu': 'Top',
|
||||
'Comfy.UseNewMenu': 'Disabled',
|
||||
// Hide canvas menu/info/selection toolbox by default.
|
||||
'Comfy.Graph.CanvasInfo': false,
|
||||
'Comfy.Graph.CanvasMenu': false,
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Background Image Upload', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Reset the background image setting before each test
|
||||
|
||||
@@ -15,10 +15,6 @@ async function afterChange(comfyPage: ComfyPage) {
|
||||
})
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Change Tracker', () => {
|
||||
test.describe('Undo/Redo', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
|
||||
@@ -3,10 +3,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
interface ChatHistoryEntry {
|
||||
prompt: string
|
||||
response: string
|
||||
|
||||
@@ -3,10 +3,6 @@ import { expect } from '@playwright/test'
|
||||
import type { Palette } from '../../src/schemas/colorPaletteSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
const customColorPalettes: Record<string, Palette> = {
|
||||
obsidian: {
|
||||
version: 102,
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Should execute command', async ({ comfyPage }) => {
|
||||
await comfyPage.registerCommand('TestCommand', () => {
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Copy Paste', () => {
|
||||
test('Can copy and paste node', async ({ comfyPage }) => {
|
||||
await comfyPage.clickEmptyLatentNode()
|
||||
|
||||
@@ -4,10 +4,6 @@ import { expect } from '@playwright/test'
|
||||
import type { Keybinding } from '../../src/schemas/keyBindingSchema'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Load workflow warning', () => {
|
||||
test('Should display a warning when loading a workflow with missing nodes', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('DOM Widget', () => {
|
||||
test('Collapsed multiline textarea is not visible', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/collapsed_multiline')
|
||||
|
||||
|
Before Width: | Height: | Size: 88 KiB After Width: | Height: | Size: 88 KiB |
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Execution', () => {
|
||||
test('Report error on unconnected slot', async ({ comfyPage }) => {
|
||||
await comfyPage.disconnectEdge()
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Feature Flags', () => {
|
||||
test('Client and server exchange feature flags on connection', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Graph', () => {
|
||||
// Should be able to fix link input slot index after swap the input order
|
||||
// Ref: https://github.com/Comfy-Org/ComfyUI_frontend/issues/3348
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Graph Canvas Menu', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Set link render mode to spline to make sure it's not affected by other tests'
|
||||
|
||||
|
Before Width: | Height: | Size: 86 KiB After Width: | Height: | Size: 86 KiB |
|
Before Width: | Height: | Size: 100 KiB After Width: | Height: | Size: 100 KiB |
@@ -4,10 +4,6 @@ import type { ComfyPage } from '../fixtures/ComfyPage'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Group Node', () => {
|
||||
test.describe('Node library sidebar', () => {
|
||||
const groupNodeName = 'DefautWorkflowGroupNode'
|
||||
|
||||
@@ -9,10 +9,6 @@ import {
|
||||
} from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Item Interaction', () => {
|
||||
test('Can select/delete all items', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('groups/mixed_graph_items')
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Keybindings', () => {
|
||||
test('Should not trigger non-modifier keybinding when typing in input fields', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
function listenForEvent(): Promise<Event> {
|
||||
return new Promise<Event>((resolve) => {
|
||||
document.addEventListener('litegraph:canvas', (e) => resolve(e), {
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Load Workflow in Media', () => {
|
||||
const fileNames = [
|
||||
'workflow.webp',
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('LOD Threshold', () => {
|
||||
test('Should switch to low quality mode at correct zoom threshold', async ({
|
||||
comfyPage
|
||||
|
||||
@@ -4,10 +4,6 @@ import type { ComfyApp } from '../../src/scripts/app'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Node Badge', () => {
|
||||
test('Can add badge', async ({ comfyPage }) => {
|
||||
await comfyPage.page.evaluate(() => {
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
// If an input is optional by node definition, it should be shown as
|
||||
// a hollow circle no matter what shape it was defined in the workflow JSON.
|
||||
test.describe('Optional input', () => {
|
||||
|
||||
@@ -3,10 +3,6 @@ import {
|
||||
comfyPageFixture as test
|
||||
} from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Node search box', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.LinkRelease.Action', 'search box')
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Note Node', () => {
|
||||
test('Can load node nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('nodes/note_nodes')
|
||||
|
||||
@@ -3,10 +3,6 @@ import { expect } from '@playwright/test'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
import type { NodeReference } from '../fixtures/utils/litegraphUtils'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Primitive Node', () => {
|
||||
test('Can load with correct size', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('primitive/primitive_node')
|
||||
|
||||
@@ -40,7 +40,6 @@ test.describe('Reroute Node', () => {
|
||||
|
||||
test.describe('LiteGraph Native Reroute Node', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
await comfyPage.setSetting('LiteGraph.Reroute.SplineOffset', 80)
|
||||
})
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
@@ -3,10 +3,6 @@ import { expect } from '@playwright/test'
|
||||
import { NodeBadgeMode } from '../../src/types/nodeSource'
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Canvas Right Click Menu', () => {
|
||||
test('Can add node', async ({ comfyPage }) => {
|
||||
await comfyPage.rightClickCanvas()
|
||||
|
||||
@@ -4,9 +4,6 @@ import { comfyPageFixture } from '../fixtures/ComfyPage'
|
||||
|
||||
const test = comfyPageFixture
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
const BLUE_COLOR = 'rgb(51, 51, 85)'
|
||||
const RED_COLOR = 'rgb(85, 51, 51)'
|
||||
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Selection Toolbox - More Options Submenus', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.Canvas.SelectionToolbox', true)
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Settings Search functionality', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Register test settings to verify hidden/deprecated filtering
|
||||
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - Delete Key Interaction', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
// Enable Vue nodes rendering
|
||||
|
||||
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 53 KiB |
@@ -1,48 +0,0 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Nodes - LOD', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.setup()
|
||||
await comfyPage.loadWorkflow('default')
|
||||
})
|
||||
|
||||
test('should toggle LOD based on zoom threshold', async ({ comfyPage }) => {
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
|
||||
const initialNodeCount = await comfyPage.vueNodes.getNodeCount()
|
||||
expect(initialNodeCount).toBeGreaterThan(0)
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-default.png')
|
||||
|
||||
const vueNodesContainer = comfyPage.vueNodes.nodes
|
||||
const textboxesInNodes = vueNodesContainer.getByRole('textbox')
|
||||
const buttonsInNodes = vueNodesContainer.getByRole('button')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(buttonsInNodes.first()).toBeVisible()
|
||||
|
||||
await comfyPage.zoom(120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('vue-nodes-lod-active.png')
|
||||
|
||||
await expect(textboxesInNodes.first()).toBeHidden()
|
||||
await expect(buttonsInNodes.first()).toBeHidden()
|
||||
|
||||
await comfyPage.zoom(-120, 10)
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await expect(comfyPage.canvas).toHaveScreenshot(
|
||||
'vue-nodes-lod-inactive.png'
|
||||
)
|
||||
await expect(textboxesInNodes.first()).toBeVisible()
|
||||
await expect(buttonsInNodes.first()).toBeVisible()
|
||||
})
|
||||
})
|
||||
|
Before Width: | Height: | Size: 106 KiB |
|
Before Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 106 KiB |
@@ -1,51 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Vue Node Selection', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
const modifiers = [
|
||||
{ key: 'Control', name: 'ctrl' },
|
||||
{ key: 'Shift', name: 'shift' }
|
||||
] as const
|
||||
|
||||
for (const { key: modifier, name } of modifiers) {
|
||||
test(`should allow selecting multiple nodes with ${name}+click`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.page.getByText('Empty Latent Image').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(2)
|
||||
|
||||
await comfyPage.page.getByText('KSampler').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(3)
|
||||
})
|
||||
|
||||
test(`should allow de-selecting nodes with ${name}+click`, async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.page.getByText('Load Checkpoint').click()
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(1)
|
||||
|
||||
await comfyPage.page.getByText('Load Checkpoint').click({
|
||||
modifiers: [modifier]
|
||||
})
|
||||
expect(await comfyPage.vueNodes.getSelectedNodeCount()).toBe(0)
|
||||
})
|
||||
}
|
||||
})
|
||||
@@ -1,53 +0,0 @@
|
||||
import {
|
||||
comfyExpect as expect,
|
||||
comfyPageFixture as test
|
||||
} from '../../../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
const BYPASS_HOTKEY = 'Control+b'
|
||||
const BYPASS_CLASS = /before:bg-bypass\/60/
|
||||
|
||||
test.describe('Vue Node Bypass', () => {
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
|
||||
await comfyPage.vueNodes.waitForNodes()
|
||||
})
|
||||
|
||||
test('should allow toggling bypass on a selected node with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'Load Checkpoint'
|
||||
})
|
||||
await checkpointNode.getByText('Load Checkpoint').click()
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
})
|
||||
|
||||
test('should allow toggling bypass on multiple selected nodes with hotkey', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
const checkpointNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'Load Checkpoint'
|
||||
})
|
||||
const ksamplerNode = comfyPage.page.locator('[data-node-id]').filter({
|
||||
hasText: 'KSampler'
|
||||
})
|
||||
|
||||
await checkpointNode.getByText('Load Checkpoint').click()
|
||||
await ksamplerNode.getByText('KSampler').click({ modifiers: ['Control'] })
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).toHaveClass(BYPASS_CLASS)
|
||||
await expect(ksamplerNode).toHaveClass(BYPASS_CLASS)
|
||||
|
||||
await comfyPage.page.keyboard.press(BYPASS_HOTKEY)
|
||||
await expect(checkpointNode).not.toHaveClass(BYPASS_CLASS)
|
||||
await expect(ksamplerNode).not.toHaveClass(BYPASS_CLASS)
|
||||
})
|
||||
})
|
||||
@@ -2,10 +2,6 @@ import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '../fixtures/ComfyPage'
|
||||
|
||||
test.beforeEach(async ({ comfyPage }) => {
|
||||
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
|
||||
})
|
||||
|
||||
test.describe('Combo text widget', () => {
|
||||
test('Truncates text when resized', async ({ comfyPage }) => {
|
||||
await comfyPage.resizeLoadCheckpointNode(0.2, 1)
|
||||
@@ -322,9 +318,6 @@ test.describe('Animated image widget', () => {
|
||||
test.describe('Load audio widget', () => {
|
||||
test('Can load audio', async ({ comfyPage }) => {
|
||||
await comfyPage.loadWorkflow('widgets/load_audio_widget')
|
||||
// Wait for the audio widget to be rendered in the DOM
|
||||
await comfyPage.page.waitForSelector('.comfy-audio', { state: 'attached' })
|
||||
await comfyPage.nextFrame()
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('load_audio_widget.png')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
/* Test files should not be compiled */
|
||||
"noEmit": true,
|
||||
// "strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
/* Build scripts configuration */
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -33,13 +33,7 @@ export default defineConfig([
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'vite.config.mts',
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts'
|
||||
]
|
||||
},
|
||||
projectService: true,
|
||||
tsConfigRootDir: import.meta.dirname,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
@@ -83,25 +77,12 @@ export default defineConfig([
|
||||
'@typescript-eslint/prefer-as-const': 'off',
|
||||
'@typescript-eslint/consistent-type-imports': 'error',
|
||||
'@typescript-eslint/no-import-type-side-effects': 'error',
|
||||
'@typescript-eslint/no-empty-object-type': [
|
||||
'error',
|
||||
{
|
||||
allowInterfaces: 'always'
|
||||
}
|
||||
],
|
||||
'unused-imports/no-unused-imports': 'error',
|
||||
'vue/no-v-html': 'off',
|
||||
// Enforce dark-theme: instead of dark: prefix
|
||||
'vue/no-restricted-class': ['error', '/^dark:/'],
|
||||
'vue/multi-word-component-names': 'off', // TODO: fix
|
||||
'vue/no-template-shadow': 'off', // TODO: fix
|
||||
/* Toggle on to do additional until we can clean up existing violations.
|
||||
'vue/no-unused-emit-declarations': 'error',
|
||||
'vue/no-unused-properties': 'error',
|
||||
'vue/no-unused-refs': 'error',
|
||||
'vue/no-use-v-else-with-v-for': 'error',
|
||||
'vue/no-useless-v-bind': 'error',
|
||||
// */
|
||||
'vue/one-component-per-file': 'off', // TODO: fix
|
||||
'vue/require-default-prop': 'off', // TODO: fix -- this one is very worthwhile
|
||||
// Restrict deprecated PrimeVue components
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
<link rel="stylesheet" type="text/css" href="user.css" />
|
||||
<link rel="stylesheet" type="text/css" href="api/userdata/user.css" />
|
||||
|
||||
<!-- Fullscreen mode on mobile browsers -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<!-- Fullscreen mode on iOS -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<!-- Status bar style (eg. black or transparent) -->
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.28.2",
|
||||
"version": "1.28.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -27,8 +27,6 @@
|
||||
"preview": "nx preview",
|
||||
"lint": "eslint src --cache",
|
||||
"lint:fix": "eslint src --cache --fix",
|
||||
"lint:unstaged": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache",
|
||||
"lint:unstaged:fix": "git diff --name-only HEAD | grep -E '\\.(js|ts|vue|mts)$' | xargs -r eslint --cache --fix",
|
||||
"lint:no-cache": "eslint src",
|
||||
"lint:fix:no-cache": "eslint src --fix",
|
||||
"knip": "knip --cache",
|
||||
@@ -96,7 +94,6 @@
|
||||
"vite-plugin-html": "^3.2.2",
|
||||
"vite-plugin-vue-devtools": "^7.7.6",
|
||||
"vitest": "^3.2.4",
|
||||
"vue-component-type-helpers": "^3.0.7",
|
||||
"vue-eslint-parser": "^10.2.0",
|
||||
"vue-tsc": "^3.0.7",
|
||||
"zip-dir": "^2.0.0",
|
||||
|
||||
3
pnpm-lock.yaml
generated
@@ -339,9 +339,6 @@ importers:
|
||||
vitest:
|
||||
specifier: ^3.2.4
|
||||
version: 3.2.4(@types/debug@4.1.12)(@types/node@20.14.10)(@vitest/ui@3.2.4)(happy-dom@15.11.0)(jsdom@26.1.0)(lightningcss@1.30.1)(terser@5.39.2)
|
||||
vue-component-type-helpers:
|
||||
specifier: ^3.0.7
|
||||
version: 3.0.7
|
||||
vue-eslint-parser:
|
||||
specifier: ^10.2.0
|
||||
version: 10.2.0(eslint@9.35.0(jiti@2.4.2))
|
||||
|
||||
|
Before Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 40 KiB |
@@ -9,18 +9,9 @@ import { normalizeI18nKey } from '../src/utils/formatUtil'
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
|
||||
interface WidgetInfo {
|
||||
name?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface WidgetLabels {
|
||||
[key: string]: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
comfyPage.page.route('**/view**', async (route) => {
|
||||
await route.fulfill({
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
@@ -29,7 +20,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const nodeDefs: ComfyNodeDefImpl[] = (
|
||||
Object.values(
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
// @ts-expect-error - app is dynamically added to window
|
||||
const api = window['app'].api as ComfyApi
|
||||
return await api.getNodeDefs()
|
||||
})
|
||||
@@ -62,7 +52,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
)
|
||||
|
||||
async function extractWidgetLabels() {
|
||||
const nodeLabels: WidgetLabels = {}
|
||||
const nodeLabels = {}
|
||||
|
||||
for (const nodeDef of nodeDefs) {
|
||||
const inputNames = Object.values(nodeDef.inputs).map(
|
||||
@@ -75,15 +65,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const widgetsMappings = await comfyPage.page.evaluate(
|
||||
(args) => {
|
||||
const [nodeName, displayName, inputNames] = args
|
||||
// @ts-expect-error - LiteGraph is dynamically added to window
|
||||
const node = window['LiteGraph'].createNode(nodeName, displayName)
|
||||
if (!node.widgets?.length) return {}
|
||||
return Object.fromEntries(
|
||||
node.widgets
|
||||
.filter(
|
||||
(w: WidgetInfo) => w?.name && !inputNames.includes(w.name)
|
||||
)
|
||||
.map((w: WidgetInfo) => [w.name, w.label])
|
||||
.filter((w) => w?.name && !inputNames.includes(w.name))
|
||||
.map((w) => [w.name, w.label])
|
||||
)
|
||||
},
|
||||
[nodeDef.name, nodeDef.display_name, inputNames]
|
||||
|
||||
@@ -72,7 +72,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
|
||||
const relativePath = file.replace(srcLocaleDir, '')
|
||||
const targetPath = join(tempBaseDir, relativePath)
|
||||
ensureDir(dirname(targetPath))
|
||||
writeFileSync(targetPath, readFileSync(file, 'utf8'))
|
||||
writeFileSync(targetPath, readFileSync(file))
|
||||
}
|
||||
console.log('Captured current locale files to temp/base/')
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
/* Script files configuration */
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -929,6 +929,48 @@ audio.comfy-audio.empty-audio-widget {
|
||||
}
|
||||
/* End of [Desktop] Electron window specific styles */
|
||||
|
||||
/* Vue Node LOD (Level of Detail) System */
|
||||
/* These classes control rendering detail based on zoom level */
|
||||
|
||||
/* Minimal LOD (zoom <= 0.4) - Title only for performance */
|
||||
.lg-node--lod-minimal {
|
||||
min-height: 32px;
|
||||
transition: min-height 0.2s ease;
|
||||
/* Performance optimizations */
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-minimal .lg-node-body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
|
||||
.lg-node--lod-reduced {
|
||||
transition: opacity 0.1s ease;
|
||||
/* Performance optimizations */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-widget-label,
|
||||
.lg-node--lod-reduced .lg-slot-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-slot {
|
||||
opacity: 0.6;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-widget {
|
||||
margin: 2px 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Full LOD (zoom > 0.8) - Complete detail rendering */
|
||||
.lg-node--lod-full {
|
||||
/* Uses default styling - no overrides needed */
|
||||
}
|
||||
|
||||
.lg-node {
|
||||
/* Disable text selection on all nodes */
|
||||
@@ -954,51 +996,23 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
/* Global performance optimizations for LOD */
|
||||
.lg-node--lod-minimal,
|
||||
.lg-node--lod-reduced {
|
||||
/* Remove ALL expensive paint effects */
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-mask-image: none !important;
|
||||
mask-image: none !important;
|
||||
clip-path: none !important;
|
||||
}
|
||||
|
||||
.isLOD .lg-node-widgets {
|
||||
pointer-events: none;
|
||||
/* Reduce paint complexity for minimal LOD */
|
||||
.lg-node--lod-minimal {
|
||||
/* Skip complex borders */
|
||||
border-radius: 0 !important;
|
||||
/* Use solid colors only */
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* Cross-browser async utilities for scheduling tasks during browser idle time
|
||||
* with proper fallbacks for browsers that don't support requestIdleCallback.
|
||||
*
|
||||
* Implementation based on:
|
||||
* https://github.com/microsoft/vscode/blob/main/src/vs/base/common/async.ts
|
||||
*/
|
||||
|
||||
interface IdleDeadline {
|
||||
didTimeout: boolean
|
||||
timeRemaining(): number
|
||||
}
|
||||
|
||||
interface IDisposable {
|
||||
dispose(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Internal implementation function that handles the actual scheduling logic.
|
||||
* Uses feature detection to determine whether to use native requestIdleCallback
|
||||
* or fall back to setTimeout-based implementation.
|
||||
*/
|
||||
let _runWhenIdle: (
|
||||
targetWindow: any,
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
/**
|
||||
* Execute the callback during the next browser idle period.
|
||||
* Falls back to setTimeout-based scheduling in browsers without native support.
|
||||
*/
|
||||
export let runWhenGlobalIdle: (
|
||||
callback: (idle: IdleDeadline) => void,
|
||||
timeout?: number
|
||||
) => IDisposable
|
||||
|
||||
// Self-invoking function to set up the idle callback implementation
|
||||
;(function () {
|
||||
const safeGlobal: any = globalThis
|
||||
|
||||
if (
|
||||
typeof safeGlobal.requestIdleCallback !== 'function' ||
|
||||
typeof safeGlobal.cancelIdleCallback !== 'function'
|
||||
) {
|
||||
// Fallback implementation for browsers without native support (e.g., Safari)
|
||||
_runWhenIdle = (_targetWindow, runner, _timeout?) => {
|
||||
setTimeout(() => {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
|
||||
// Simulate IdleDeadline - give 15ms window (one frame at ~64fps)
|
||||
const end = Date.now() + 15
|
||||
const deadline: IdleDeadline = {
|
||||
didTimeout: true,
|
||||
timeRemaining() {
|
||||
return Math.max(0, end - Date.now())
|
||||
}
|
||||
}
|
||||
|
||||
runner(Object.freeze(deadline))
|
||||
})
|
||||
|
||||
let disposed = false
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Native requestIdleCallback implementation
|
||||
_runWhenIdle = (targetWindow: typeof safeGlobal, runner, timeout?) => {
|
||||
const handle: number = targetWindow.requestIdleCallback(
|
||||
runner,
|
||||
typeof timeout === 'number' ? { timeout } : undefined
|
||||
)
|
||||
|
||||
let disposed = false
|
||||
return {
|
||||
dispose() {
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
disposed = true
|
||||
targetWindow.cancelIdleCallback(handle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
runWhenGlobalIdle = (runner, timeout) =>
|
||||
_runWhenIdle(globalThis, runner, timeout)
|
||||
})()
|
||||
@@ -1,8 +1,5 @@
|
||||
<template>
|
||||
<div
|
||||
ref="rootEl"
|
||||
class="relative overflow-hidden h-full w-full bg-neutral-900"
|
||||
>
|
||||
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black">
|
||||
<div class="p-terminal rounded-none h-full w-full p-2">
|
||||
<div ref="terminalEl" class="h-full terminal-host" />
|
||||
</div>
|
||||
@@ -101,13 +98,12 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../../../assets/css/style.css';
|
||||
|
||||
:deep(.p-terminal) .xterm {
|
||||
@apply overflow-hidden;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
:deep(.p-terminal) .xterm-screen {
|
||||
@apply bg-neutral-900 overflow-hidden;
|
||||
background-color: black;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
<template>
|
||||
<div :class="wrapperClass">
|
||||
<div class="grid grid-rows-2 gap-8">
|
||||
<!-- Top container: Logo -->
|
||||
<div class="flex items-end justify-center">
|
||||
<img
|
||||
src="/assets/images/comfy-brand-mark.svg"
|
||||
:alt="t('g.logoAlt')"
|
||||
class="w-60"
|
||||
/>
|
||||
</div>
|
||||
<!-- Bottom container: Progress and text -->
|
||||
<div class="flex flex-col items-center justify-center gap-4">
|
||||
<ProgressBar
|
||||
v-if="!hideProgress"
|
||||
:mode="progressMode"
|
||||
:value="progressPercentage ?? 0"
|
||||
:show-value="false"
|
||||
class="w-90 h-2 mt-8"
|
||||
:pt="{ value: { class: 'bg-brand-yellow' } }"
|
||||
/>
|
||||
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
|
||||
{{ title }}
|
||||
</h1>
|
||||
<p v-if="statusText" class="text-lg text-neutral-400">
|
||||
{{ statusText }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import ProgressBar from 'primevue/progressbar'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
/** Props for the StartupDisplay component */
|
||||
interface StartupDisplayProps {
|
||||
/** Progress: 0-100 for determinate, undefined for indeterminate */
|
||||
progressPercentage?: number
|
||||
/** Main title text */
|
||||
title?: string
|
||||
/** Status text shown below the title */
|
||||
statusText?: string
|
||||
/** Hide the progress bar */
|
||||
hideProgress?: boolean
|
||||
/** Use full screen wrapper (default: true) */
|
||||
fullScreen?: boolean
|
||||
}
|
||||
|
||||
const {
|
||||
progressPercentage,
|
||||
title,
|
||||
statusText,
|
||||
hideProgress = false,
|
||||
fullScreen = true
|
||||
} = defineProps<StartupDisplayProps>()
|
||||
|
||||
const progressMode = computed(() =>
|
||||
progressPercentage === undefined ? 'indeterminate' : 'determinate'
|
||||
)
|
||||
|
||||
const wrapperClass = computed(() =>
|
||||
fullScreen
|
||||
? 'flex items-center justify-center min-h-screen'
|
||||
: 'flex items-center justify-center'
|
||||
)
|
||||
</script>
|
||||
@@ -33,7 +33,7 @@
|
||||
|
||||
<!-- TransformPane for Vue node rendering -->
|
||||
<TransformPane
|
||||
v-if="shouldRenderVueNodes && comfyApp.canvas && comfyAppReady"
|
||||
v-if="isVueNodesEnabled && comfyApp.canvas && comfyAppReady"
|
||||
:canvas="comfyApp.canvas"
|
||||
@transform-update="handleTransformUpdate"
|
||||
@wheel.capture="canvasInteractions.forwardEventToCanvas"
|
||||
@@ -43,6 +43,8 @@
|
||||
v-for="nodeData in allNodes"
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:readonly="false"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
@@ -51,6 +53,9 @@
|
||||
"
|
||||
:zoom-level="canvasStore.canvas?.ds?.scale || 1"
|
||||
:data-node-id="nodeData.id"
|
||||
@node-click="handleNodeSelect"
|
||||
@update:collapsed="handleNodeCollapse"
|
||||
@update:title="handleNodeTitleUpdate"
|
||||
/>
|
||||
</TransformPane>
|
||||
|
||||
@@ -74,6 +79,7 @@ import {
|
||||
nextTick,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
provide,
|
||||
ref,
|
||||
shallowRef,
|
||||
watch,
|
||||
@@ -111,11 +117,14 @@ import { useWorkflowStore } from '@/platform/workflow/management/stores/workflow
|
||||
import { useWorkflowAutoSave } from '@/platform/workflow/persistence/composables/useWorkflowAutoSave'
|
||||
import { useWorkflowPersistence } from '@/platform/workflow/persistence/composables/useWorkflowPersistence'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { SelectedNodeIdsKey } from '@/renderer/core/canvas/injectionKeys'
|
||||
import { attachSlotLinkPreviewRenderer } from '@/renderer/core/canvas/links/slotLinkPreviewRenderer'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import TransformPane from '@/renderer/core/layout/transform/TransformPane.vue'
|
||||
import MiniMap from '@/renderer/extensions/minimap/MiniMap.vue'
|
||||
import VueGraphNode from '@/renderer/extensions/vueNodes/components/LGraphNode.vue'
|
||||
import { useNodeEventHandlers } from '@/renderer/extensions/vueNodes/composables/useNodeEventHandlers'
|
||||
import { useExecutionStateProvider } from '@/renderer/extensions/vueNodes/execution/useExecutionStateProvider'
|
||||
import { UnauthorizedError, api } from '@/scripts/api'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
import { ChangeTracker } from '@/scripts/changeTracker'
|
||||
@@ -162,13 +171,20 @@ const minimapEnabled = computed(() => settingStore.get('Comfy.Minimap.Visible'))
|
||||
|
||||
// Feature flags
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const isVueNodesEnabled = computed(() => shouldRenderVueNodes.value)
|
||||
|
||||
// Vue node system
|
||||
const vueNodeLifecycle = useVueNodeLifecycle()
|
||||
const viewportCulling = useViewportCulling()
|
||||
const vueNodeLifecycle = useVueNodeLifecycle(isVueNodesEnabled)
|
||||
const viewportCulling = useViewportCulling(
|
||||
isVueNodesEnabled,
|
||||
vueNodeLifecycle.vueNodeData,
|
||||
vueNodeLifecycle.nodeDataTrigger,
|
||||
vueNodeLifecycle.nodeManager
|
||||
)
|
||||
const nodeEventHandlers = useNodeEventHandlers(vueNodeLifecycle.nodeManager)
|
||||
|
||||
const handleVueNodeLifecycleReset = async () => {
|
||||
if (shouldRenderVueNodes.value) {
|
||||
if (isVueNodesEnabled.value) {
|
||||
vueNodeLifecycle.disposeNodeManagerAndSyncs()
|
||||
await nextTick()
|
||||
vueNodeLifecycle.initializeNodeManager()
|
||||
@@ -187,8 +203,32 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const nodePositions = vueNodeLifecycle.nodePositions
|
||||
const nodeSizes = vueNodeLifecycle.nodeSizes
|
||||
const allNodes = viewportCulling.allNodes
|
||||
const handleTransformUpdate = viewportCulling.handleTransformUpdate
|
||||
|
||||
const handleTransformUpdate = () => {
|
||||
viewportCulling.handleTransformUpdate()
|
||||
// TODO: Fix paste position sync in separate PR
|
||||
vueNodeLifecycle.detectChangesInRAF.value()
|
||||
}
|
||||
const handleNodeSelect = nodeEventHandlers.handleNodeSelect
|
||||
const handleNodeCollapse = nodeEventHandlers.handleNodeCollapse
|
||||
const handleNodeTitleUpdate = nodeEventHandlers.handleNodeTitleUpdate
|
||||
|
||||
// Provide selection state to all Vue nodes
|
||||
const selectedNodeIds = computed(
|
||||
() =>
|
||||
new Set(
|
||||
canvasStore.selectedItems
|
||||
.filter((item) => item.id !== undefined)
|
||||
.map((item) => String(item.id))
|
||||
)
|
||||
)
|
||||
provide(SelectedNodeIdsKey, selectedNodeIds)
|
||||
|
||||
// Provide execution state to all Vue nodes
|
||||
useExecutionStateProvider()
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
@@ -307,27 +347,13 @@ watch(
|
||||
removeSlotError(node)
|
||||
const nodeErrors = lastNodeErrors?.[node.id]
|
||||
if (!nodeErrors) continue
|
||||
|
||||
const validErrors = nodeErrors.errors.filter(
|
||||
(error) => error.extra_info?.input_name !== undefined
|
||||
)
|
||||
const slotErrorsChanged =
|
||||
validErrors.length > 0 &&
|
||||
validErrors.some((error) => {
|
||||
const inputName = error.extra_info!.input_name!
|
||||
const inputIndex = node.findInputSlot(inputName)
|
||||
for (const error of nodeErrors.errors) {
|
||||
if (error.extra_info && error.extra_info.input_name) {
|
||||
const inputIndex = node.findInputSlot(error.extra_info.input_name)
|
||||
if (inputIndex !== -1) {
|
||||
node.inputs[inputIndex].hasErrors = true
|
||||
return true
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// Trigger Vue node data update if slot errors changed
|
||||
if (slotErrorsChanged && comfyApp.graph.onTrigger) {
|
||||
comfyApp.graph.onTrigger('node:slot-errors:changed', {
|
||||
nodeId: node.id
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,11 +33,9 @@ const tooltipText = ref('')
|
||||
const left = ref<string>()
|
||||
const top = ref<string>()
|
||||
|
||||
function hideTooltip() {
|
||||
return (tooltipText.value = '')
|
||||
}
|
||||
const hideTooltip = () => (tooltipText.value = '')
|
||||
|
||||
async function showTooltip(tooltip: string | null | undefined) {
|
||||
const showTooltip = async (tooltip: string | null | undefined) => {
|
||||
if (!tooltip) return
|
||||
|
||||
left.value = comfyApp.canvas.mouse[0] + 'px'
|
||||
@@ -58,9 +56,9 @@ async function showTooltip(tooltip: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function onIdle() {
|
||||
const onIdle = () => {
|
||||
const { canvas } = comfyApp
|
||||
const node = canvas?.node_over
|
||||
const node = canvas.node_over
|
||||
if (!node) return
|
||||
|
||||
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
:style="`backgroundColor: ${containerStyles.backgroundColor};`"
|
||||
:pt="{
|
||||
header: 'hidden',
|
||||
content: 'p-1 h-10 flex flex-row gap-1'
|
||||
content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1'
|
||||
}"
|
||||
@wheel="canvasInteractions.handleWheel"
|
||||
>
|
||||
|
||||
@@ -49,11 +49,11 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import Button from 'primevue/button'
|
||||
import Popover from 'primevue/popover'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import {
|
||||
forceCloseMoreOptionsSignal,
|
||||
moreOptionsOpen,
|
||||
@@ -152,7 +152,9 @@ const repositionPopover = () => {
|
||||
}
|
||||
}
|
||||
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(repositionPopover)
|
||||
const { startSync, stopSync } = useCanvasTransformSync(repositionPopover, {
|
||||
autoStart: false
|
||||
})
|
||||
|
||||
function openPopover(triggerEvent?: Event): boolean {
|
||||
const el = getButtonEl()
|
||||
|
||||
@@ -168,8 +168,7 @@ const EXTERNAL_LINKS = {
|
||||
DOCS: 'https://docs.comfy.org/',
|
||||
DISCORD: 'https://www.comfy.org/discord',
|
||||
GITHUB: 'https://github.com/comfyanonymous/ComfyUI',
|
||||
DESKTOP_GUIDE_WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
|
||||
DESKTOP_GUIDE_MACOS: 'https://docs.comfy.org/installation/desktop/macos',
|
||||
DESKTOP_GUIDE: 'https://comfyorg.notion.site/',
|
||||
UPDATE_GUIDE: 'https://docs.comfy.org/installation/update_comfyui'
|
||||
} as const
|
||||
|
||||
@@ -223,11 +222,7 @@ const moreItems = computed<MenuItem[]>(() => {
|
||||
label: t('helpCenter.desktopUserGuide'),
|
||||
visible: isElectron(),
|
||||
action: () => {
|
||||
const docsUrl =
|
||||
electronAPI().getPlatform() === 'darwin'
|
||||
? EXTERNAL_LINKS.DESKTOP_GUIDE_MACOS
|
||||
: EXTERNAL_LINKS.DESKTOP_GUIDE_WINDOWS
|
||||
openExternalLink(docsUrl)
|
||||
openExternalLink(EXTERNAL_LINKS.DESKTOP_GUIDE)
|
||||
emit('close')
|
||||
}
|
||||
},
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg text-sm">
|
||||
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg">
|
||||
<!-- Auto Update Setting -->
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t('install.settings.autoUpdate') }}
|
||||
</h3>
|
||||
<p class="text-neutral-400 mt-1">
|
||||
<p class="text-sm text-neutral-400 mt-1">
|
||||
{{ $t('install.settings.autoUpdateDescription') }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -32,10 +32,14 @@
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t('install.settings.allowMetrics') }}
|
||||
</h3>
|
||||
<p class="text-neutral-400">
|
||||
<p class="text-sm text-neutral-400 mt-1">
|
||||
{{ $t('install.settings.allowMetricsDescription') }}
|
||||
</p>
|
||||
<a href="#" @click.prevent="showMetricsInfo">
|
||||
<a
|
||||
href="#"
|
||||
class="text-sm text-blue-400 hover:text-blue-300 mt-1 inline-block"
|
||||
@click.prevent="showMetricsInfo"
|
||||
>
|
||||
{{ $t('install.settings.learnMoreAboutData') }}
|
||||
</a>
|
||||
</div>
|
||||
@@ -47,9 +51,7 @@
|
||||
<Dialog
|
||||
v-model:visible="showDialog"
|
||||
modal
|
||||
dismissable-mask
|
||||
:header="$t('install.settings.dataCollectionDialog.title')"
|
||||
class="select-none"
|
||||
>
|
||||
<div class="text-neutral-300">
|
||||
<h4 class="font-medium mb-2">
|
||||
@@ -108,7 +110,11 @@
|
||||
</ul>
|
||||
|
||||
<div class="mt-4">
|
||||
<a href="https://comfy.org/privacy" target="_blank">
|
||||
<a
|
||||
href="https://comfy.org/privacy"
|
||||
target="_blank"
|
||||
class="text-blue-400 hover:text-blue-300 underline"
|
||||
>
|
||||
{{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -1,66 +1,130 @@
|
||||
<template>
|
||||
<div
|
||||
class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
|
||||
>
|
||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
||||
{{ $t('install.gpuPicker.title') }}
|
||||
</h2>
|
||||
<div class="flex flex-col gap-6 w-[600px] h-[30rem] select-none">
|
||||
<!-- Installation Path Section -->
|
||||
<div class="grow flex flex-col gap-4 text-neutral-300">
|
||||
<h2 class="text-2xl font-semibold text-neutral-100">
|
||||
{{ $t('install.gpuSelection.selectGpu') }}
|
||||
</h2>
|
||||
|
||||
<!-- GPU Selection buttons - takes up remaining space and centers content -->
|
||||
<div class="flex-1 flex gap-8 justify-center items-center">
|
||||
<!-- Apple Metal / NVIDIA -->
|
||||
<HardwareOption
|
||||
v-if="platform === 'darwin'"
|
||||
:image-path="'/assets/images/apple-mps-logo.png'"
|
||||
placeholder-text="Apple Metal"
|
||||
subtitle="Apple Metal"
|
||||
:value="'mps'"
|
||||
:selected="selected === 'mps'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('mps')"
|
||||
/>
|
||||
<HardwareOption
|
||||
v-else
|
||||
:image-path="'/assets/images/nvidia-logo-square.jpg'"
|
||||
placeholder-text="NVIDIA"
|
||||
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
|
||||
:value="'nvidia'"
|
||||
:selected="selected === 'nvidia'"
|
||||
:recommended="true"
|
||||
@click="pickGpu('nvidia')"
|
||||
/>
|
||||
<!-- CPU -->
|
||||
<HardwareOption
|
||||
placeholder-text="CPU"
|
||||
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
|
||||
:value="'cpu'"
|
||||
:selected="selected === 'cpu'"
|
||||
@click="pickGpu('cpu')"
|
||||
/>
|
||||
<!-- Manual Install -->
|
||||
<HardwareOption
|
||||
placeholder-text="Manual Install"
|
||||
:subtitle="$t('install.gpuPicker.manualSubtitle')"
|
||||
:value="'unsupported'"
|
||||
:selected="selected === 'unsupported'"
|
||||
@click="pickGpu('unsupported')"
|
||||
/>
|
||||
</div>
|
||||
<p class="m-1 text-neutral-400">
|
||||
{{ $t('install.gpuSelection.selectGpuDescription') }}:
|
||||
</p>
|
||||
|
||||
<div class="pt-12 px-24 h-16">
|
||||
<div v-show="showRecommendedBadge" class="flex items-center gap-2">
|
||||
<Tag
|
||||
:value="$t('install.gpuPicker.recommended')"
|
||||
class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
|
||||
/>
|
||||
<i-lucide:badge-check class="text-neutral-300 text-lg" />
|
||||
<!-- GPU Selection buttons -->
|
||||
<div
|
||||
class="flex gap-2 text-center transition-opacity"
|
||||
:class="{ selected: selected }"
|
||||
>
|
||||
<!-- NVIDIA -->
|
||||
<div
|
||||
v-if="platform !== 'darwin'"
|
||||
class="gpu-button"
|
||||
:class="{ selected: selected === 'nvidia' }"
|
||||
role="button"
|
||||
@click="pickGpu('nvidia')"
|
||||
>
|
||||
<img
|
||||
class="m-12"
|
||||
alt="NVIDIA logo"
|
||||
width="196"
|
||||
height="32"
|
||||
src="/assets/images/nvidia-logo.svg"
|
||||
/>
|
||||
</div>
|
||||
<!-- MPS -->
|
||||
<div
|
||||
v-if="platform === 'darwin'"
|
||||
class="gpu-button"
|
||||
:class="{ selected: selected === 'mps' }"
|
||||
role="button"
|
||||
@click="pickGpu('mps')"
|
||||
>
|
||||
<img
|
||||
class="rounded-lg hover-brighten"
|
||||
alt="Apple Metal Performance Shaders Logo"
|
||||
width="292"
|
||||
ratio
|
||||
src="/assets/images/apple-mps-logo.png"
|
||||
/>
|
||||
</div>
|
||||
<!-- Manual configuration -->
|
||||
<div
|
||||
class="gpu-button"
|
||||
:class="{ selected: selected === 'unsupported' }"
|
||||
role="button"
|
||||
@click="pickGpu('unsupported')"
|
||||
>
|
||||
<img
|
||||
class="m-12"
|
||||
alt="Manual configuration"
|
||||
width="196"
|
||||
src="/assets/images/manual-configuration.svg"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Details on selected GPU -->
|
||||
<p v-if="selected === 'nvidia'" class="m-1">
|
||||
<Tag icon="pi pi-check" severity="success" :value="'CUDA'" />
|
||||
{{ $t('install.gpuSelection.nvidiaDescription') }}
|
||||
</p>
|
||||
|
||||
<p v-if="selected === 'mps'" class="m-1">
|
||||
<Tag icon="pi pi-check" severity="success" :value="'MPS'" />
|
||||
{{ $t('install.gpuSelection.mpsDescription') }}
|
||||
</p>
|
||||
|
||||
<div v-if="selected === 'unsupported'" class="text-neutral-300">
|
||||
<p class="m-1">
|
||||
<Tag
|
||||
icon="pi pi-exclamation-triangle"
|
||||
severity="warn"
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
{{ $t('install.gpuSelection.customSkipsPython') }}
|
||||
</p>
|
||||
|
||||
<ul>
|
||||
<li>
|
||||
<strong>
|
||||
{{ $t('install.gpuSelection.customComfyNeedsPython') }}
|
||||
</strong>
|
||||
</li>
|
||||
<li>{{ $t('install.gpuSelection.customManualVenv') }}</li>
|
||||
<li>{{ $t('install.gpuSelection.customInstallRequirements') }}</li>
|
||||
<li>{{ $t('install.gpuSelection.customMayNotWork') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div v-if="selected === 'cpu'">
|
||||
<p class="m-1">
|
||||
<Tag
|
||||
icon="pi pi-exclamation-triangle"
|
||||
severity="warn"
|
||||
:value="t('icon.exclamation-triangle')"
|
||||
/>
|
||||
{{ $t('install.gpuSelection.cpuModeDescription') }}
|
||||
</p>
|
||||
<p class="m-1">
|
||||
{{ $t('install.gpuSelection.cpuModeDescription2') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-neutral-300 px-24">
|
||||
<p v-show="descriptionText" class="leading-relaxed">
|
||||
{{ descriptionText }}
|
||||
</p>
|
||||
<div
|
||||
class="transition-opacity flex gap-3 h-0"
|
||||
:class="{
|
||||
'opacity-40': selected && selected !== 'cpu'
|
||||
}"
|
||||
>
|
||||
<ToggleSwitch
|
||||
v-model="cpuMode"
|
||||
input-id="cpu-mode"
|
||||
class="-translate-y-40"
|
||||
/>
|
||||
<label for="cpu-mode" class="select-none">
|
||||
{{ $t('install.gpuSelection.enableCpuMode') }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -68,12 +132,20 @@
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import Tag from 'primevue/tag'
|
||||
import ToggleSwitch from 'primevue/toggleswitch'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import HardwareOption from '@/components/install/HardwareOption.vue'
|
||||
import { st } from '@/i18n'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cpuMode = computed({
|
||||
get: () => selected.value === 'cpu',
|
||||
set: (value) => {
|
||||
selected.value = value ? 'cpu' : null
|
||||
}
|
||||
})
|
||||
const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
required: true
|
||||
})
|
||||
@@ -81,23 +153,55 @@ const selected = defineModel<TorchDeviceType | null>('device', {
|
||||
const electron = electronAPI()
|
||||
const platform = electron.getPlatform()
|
||||
|
||||
const showRecommendedBadge = computed(
|
||||
() => selected.value === 'mps' || selected.value === 'nvidia'
|
||||
)
|
||||
|
||||
const descriptionKeys = {
|
||||
mps: 'appleMetal',
|
||||
nvidia: 'nvidia',
|
||||
cpu: 'cpu',
|
||||
unsupported: 'manual'
|
||||
} as const
|
||||
|
||||
const descriptionText = computed(() => {
|
||||
const key = selected.value ? descriptionKeys[selected.value] : undefined
|
||||
return st(`install.gpuPicker.${key}Description`, '')
|
||||
})
|
||||
|
||||
const pickGpu = (value: TorchDeviceType) => {
|
||||
selected.value = value
|
||||
const pickGpu = (value: typeof selected.value) => {
|
||||
const newValue = selected.value === value ? null : value
|
||||
selected.value = newValue
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.p-tag {
|
||||
--p-tag-gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hover-brighten {
|
||||
@apply transition-colors;
|
||||
transition-property: filter, box-shadow;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(107%) contrast(105%);
|
||||
box-shadow: 0 0 0.25rem #ffffff79;
|
||||
}
|
||||
}
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-neutral-900 rounded-lg transition-colors;
|
||||
}
|
||||
|
||||
div.selected {
|
||||
.gpu-button:not(.selected) {
|
||||
@apply opacity-50 hover:opacity-100;
|
||||
}
|
||||
}
|
||||
|
||||
.gpu-button {
|
||||
@apply w-1/2 m-0 cursor-pointer rounded-lg flex flex-col items-center justify-around bg-neutral-800/50 hover:bg-neutral-800/75 transition-colors;
|
||||
|
||||
&.selected {
|
||||
@apply opacity-100 bg-neutral-700/50 hover:bg-neutral-700/60;
|
||||
}
|
||||
}
|
||||
|
||||
.disabled {
|
||||
@apply pointer-events-none opacity-40;
|
||||
}
|
||||
|
||||
.p-card-header {
|
||||
@apply text-center grow;
|
||||
}
|
||||
|
||||
.p-card-body {
|
||||
@apply text-center pt-0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
|
||||
import HardwareOption from './HardwareOption.vue'
|
||||
|
||||
const meta: Meta<typeof HardwareOption> = {
|
||||
title: 'Desktop/Components/HardwareOption',
|
||||
component: HardwareOption,
|
||||
parameters: {
|
||||
layout: 'centered',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [{ name: 'dark', value: '#1a1a1a' }]
|
||||
}
|
||||
},
|
||||
argTypes: {
|
||||
selected: { control: 'boolean' },
|
||||
imagePath: { control: 'text' },
|
||||
placeholderText: { control: 'text' },
|
||||
subtitle: { control: 'text' }
|
||||
}
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const AppleMetalSelected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
|
||||
export const AppleMetalUnselected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/apple-mps-logo.png',
|
||||
placeholderText: 'Apple Metal',
|
||||
subtitle: 'Apple Metal',
|
||||
value: 'mps',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const CPUOption: Story = {
|
||||
args: {
|
||||
placeholderText: 'CPU',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'cpu',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const ManualInstall: Story = {
|
||||
args: {
|
||||
placeholderText: 'Manual Install',
|
||||
subtitle: 'Subtitle',
|
||||
value: 'unsupported',
|
||||
selected: false
|
||||
}
|
||||
}
|
||||
|
||||
export const NvidiaSelected: Story = {
|
||||
args: {
|
||||
imagePath: '/assets/images/nvidia-logo-square.jpg',
|
||||
placeholderText: 'NVIDIA',
|
||||
subtitle: 'NVIDIA',
|
||||
value: 'nvidia',
|
||||
selected: true
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Recommended Badge -->
|
||||
<button
|
||||
:class="
|
||||
cn(
|
||||
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
|
||||
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
|
||||
)
|
||||
"
|
||||
@click="$emit('click')"
|
||||
>
|
||||
<!-- Icon/Logo Area - Rounded square container -->
|
||||
<div
|
||||
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
|
||||
>
|
||||
<img
|
||||
v-if="imagePath"
|
||||
:src="imagePath"
|
||||
:alt="placeholderText"
|
||||
class="w-full h-full object-cover"
|
||||
style="object-position: 57% center"
|
||||
draggable="false"
|
||||
/>
|
||||
<span v-else class="text-xl font-medium text-neutral-400">
|
||||
{{ placeholderText }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div v-if="subtitle" class="text-center mt-4">
|
||||
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface Props {
|
||||
imagePath?: string
|
||||
placeholderText: string
|
||||
subtitle?: string
|
||||
value: TorchDeviceType
|
||||
selected?: boolean
|
||||
recommended?: boolean
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
|
||||
defineEmits<{ click: [] }>()
|
||||
</script>
|
||||
@@ -1,79 +0,0 @@
|
||||
<template>
|
||||
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
|
||||
<!-- Back button -->
|
||||
<Button
|
||||
v-if="currentStep !== '1'"
|
||||
:label="$t('g.back')"
|
||||
severity="secondary"
|
||||
icon="pi pi-arrow-left"
|
||||
class="font-inter rounded-lg border-0 px-6 py-2 justify-self-start"
|
||||
@click="$emit('previous')"
|
||||
/>
|
||||
<div v-else></div>
|
||||
|
||||
<!-- Step indicators in center -->
|
||||
<StepList class="flex justify-center items-center gap-3 select-none">
|
||||
<Step value="1" :pt="stepPassthrough">
|
||||
{{ $t('install.gpu') }}
|
||||
</Step>
|
||||
<Step value="2" :disabled="disableLocationStep" :pt="stepPassthrough">
|
||||
{{ $t('install.installLocation') }}
|
||||
</Step>
|
||||
<Step value="3" :disabled="disableSettingsStep" :pt="stepPassthrough">
|
||||
{{ $t('install.desktopSettings') }}
|
||||
</Step>
|
||||
</StepList>
|
||||
|
||||
<!-- Next/Install button -->
|
||||
<Button
|
||||
:label="currentStep !== '3' ? $t('g.next') : $t('g.install')"
|
||||
class="px-8 py-2 bg-brand-yellow hover:bg-brand-yellow/90 font-inter rounded-lg border-0 transition-colors justify-self-end"
|
||||
:pt="{
|
||||
label: { class: 'text-neutral-900 font-inter font-black' }
|
||||
}"
|
||||
:disabled="!canProceed"
|
||||
@click="currentStep !== '3' ? $emit('next') : $emit('install')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PassThrough } from '@primevue/core'
|
||||
import Button from 'primevue/button'
|
||||
import Step, { type StepPassThroughOptions } from 'primevue/step'
|
||||
import StepList from 'primevue/steplist'
|
||||
|
||||
defineProps<{
|
||||
/** Current step index as string ('1', '2', '3', '4') */
|
||||
currentStep: string
|
||||
/** Whether the user can proceed to the next step */
|
||||
canProceed: boolean
|
||||
/** Whether the location step should be disabled */
|
||||
disableLocationStep: boolean
|
||||
/** Whether the migration step should be disabled */
|
||||
disableMigrationStep: boolean
|
||||
/** Whether the settings step should be disabled */
|
||||
disableSettingsStep: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
previous: []
|
||||
next: []
|
||||
install: []
|
||||
}>()
|
||||
|
||||
const stepPassthrough: PassThrough<StepPassThroughOptions> = {
|
||||
root: { class: 'flex-none p-0 m-0' },
|
||||
header: ({ context }) => ({
|
||||
class: [
|
||||
'h-2.5 p-0 m-0 border-0 rounded-full transition-all duration-300',
|
||||
context.active
|
||||
? 'bg-brand-yellow w-8 rounded-sm'
|
||||
: 'bg-neutral-700 w-2.5',
|
||||
context.disabled ? 'opacity-60 cursor-not-allowed' : ''
|
||||
].join(' ')
|
||||
}),
|
||||
number: { class: 'hidden' },
|
||||
title: { class: 'hidden' }
|
||||
}
|
||||
</script>
|
||||
@@ -1,148 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import InstallLocationPicker from './InstallLocationPicker.vue'
|
||||
|
||||
const meta: Meta<typeof InstallLocationPicker> = {
|
||||
title: 'Desktop/Components/InstallLocationPicker',
|
||||
component: InstallLocationPicker,
|
||||
parameters: {
|
||||
layout: 'padded',
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' },
|
||||
{ name: 'neutral-950', value: '#0a0a0a' }
|
||||
]
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
// Mock electron API
|
||||
;(window as any).electronAPI = {
|
||||
getSystemPaths: () =>
|
||||
Promise.resolve({
|
||||
defaultInstallPath: '/Users/username/ComfyUI'
|
||||
}),
|
||||
validateInstallPath: () =>
|
||||
Promise.resolve({
|
||||
isValid: true,
|
||||
exists: false,
|
||||
canWrite: true,
|
||||
freeSpace: 100000000000,
|
||||
requiredSpace: 10000000000,
|
||||
isNonDefaultDrive: false
|
||||
}),
|
||||
validateComfyUISource: () =>
|
||||
Promise.resolve({
|
||||
isValid: true
|
||||
}),
|
||||
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
|
||||
}
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Default story with accordion expanded
|
||||
export const Default: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8">
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story with different background to test transparency
|
||||
export const OnNeutral900: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-900 p-8">
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
|
||||
// Story with debug overlay showing background colors
|
||||
export const DebugBackgrounds: Story = {
|
||||
render: (args) => ({
|
||||
components: { InstallLocationPicker },
|
||||
setup() {
|
||||
const installPath = ref('/Users/username/ComfyUI')
|
||||
const pathError = ref('')
|
||||
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
|
||||
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
|
||||
|
||||
return {
|
||||
args,
|
||||
installPath,
|
||||
pathError,
|
||||
migrationSourcePath,
|
||||
migrationItemIds
|
||||
}
|
||||
},
|
||||
template: `
|
||||
<div class="min-h-screen bg-neutral-950 p-8 relative">
|
||||
<div class="absolute top-4 right-4 text-white text-xs space-y-2 z-50">
|
||||
<div>Parent bg: neutral-950 (#0a0a0a)</div>
|
||||
<div>Accordion content: bg-transparent</div>
|
||||
<div>Migration options: bg-transparent + p-4 rounded-lg</div>
|
||||
</div>
|
||||
<InstallLocationPicker
|
||||
v-model:installPath="installPath"
|
||||
v-model:pathError="pathError"
|
||||
v-model:migrationSourcePath="migrationSourcePath"
|
||||
v-model:migrationItemIds="migrationItemIds"
|
||||
/>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
}
|
||||
@@ -1,215 +1,103 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
|
||||
<div class="flex flex-col gap-6 w-[600px]">
|
||||
<!-- Installation Path Section -->
|
||||
<div class="grow flex flex-col gap-6 text-neutral-300">
|
||||
<h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
|
||||
{{ $t('install.locationPicker.title') }}
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-2xl font-semibold text-neutral-100">
|
||||
{{ $t('install.chooseInstallationLocation') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-center text-neutral-400 px-12">
|
||||
{{ $t('install.locationPicker.subtitle') }}
|
||||
<p class="text-neutral-400 my-0">
|
||||
{{ $t('install.installLocationDescription') }}
|
||||
</p>
|
||||
|
||||
<!-- Path Input -->
|
||||
<div class="flex gap-2 px-12">
|
||||
<InputText
|
||||
v-model="installPath"
|
||||
:placeholder="$t('install.locationPicker.pathPlaceholder')"
|
||||
class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validatePath"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<Button
|
||||
icon="pi pi-folder-open"
|
||||
severity="secondary"
|
||||
class="bg-neutral-700 hover:bg-neutral-600 border-0"
|
||||
@click="browsePath"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<IconField class="flex-1">
|
||||
<InputText
|
||||
v-model="installPath"
|
||||
class="w-full"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validatePath"
|
||||
@focus="onFocus"
|
||||
/>
|
||||
<InputIcon
|
||||
v-tooltip.top="$t('install.installLocationTooltip')"
|
||||
class="pi pi-info-circle"
|
||||
/>
|
||||
</IconField>
|
||||
<Button icon="pi pi-folder" class="w-12" @click="browsePath" />
|
||||
</div>
|
||||
|
||||
<!-- Error Messages -->
|
||||
<div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
|
||||
<Message
|
||||
v-if="pathError"
|
||||
severity="error"
|
||||
class="whitespace-pre-line w-full"
|
||||
>
|
||||
{{ pathError }}
|
||||
</Message>
|
||||
<Message v-if="pathExists" severity="warn" class="w-full">
|
||||
{{ $t('install.pathExists') }}
|
||||
</Message>
|
||||
<Message v-if="nonDefaultDrive" severity="warn" class="w-full">
|
||||
{{ $t('install.nonDefaultDrive') }}
|
||||
</Message>
|
||||
<Message v-if="pathError" severity="error" class="whitespace-pre-line">
|
||||
{{ pathError }}
|
||||
</Message>
|
||||
<Message v-if="pathExists" severity="warn">
|
||||
{{ $t('install.pathExists') }}
|
||||
</Message>
|
||||
<Message v-if="nonDefaultDrive" severity="warn">
|
||||
{{ $t('install.nonDefaultDrive') }}
|
||||
</Message>
|
||||
</div>
|
||||
|
||||
<!-- System Paths Info -->
|
||||
<div class="bg-neutral-800 p-4 rounded-lg">
|
||||
<h3 class="text-lg font-medium mt-0 mb-3 text-neutral-100">
|
||||
{{ $t('install.systemLocations') }}
|
||||
</h3>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-folder text-neutral-400" />
|
||||
<span class="text-neutral-400">App Data:</span>
|
||||
<span class="text-neutral-200">{{ appData }}</span>
|
||||
<span
|
||||
v-tooltip="$t('install.appDataLocationTooltip')"
|
||||
class="pi pi-info-circle"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="pi pi-desktop text-neutral-400" />
|
||||
<span class="text-neutral-400">App Path:</span>
|
||||
<span class="text-neutral-200">{{ appPath }}</span>
|
||||
<span
|
||||
v-tooltip="$t('install.appPathLocationTooltip')"
|
||||
class="pi pi-info-circle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Collapsible Sections using PrimeVue Accordion -->
|
||||
<Accordion
|
||||
v-model:value="activeAccordionIndex"
|
||||
:multiple="true"
|
||||
class="location-picker-accordion"
|
||||
:pt="{
|
||||
root: 'bg-transparent border-0',
|
||||
panel: {
|
||||
root: 'border-0 mb-0'
|
||||
},
|
||||
header: {
|
||||
root: 'border-0',
|
||||
content:
|
||||
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
|
||||
toggleicon: 'text-xs order-first mr-0'
|
||||
},
|
||||
content: {
|
||||
root: 'bg-transparent border-0',
|
||||
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
|
||||
}
|
||||
}"
|
||||
>
|
||||
<AccordionPanel value="0">
|
||||
<AccordionHeader>
|
||||
{{ $t('install.locationPicker.migrateFromExisting') }}
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<MigrationPicker
|
||||
v-model:source-path="migrationSourcePath"
|
||||
v-model:migration-item-ids="migrationItemIds"
|
||||
/>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
|
||||
<AccordionPanel value="1">
|
||||
<AccordionHeader>
|
||||
{{ $t('install.locationPicker.chooseDownloadServers') }}
|
||||
</AccordionHeader>
|
||||
<AccordionContent>
|
||||
<template
|
||||
v-for="([item, modelValue], index) in mirrors"
|
||||
:key="item.settingId + item.mirror"
|
||||
>
|
||||
<Divider v-if="index > 0" class="my-8" />
|
||||
|
||||
<MirrorItem
|
||||
v-model="modelValue.value"
|
||||
:item="item"
|
||||
@state-change="validationStates[index] = $event"
|
||||
/>
|
||||
</template>
|
||||
</AccordionContent>
|
||||
</AccordionPanel>
|
||||
</Accordion>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import Accordion from 'primevue/accordion'
|
||||
import AccordionContent from 'primevue/accordioncontent'
|
||||
import AccordionHeader from 'primevue/accordionheader'
|
||||
import AccordionPanel from 'primevue/accordionpanel'
|
||||
import Button from 'primevue/button'
|
||||
import Divider from 'primevue/divider'
|
||||
import IconField from 'primevue/iconfield'
|
||||
import InputIcon from 'primevue/inputicon'
|
||||
import InputText from 'primevue/inputtext'
|
||||
import Message from 'primevue/message'
|
||||
import { type ModelRef, computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import MigrationPicker from '@/components/install/MigrationPicker.vue'
|
||||
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
|
||||
import {
|
||||
PYPI_MIRROR,
|
||||
PYTHON_MIRROR,
|
||||
type UVMirror
|
||||
} from '@/constants/uvMirrors'
|
||||
import { electronAPI } from '@/utils/envUtil'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const installPath = defineModel<string>('installPath', { required: true })
|
||||
const pathError = defineModel<string>('pathError', { required: true })
|
||||
const migrationSourcePath = defineModel<string>('migrationSourcePath')
|
||||
const migrationItemIds = defineModel<string[]>('migrationItemIds')
|
||||
const pythonMirror = defineModel<string>('pythonMirror', {
|
||||
default: ''
|
||||
})
|
||||
const pypiMirror = defineModel<string>('pypiMirror', {
|
||||
default: ''
|
||||
})
|
||||
const torchMirror = defineModel<string>('torchMirror', {
|
||||
default: ''
|
||||
})
|
||||
|
||||
const { device } = defineProps<{ device: TorchDeviceType | null }>()
|
||||
|
||||
const pathExists = ref(false)
|
||||
const nonDefaultDrive = ref(false)
|
||||
const appData = ref('')
|
||||
const appPath = ref('')
|
||||
const inputTouched = ref(false)
|
||||
|
||||
// Accordion state - array of active panel values
|
||||
const activeAccordionIndex = ref<string[] | undefined>(undefined)
|
||||
|
||||
const electron = electronAPI()
|
||||
|
||||
// Mirror configuration logic
|
||||
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
|
||||
switch (device) {
|
||||
case 'mps':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.NightlyCpu,
|
||||
fallbackMirror: TorchMirrorUrl.NightlyCpu
|
||||
}
|
||||
case 'nvidia':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.Cuda,
|
||||
fallbackMirror: TorchMirrorUrl.Cuda
|
||||
}
|
||||
case 'cpu':
|
||||
default:
|
||||
return {
|
||||
settingId,
|
||||
mirror: PYPI_MIRROR.mirror,
|
||||
fallbackMirror: PYPI_MIRROR.fallbackMirror
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userIsInChina = ref(false)
|
||||
const useFallbackMirror = (mirror: UVMirror) => ({
|
||||
...mirror,
|
||||
mirror: mirror.fallbackMirror
|
||||
})
|
||||
|
||||
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
|
||||
(
|
||||
[
|
||||
[PYTHON_MIRROR, pythonMirror],
|
||||
[PYPI_MIRROR, pypiMirror],
|
||||
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
|
||||
] as [UVMirror, ModelRef<string>][]
|
||||
).map(([item, modelValue]) => [
|
||||
userIsInChina.value ? useFallbackMirror(item) : item,
|
||||
modelValue
|
||||
])
|
||||
)
|
||||
|
||||
const validationStates = ref<ValidationState[]>(
|
||||
mirrors.value.map(() => ValidationState.IDLE)
|
||||
)
|
||||
|
||||
// Get default install path on component mount
|
||||
// Get system paths on component mount
|
||||
onMounted(async () => {
|
||||
const paths = await electron.getSystemPaths()
|
||||
appData.value = paths.appData
|
||||
appPath.value = paths.appPath
|
||||
installPath.value = paths.defaultInstallPath
|
||||
|
||||
await validatePath(paths.defaultInstallPath)
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
const validatePath = async (path: string | undefined) => {
|
||||
@@ -263,52 +151,3 @@ const onFocus = async () => {
|
||||
await validatePath(installPath.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
:deep(.location-picker-accordion) {
|
||||
@apply px-12;
|
||||
|
||||
.p-accordionpanel {
|
||||
@apply border-0 bg-transparent;
|
||||
}
|
||||
|
||||
.p-accordionheader {
|
||||
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
border-radius 0.5s ease;
|
||||
}
|
||||
|
||||
/* When panel is expanded, adjust header border radius */
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader {
|
||||
@apply rounded-t-xl rounded-b-none;
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordioncontent {
|
||||
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
|
||||
}
|
||||
|
||||
.p-accordioncontent-content {
|
||||
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
|
||||
}
|
||||
|
||||
/* Override default chevron icons to use up/down */
|
||||
.p-accordionheader-toggle-icon {
|
||||
&::before {
|
||||
content: '\e933';
|
||||
}
|
||||
}
|
||||
|
||||
.p-accordionpanel-active {
|
||||
.p-accordionheader-toggle-icon {
|
||||
&::before {
|
||||
content: '\e902';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// eslint-disable-next-line storybook/no-renderer-packages
|
||||
import type { Meta, StoryObj } from '@storybook/vue3'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import MigrationPicker from './MigrationPicker.vue'
|
||||
|
||||
const meta: Meta<typeof MigrationPicker> = {
|
||||
title: 'Desktop/Components/MigrationPicker',
|
||||
component: MigrationPicker,
|
||||
parameters: {
|
||||
backgrounds: {
|
||||
default: 'dark',
|
||||
values: [
|
||||
{ name: 'dark', value: '#0a0a0a' },
|
||||
{ name: 'neutral-900', value: '#171717' }
|
||||
]
|
||||
}
|
||||
},
|
||||
decorators: [
|
||||
() => {
|
||||
;(window as any).electronAPI = {
|
||||
validateComfyUISource: () => Promise.resolve({ isValid: true }),
|
||||
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
|
||||
}
|
||||
|
||||
return { template: '<story />' }
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const Default: Story = {
|
||||
render: () => ({
|
||||
components: { MigrationPicker },
|
||||
setup() {
|
||||
const sourcePath = ref('')
|
||||
const migrationItemIds = ref<string[]>([])
|
||||
return { sourcePath, migrationItemIds }
|
||||
},
|
||||
template:
|
||||
'<MigrationPicker v-model:sourcePath="sourcePath" v-model:migrationItemIds="migrationItemIds" />'
|
||||
})
|
||||
}
|
||||
@@ -2,6 +2,10 @@
|
||||
<div class="flex flex-col gap-6 w-[600px]">
|
||||
<!-- Source Location Section -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="text-2xl font-semibold text-neutral-100">
|
||||
{{ $t('install.migrateFromExistingInstallation') }}
|
||||
</h2>
|
||||
|
||||
<p class="text-neutral-400 my-0">
|
||||
{{ $t('install.migrationSourcePathDescription') }}
|
||||
</p>
|
||||
@@ -9,7 +13,7 @@
|
||||
<div class="flex gap-2">
|
||||
<InputText
|
||||
v-model="sourcePath"
|
||||
:placeholder="$t('install.locationPicker.migrationPathPlaceholder')"
|
||||
placeholder="Select existing ComfyUI installation (optional)"
|
||||
class="flex-1"
|
||||
:class="{ 'p-invalid': pathError }"
|
||||
@update:model-value="validateSource"
|
||||
@@ -23,7 +27,10 @@
|
||||
</div>
|
||||
|
||||
<!-- Migration Options -->
|
||||
<div v-if="isValidSource" class="flex flex-col gap-4 p-4 rounded-lg">
|
||||
<div
|
||||
v-if="isValidSource"
|
||||
class="flex flex-col gap-4 bg-neutral-800 p-4 rounded-lg"
|
||||
>
|
||||
<h3 class="text-lg mt-0 font-medium text-neutral-100">
|
||||
{{ $t('install.selectItemsToMigrate') }}
|
||||
</h3>
|
||||
|
||||
121
src/components/install/MirrorsConfiguration.vue
Normal file
@@ -0,0 +1,121 @@
|
||||
<template>
|
||||
<Panel
|
||||
:header="$t('install.settings.mirrorSettings')"
|
||||
toggleable
|
||||
:collapsed="!showMirrorInputs"
|
||||
pt:root="bg-neutral-800 border-none w-[600px]"
|
||||
>
|
||||
<template
|
||||
v-for="([item, modelValue], index) in mirrors"
|
||||
:key="item.settingId + item.mirror"
|
||||
>
|
||||
<Divider v-if="index > 0" />
|
||||
|
||||
<MirrorItem
|
||||
v-model="modelValue.value"
|
||||
:item="item"
|
||||
@state-change="validationStates[index] = $event"
|
||||
/>
|
||||
</template>
|
||||
<template #icons>
|
||||
<i
|
||||
v-tooltip="validationStateTooltip"
|
||||
:class="{
|
||||
'pi pi-spin pi-spinner text-neutral-400':
|
||||
validationState === ValidationState.LOADING,
|
||||
'pi pi-check text-green-500':
|
||||
validationState === ValidationState.VALID,
|
||||
'pi pi-times text-red-500':
|
||||
validationState === ValidationState.INVALID
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
|
||||
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
|
||||
import Divider from 'primevue/divider'
|
||||
import Panel from 'primevue/panel'
|
||||
import type { ModelRef } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
|
||||
import type { UVMirror } from '@/constants/uvMirrors'
|
||||
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
|
||||
import { t } from '@/i18n'
|
||||
import { isInChina } from '@/utils/networkUtil'
|
||||
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
|
||||
|
||||
const showMirrorInputs = ref(false)
|
||||
const { device } = defineProps<{ device: TorchDeviceType | null }>()
|
||||
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
|
||||
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
|
||||
const torchMirror = defineModel<string>('torchMirror', { required: true })
|
||||
|
||||
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
|
||||
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
|
||||
switch (device) {
|
||||
case 'mps':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.NightlyCpu,
|
||||
fallbackMirror: TorchMirrorUrl.NightlyCpu
|
||||
}
|
||||
case 'nvidia':
|
||||
return {
|
||||
settingId,
|
||||
mirror: TorchMirrorUrl.Cuda,
|
||||
fallbackMirror: TorchMirrorUrl.Cuda
|
||||
}
|
||||
case 'cpu':
|
||||
default:
|
||||
return {
|
||||
settingId,
|
||||
mirror: PYPI_MIRROR.mirror,
|
||||
fallbackMirror: PYPI_MIRROR.fallbackMirror
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const userIsInChina = ref(false)
|
||||
onMounted(async () => {
|
||||
userIsInChina.value = await isInChina()
|
||||
})
|
||||
|
||||
const useFallbackMirror = (mirror: UVMirror) => ({
|
||||
...mirror,
|
||||
mirror: mirror.fallbackMirror
|
||||
})
|
||||
|
||||
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
|
||||
(
|
||||
[
|
||||
[PYTHON_MIRROR, pythonMirror],
|
||||
[PYPI_MIRROR, pypiMirror],
|
||||
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
|
||||
] as [UVMirror, ModelRef<string>][]
|
||||
).map(([item, modelValue]) => [
|
||||
userIsInChina.value ? useFallbackMirror(item) : item,
|
||||
modelValue
|
||||
])
|
||||
)
|
||||
|
||||
const validationStates = ref<ValidationState[]>(
|
||||
mirrors.value.map(() => ValidationState.IDLE)
|
||||
)
|
||||
const validationState = computed(() => {
|
||||
return mergeValidationStates(validationStates.value)
|
||||
})
|
||||
const validationStateTooltip = computed(() => {
|
||||
switch (validationState.value) {
|
||||
case ValidationState.INVALID:
|
||||
return t('install.settings.mirrorsUnreachable')
|
||||
case ValidationState.VALID:
|
||||
return t('install.settings.mirrorsReachable')
|
||||
default:
|
||||
return t('install.settings.checkingMirrors')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -1,10 +1,10 @@
|
||||
<template>
|
||||
<div class="flex flex-col gap-4 text-neutral-400 text-sm">
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-neutral-100 mb-3 mt-0">
|
||||
<div class="flex flex-col items-center gap-4">
|
||||
<div class="w-full">
|
||||
<h3 class="text-lg font-medium text-neutral-100">
|
||||
{{ $t(`settings.${normalizedSettingId}.name`) }}
|
||||
</h3>
|
||||
<p class="my-1">
|
||||
<p class="text-sm text-neutral-400 mt-1">
|
||||
{{ $t(`settings.${normalizedSettingId}.tooltip`) }}
|
||||
</p>
|
||||
</div>
|
||||
@@ -16,61 +16,18 @@
|
||||
"
|
||||
@state-change="validationState = $event"
|
||||
/>
|
||||
<div v-if="secondParagraph" class="mt-2">
|
||||
<a href="#" @click.prevent="showDialog = true">
|
||||
{{ $t('g.learnMore') }}
|
||||
</a>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="showDialog"
|
||||
modal
|
||||
dismissable-mask
|
||||
:header="$t(`settings.${normalizedSettingId}.urlFormatTitle`)"
|
||||
class="select-none max-w-3xl"
|
||||
>
|
||||
<div class="text-neutral-300">
|
||||
<p class="mt-1 whitespace-pre-wrap">{{ secondParagraph }}</p>
|
||||
<div class="mt-2 break-all">
|
||||
<span class="text-neutral-300 font-semibold">
|
||||
{{ EXAMPLE_URL_FIRST_PART }}
|
||||
</span>
|
||||
<span>{{ EXAMPLE_URL_SECOND_PART }}</span>
|
||||
</div>
|
||||
<Divider />
|
||||
<p>
|
||||
{{ $t(`settings.${normalizedSettingId}.fileUrlDescription`) }}
|
||||
</p>
|
||||
<span class="text-neutral-300 font-semibold">
|
||||
{{ FILE_URL_SCHEME }}
|
||||
</span>
|
||||
<span>
|
||||
{{ EXAMPLE_FILE_URL }}
|
||||
</span>
|
||||
</div>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Dialog from 'primevue/dialog'
|
||||
import Divider from 'primevue/divider'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import UrlInput from '@/components/common/UrlInput.vue'
|
||||
import type { UVMirror } from '@/constants/uvMirrors'
|
||||
import { st } from '@/i18n'
|
||||
import { normalizeI18nKey } from '@/utils/formatUtil'
|
||||
import { checkMirrorReachable } from '@/utils/networkUtil'
|
||||
import { ValidationState } from '@/utils/validationUtil'
|
||||
|
||||
const FILE_URL_SCHEME = 'file://'
|
||||
const EXAMPLE_FILE_URL = '/C:/MyPythonInstallers/'
|
||||
const EXAMPLE_URL_FIRST_PART =
|
||||
'https://github.com/astral-sh/python-build-standalone/releases/download'
|
||||
const EXAMPLE_URL_SECOND_PART =
|
||||
'/20250902/cpython-3.12.11+20250902-x86_64-pc-windows-msvc-install_only.tar.gz'
|
||||
|
||||
const { item } = defineProps<{
|
||||
item: UVMirror
|
||||
}>()
|
||||
@@ -81,16 +38,11 @@ const emit = defineEmits<{
|
||||
|
||||
const modelValue = defineModel<string>('modelValue', { required: true })
|
||||
const validationState = ref<ValidationState>(ValidationState.IDLE)
|
||||
const showDialog = ref(false)
|
||||
|
||||
const normalizedSettingId = computed(() => {
|
||||
return normalizeI18nKey(item.settingId)
|
||||
})
|
||||
|
||||
const secondParagraph = computed(() =>
|
||||
st(`settings.${normalizedSettingId.value}.urlDescription`, '')
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
modelValue.value = item.mirror
|
||||
})
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- Reference:
|
||||
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
|
||||
-->
|
||||
|
||||
<template>
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<div v-else class="_sb_node_preview">
|
||||
<div class="_sb_node_preview">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header text-ellipsis mr-4"
|
||||
@@ -85,8 +85,6 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
@@ -96,8 +94,6 @@ const { nodeDef } = defineProps<{
|
||||
nodeDef: ComfyNodeDefV2
|
||||
}>()
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const litegraphColors = computed(
|
||||
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
|
||||
@@ -64,29 +64,31 @@ const litegraphService = useLitegraphService()
|
||||
|
||||
const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore)
|
||||
const dismissable = ref(true)
|
||||
function getNewNodeLocation(): Point {
|
||||
const getNewNodeLocation = (): Point => {
|
||||
return triggerEvent
|
||||
? [triggerEvent.canvasX, triggerEvent.canvasY]
|
||||
: litegraphService.getCanvasCenter()
|
||||
}
|
||||
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
|
||||
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
const addFilter = (filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) => {
|
||||
nodeFilters.value.push(filter)
|
||||
}
|
||||
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
const removeFilter = (
|
||||
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
|
||||
) => {
|
||||
nodeFilters.value = nodeFilters.value.filter(
|
||||
(f) => toRaw(f) !== toRaw(filter)
|
||||
)
|
||||
}
|
||||
function clearFilters() {
|
||||
const clearFilters = () => {
|
||||
nodeFilters.value = []
|
||||
}
|
||||
function closeDialog() {
|
||||
const closeDialog = () => {
|
||||
visible.value = false
|
||||
}
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl) {
|
||||
const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
})
|
||||
@@ -104,7 +106,7 @@ function addNode(nodeDef: ComfyNodeDefImpl) {
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
function showSearchBox(e: CanvasPointerEvent | null) {
|
||||
const showSearchBox = (e: CanvasPointerEvent | null) => {
|
||||
if (newSearchBoxEnabled.value) {
|
||||
if (e?.pointerType === 'touch') {
|
||||
setTimeout(() => {
|
||||
@@ -118,12 +120,11 @@ function showSearchBox(e: CanvasPointerEvent | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstLink() {
|
||||
return canvasStore.getCanvas().linkConnector.renderLinks.at(0)
|
||||
}
|
||||
const getFirstLink = () =>
|
||||
canvasStore.getCanvas().linkConnector.renderLinks.at(0)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
function showNewSearchBox(e: CanvasPointerEvent | null) {
|
||||
const showNewSearchBox = (e: CanvasPointerEvent | null) => {
|
||||
const firstLink = getFirstLink()
|
||||
if (firstLink) {
|
||||
const filter =
|
||||
@@ -148,7 +149,7 @@ function showNewSearchBox(e: CanvasPointerEvent | null) {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function showContextMenu(e: CanvasPointerEvent) {
|
||||
const showContextMenu = (e: CanvasPointerEvent) => {
|
||||
const firstLink = getFirstLink()
|
||||
if (!firstLink) return
|
||||
|
||||
@@ -225,7 +226,7 @@ watchEffect(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function canvasEventHandler(e: LiteGraphCanvasEvent) {
|
||||
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
|
||||
if (e.detail.subType === 'empty-double-click') {
|
||||
showSearchBox(e.detail.originalEvent)
|
||||
} else if (e.detail.subType === 'group-double-click') {
|
||||
@@ -248,10 +249,8 @@ const linkReleaseActionShift = computed(() =>
|
||||
)
|
||||
|
||||
// Prevent normal LinkConnector reset (called by CanvasPointer.finally)
|
||||
function preventDefault(e: Event) {
|
||||
return e.preventDefault()
|
||||
}
|
||||
function cancelNextReset(e: CustomEvent<CanvasPointerEvent>) {
|
||||
const preventDefault = (e: Event) => e.preventDefault()
|
||||
const cancelNextReset = (e: CustomEvent<CanvasPointerEvent>) => {
|
||||
e.preventDefault()
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
@@ -261,7 +260,7 @@ function cancelNextReset(e: CustomEvent<CanvasPointerEvent>) {
|
||||
})
|
||||
}
|
||||
|
||||
function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
|
||||
const handleDroppedOnCanvas = (e: CustomEvent<CanvasPointerEvent>) => {
|
||||
disconnectOnReset = true
|
||||
const action = e.detail.shiftKey
|
||||
? linkReleaseActionShift.value
|
||||
@@ -282,7 +281,7 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
|
||||
}
|
||||
|
||||
// Resets litegraph state
|
||||
function reset() {
|
||||
const reset = () => {
|
||||
listenerController?.abort()
|
||||
listenerController = null
|
||||
triggerEvent = null
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
ref="workflowTabRef"
|
||||
class="flex p-2 gap-2 workflow-tab group"
|
||||
class="flex p-2 gap-2 workflow-tab"
|
||||
v-bind="$attrs"
|
||||
@mouseenter="handleMouseEnter"
|
||||
@mouseleave="handleMouseLeave"
|
||||
@@ -11,13 +11,9 @@
|
||||
{{ workflowOption.workflow.filename }}
|
||||
</span>
|
||||
<div class="relative">
|
||||
<span
|
||||
v-if="shouldShowStatusIndicator"
|
||||
class="group-hover:hidden absolute font-bold text-2xl top-1/2 left-1/2 -translate-1/2 z-10 bg-(--comfy-menu-secondary-bg) w-4"
|
||||
>•</span
|
||||
>
|
||||
<span v-if="shouldShowStatusIndicator" class="status-indicator">•</span>
|
||||
<Button
|
||||
class="close-button p-0 w-auto invisible"
|
||||
class="close-button p-0 w-auto"
|
||||
icon="pi pi-times"
|
||||
text
|
||||
severity="secondary"
|
||||
@@ -178,6 +174,18 @@ onUnmounted(() => {
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@reference '../../assets/css/style.css';
|
||||
|
||||
.status-indicator {
|
||||
@apply absolute font-bold;
|
||||
font-size: 1.5rem;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.p-tooltip.workflow-tab-tooltip {
|
||||
z-index: 1200 !important;
|
||||
|
||||
@@ -358,6 +358,14 @@ onUpdated(() => {
|
||||
@apply visible;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton:hover) .status-indicator {
|
||||
@apply hidden;
|
||||
}
|
||||
|
||||
:deep(.p-togglebutton) .close-button {
|
||||
@apply invisible;
|
||||
}
|
||||
|
||||
:deep(.p-scrollpanel-content) {
|
||||
@apply h-full;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { whenever } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions'
|
||||
import { t } from '@/i18n'
|
||||
@@ -34,8 +33,19 @@ export const useCurrentUser = () => {
|
||||
return null
|
||||
})
|
||||
|
||||
const onUserResolved = (callback: (user: AuthUserInfo) => void) =>
|
||||
whenever(resolvedUserInfo, callback, { immediate: true })
|
||||
const onUserResolved = (callback: (user: AuthUserInfo) => void) => {
|
||||
if (resolvedUserInfo.value) {
|
||||
callback(resolvedUserInfo.value)
|
||||
}
|
||||
|
||||
const stop = watch(resolvedUserInfo, (value) => {
|
||||
if (value) {
|
||||
callback(value)
|
||||
}
|
||||
})
|
||||
|
||||
return () => stop()
|
||||
}
|
||||
|
||||
const userDisplayName = computed(() => {
|
||||
if (isApiKeyLogin.value) {
|
||||
|
||||
@@ -9,8 +9,7 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
|
||||
const fitAddon = new FitAddon()
|
||||
const terminal = markRaw(
|
||||
new Terminal({
|
||||
convertEol: true,
|
||||
theme: { background: '#171717' }
|
||||
convertEol: true
|
||||
})
|
||||
)
|
||||
terminal.loadAddon(fitAddon)
|
||||
|
||||
136
src/composables/canvas/useCanvasTransformSync.ts
Normal file
@@ -0,0 +1,136 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
interface CanvasTransformSyncOptions {
|
||||
/**
|
||||
* Whether to automatically start syncing when canvas is available
|
||||
* @default true
|
||||
*/
|
||||
autoStart?: boolean
|
||||
/**
|
||||
* Called when sync starts
|
||||
*/
|
||||
onStart?: () => void
|
||||
/**
|
||||
* Called when sync stops
|
||||
*/
|
||||
onStop?: () => void
|
||||
}
|
||||
|
||||
interface CanvasTransform {
|
||||
scale: number
|
||||
offsetX: number
|
||||
offsetY: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
|
||||
*
|
||||
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
|
||||
* on every frame. It handles RAF lifecycle management, and ensures proper cleanup.
|
||||
*
|
||||
* The sync function typically reads canvas.ds properties like offset and scale to keep
|
||||
* Vue components aligned with the canvas coordinate system.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const syncWithCanvas = (canvas: LGraphCanvas) => {
|
||||
* canvas.ds.scale
|
||||
* canvas.ds.offset
|
||||
* }
|
||||
*
|
||||
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
|
||||
* syncWithCanvas,
|
||||
* {
|
||||
* autoStart: false,
|
||||
* onStart: () => emit('rafStatusChange', true),
|
||||
* onStop: () => emit('rafStatusChange', false)
|
||||
* }
|
||||
* )
|
||||
* ```
|
||||
*/
|
||||
export function useCanvasTransformSync(
|
||||
syncFn: (canvas: LGraphCanvas) => void,
|
||||
options: CanvasTransformSyncOptions = {}
|
||||
) {
|
||||
const { onStart, onStop, autoStart = true } = options
|
||||
const { getCanvas } = useCanvasStore()
|
||||
|
||||
const isActive = ref(false)
|
||||
let rafId: number | null = null
|
||||
let lastTransform: CanvasTransform = {
|
||||
scale: 0,
|
||||
offsetX: 0,
|
||||
offsetY: 0
|
||||
}
|
||||
|
||||
const hasTransformChanged = (canvas: LGraphCanvas): boolean => {
|
||||
const ds = canvas.ds
|
||||
return (
|
||||
ds.scale !== lastTransform.scale ||
|
||||
ds.offset[0] !== lastTransform.offsetX ||
|
||||
ds.offset[1] !== lastTransform.offsetY
|
||||
)
|
||||
}
|
||||
|
||||
const sync = () => {
|
||||
if (!isActive.value) return
|
||||
|
||||
const canvas = getCanvas()
|
||||
if (!canvas) return
|
||||
|
||||
try {
|
||||
// Only run sync if transform actually changed
|
||||
if (hasTransformChanged(canvas)) {
|
||||
lastTransform = {
|
||||
scale: canvas.ds.scale,
|
||||
offsetX: canvas.ds.offset[0],
|
||||
offsetY: canvas.ds.offset[1]
|
||||
}
|
||||
|
||||
syncFn(canvas)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Canvas transform sync error:', error)
|
||||
}
|
||||
|
||||
rafId = requestAnimationFrame(sync)
|
||||
}
|
||||
|
||||
const startSync = () => {
|
||||
if (isActive.value) return
|
||||
|
||||
isActive.value = true
|
||||
onStart?.()
|
||||
|
||||
// Reset last transform to force initial sync
|
||||
lastTransform = { scale: 0, offsetX: 0, offsetY: 0 }
|
||||
|
||||
sync()
|
||||
}
|
||||
|
||||
const stopSync = () => {
|
||||
isActive.value = false
|
||||
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
rafId = null
|
||||
}
|
||||
|
||||
onStop?.()
|
||||
}
|
||||
|
||||
onUnmounted(stopSync)
|
||||
|
||||
if (autoStart) {
|
||||
startSync()
|
||||
}
|
||||
|
||||
return {
|
||||
isActive,
|
||||
startSync,
|
||||
stopSync
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useRafFn } from '@vueuse/core'
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
|
||||
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
|
||||
import type { Rect } from '@/lib/litegraph/src/interfaces'
|
||||
import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
|
||||
@@ -71,7 +71,7 @@ export function useSelectionToolboxPosition(
|
||||
visible.value = true
|
||||
|
||||
// Get bounds for all selected items
|
||||
const allBounds: ReadOnlyRect[] = []
|
||||
const allBounds: Rect[] = []
|
||||
for (const item of selectableItems) {
|
||||
// Skip items without valid IDs
|
||||
if (item.id == null) continue
|
||||
@@ -128,7 +128,9 @@ export function useSelectionToolboxPosition(
|
||||
}
|
||||
|
||||
// Sync with canvas transform
|
||||
const { resume: startSync, pause: stopSync } = useRafFn(updateTransform)
|
||||
const { startSync, stopSync } = useCanvasTransformSync(updateTransform, {
|
||||
autoStart: false
|
||||
})
|
||||
|
||||
// Watch for selection changes
|
||||
watch(
|
||||
|
||||
@@ -2,17 +2,42 @@
|
||||
* Vue node lifecycle management for LiteGraph integration
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type { INodeOutputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import type { INodeInputSlot } from '@/lib/litegraph/src/interfaces'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
|
||||
|
||||
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
|
||||
export interface NodeState {
|
||||
visible: boolean
|
||||
dirty: boolean
|
||||
lastUpdate: number
|
||||
culled: boolean
|
||||
}
|
||||
|
||||
interface NodeMetadata {
|
||||
lastRenderTime: number
|
||||
cachedBounds: DOMRect | null
|
||||
lodLevel: 'high' | 'medium' | 'low'
|
||||
spatialIndex?: QuadTree<string>
|
||||
}
|
||||
|
||||
interface PerformanceMetrics {
|
||||
fps: number
|
||||
frameTime: number
|
||||
updateTime: number
|
||||
nodeCount: number
|
||||
culledCount: number
|
||||
callbackUpdateCount: number
|
||||
rafUpdateCount: number
|
||||
adaptiveQuality: boolean
|
||||
}
|
||||
|
||||
export interface SafeWidgetData {
|
||||
name: string
|
||||
type: string
|
||||
@@ -30,34 +55,117 @@ export interface VueNodeData {
|
||||
executing: boolean
|
||||
subgraphId?: string | null
|
||||
widgets?: SafeWidgetData[]
|
||||
inputs?: INodeInputSlot[]
|
||||
outputs?: INodeOutputSlot[]
|
||||
inputs?: unknown[]
|
||||
outputs?: unknown[]
|
||||
hasErrors?: boolean
|
||||
flags?: {
|
||||
collapsed?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
interface SpatialMetrics {
|
||||
queryTime: number
|
||||
nodesInIndex: number
|
||||
}
|
||||
|
||||
interface GraphNodeManager {
|
||||
// Reactive state - safe data extracted from LiteGraph nodes
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
nodeState: ReadonlyMap<string, NodeState>
|
||||
nodePositions: ReadonlyMap<string, { x: number; y: number }>
|
||||
nodeSizes: ReadonlyMap<string, { width: number; height: number }>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: string): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
setupEventListeners(): () => void
|
||||
cleanup(): void
|
||||
|
||||
// Update methods
|
||||
scheduleUpdate(
|
||||
nodeId?: string,
|
||||
priority?: 'critical' | 'normal' | 'low'
|
||||
): void
|
||||
forceSync(): void
|
||||
detectChangesInRAF(): void
|
||||
|
||||
// Spatial queries
|
||||
getVisibleNodeIds(viewportBounds: Bounds): Set<string>
|
||||
|
||||
// Performance
|
||||
performanceMetrics: PerformanceMetrics
|
||||
spatialMetrics: SpatialMetrics
|
||||
|
||||
// Debug
|
||||
getSpatialIndexDebugInfo(): SpatialIndexDebugInfo | null
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
const { moveNode, resizeNode, createNode, deleteNode, setSource } =
|
||||
useLayoutMutations()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
const nodeState = reactive(new Map<string, NodeState>())
|
||||
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
|
||||
const nodeSizes = reactive(
|
||||
new Map<string, { width: number; height: number }>()
|
||||
)
|
||||
|
||||
// Non-reactive storage for original LiteGraph nodes
|
||||
const nodeRefs = new Map<string, LGraphNode>()
|
||||
|
||||
// WeakMap for heavy data that auto-GCs when nodes are removed
|
||||
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
|
||||
|
||||
// Performance tracking
|
||||
const performanceMetrics = reactive<PerformanceMetrics>({
|
||||
fps: 0,
|
||||
frameTime: 0,
|
||||
updateTime: 0,
|
||||
nodeCount: 0,
|
||||
culledCount: 0,
|
||||
callbackUpdateCount: 0,
|
||||
rafUpdateCount: 0,
|
||||
adaptiveQuality: false
|
||||
})
|
||||
|
||||
// Spatial indexing using QuadTree
|
||||
const spatialIndex = new QuadTree<string>(
|
||||
{ x: -10000, y: -10000, width: 20000, height: 20000 },
|
||||
{ maxDepth: 6, maxItemsPerNode: 4 }
|
||||
)
|
||||
let lastSpatialQueryTime = 0
|
||||
|
||||
// Spatial metrics
|
||||
const spatialMetrics = reactive<SpatialMetrics>({
|
||||
queryTime: 0,
|
||||
nodesInIndex: 0
|
||||
})
|
||||
|
||||
// Update batching
|
||||
const pendingUpdates = new Set<string>()
|
||||
const criticalUpdates = new Set<string>()
|
||||
const lowPriorityUpdates = new Set<string>()
|
||||
let updateScheduled = false
|
||||
let batchTimeoutId: number | null = null
|
||||
|
||||
// Change detection state
|
||||
const lastNodesSnapshot = new Map<
|
||||
string,
|
||||
{ pos: [number, number]; size: [number, number] }
|
||||
>()
|
||||
|
||||
const attachMetadata = (node: LGraphNode) => {
|
||||
nodeMetadata.set(node, {
|
||||
lastRenderTime: performance.now(),
|
||||
cachedBounds: null,
|
||||
lodLevel: 'high',
|
||||
spatialIndex: undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
@@ -178,6 +286,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
...currentData,
|
||||
widgets: updatedWidgets
|
||||
})
|
||||
performanceMetrics.callbackUpdateCount++
|
||||
} catch (error) {
|
||||
// Ignore widget update errors to prevent cascade failures
|
||||
}
|
||||
@@ -247,6 +356,71 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
}
|
||||
|
||||
// Uncomment when needed for future features
|
||||
// const getNodeMetadata = (node: LGraphNode): NodeMetadata => {
|
||||
// let metadata = nodeMetadata.get(node)
|
||||
// if (!metadata) {
|
||||
// attachMetadata(node)
|
||||
// metadata = nodeMetadata.get(node)!
|
||||
// }
|
||||
// return metadata
|
||||
// }
|
||||
|
||||
const scheduleUpdate = (
|
||||
nodeId?: string,
|
||||
priority: 'critical' | 'normal' | 'low' = 'normal'
|
||||
) => {
|
||||
if (nodeId) {
|
||||
const state = nodeState.get(nodeId)
|
||||
if (state) state.dirty = true
|
||||
|
||||
// Priority queuing
|
||||
if (priority === 'critical') {
|
||||
criticalUpdates.add(nodeId)
|
||||
flush() // Immediate flush for critical updates
|
||||
return
|
||||
} else if (priority === 'low') {
|
||||
lowPriorityUpdates.add(nodeId)
|
||||
} else {
|
||||
pendingUpdates.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateScheduled) {
|
||||
updateScheduled = true
|
||||
|
||||
// Adaptive batching strategy
|
||||
if (pendingUpdates.size > 10) {
|
||||
// Many updates - batch in nextTick
|
||||
void nextTick(() => flush())
|
||||
} else {
|
||||
// Few updates - small delay for more batching
|
||||
batchTimeoutId = window.setTimeout(() => flush(), 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
if (batchTimeoutId !== null) {
|
||||
clearTimeout(batchTimeoutId)
|
||||
batchTimeoutId = null
|
||||
}
|
||||
|
||||
// Clear all pending updates
|
||||
criticalUpdates.clear()
|
||||
pendingUpdates.clear()
|
||||
lowPriorityUpdates.clear()
|
||||
updateScheduled = false
|
||||
|
||||
// Sync with graph state
|
||||
syncWithGraph()
|
||||
|
||||
const endTime = performance.now()
|
||||
performanceMetrics.updateTime = endTime - startTime
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
@@ -257,6 +431,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (!currentNodes.has(id)) {
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
nodeState.delete(id)
|
||||
nodePositions.delete(id)
|
||||
nodeSizes.delete(id)
|
||||
lastNodesSnapshot.delete(id)
|
||||
spatialIndex.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,7 +451,163 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
if (!nodeState.has(id)) {
|
||||
nodeState.set(id, {
|
||||
visible: true,
|
||||
dirty: false,
|
||||
lastUpdate: performance.now(),
|
||||
culled: false
|
||||
})
|
||||
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||
attachMetadata(node)
|
||||
|
||||
// Add to spatial index
|
||||
const bounds: Bounds = {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
spatialIndex.insert(id, bounds, id)
|
||||
}
|
||||
})
|
||||
|
||||
// Update performance metrics
|
||||
performanceMetrics.nodeCount = vueNodeData.size
|
||||
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
|
||||
(s) => s.culled
|
||||
).length
|
||||
}
|
||||
|
||||
// Most performant: Direct position sync without re-setting entire node
|
||||
// Query visible nodes using QuadTree spatial index
|
||||
const getVisibleNodeIds = (viewportBounds: Bounds): Set<string> => {
|
||||
const startTime = performance.now()
|
||||
|
||||
// Use QuadTree for fast spatial query
|
||||
const results: string[] = spatialIndex.query(viewportBounds)
|
||||
const visibleIds = new Set(results)
|
||||
|
||||
lastSpatialQueryTime = performance.now() - startTime
|
||||
spatialMetrics.queryTime = lastSpatialQueryTime
|
||||
|
||||
return visibleIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects position changes for a single node and updates reactive state
|
||||
*/
|
||||
const detectPositionChanges = (node: LGraphNode, id: string): boolean => {
|
||||
const currentPos = nodePositions.get(id)
|
||||
|
||||
if (
|
||||
!currentPos ||
|
||||
currentPos.x !== node.pos[0] ||
|
||||
currentPos.y !== node.pos[1]
|
||||
) {
|
||||
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||
|
||||
// Push position change to layout store
|
||||
// Source is already set to 'canvas' in detectChangesInRAF
|
||||
void moveNode(id, { x: node.pos[0], y: node.pos[1] })
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects size changes for a single node and updates reactive state
|
||||
*/
|
||||
const detectSizeChanges = (node: LGraphNode, id: string): boolean => {
|
||||
const currentSize = nodeSizes.get(id)
|
||||
|
||||
if (
|
||||
!currentSize ||
|
||||
currentSize.width !== node.size[0] ||
|
||||
currentSize.height !== node.size[1]
|
||||
) {
|
||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||
|
||||
// Push size change to layout store
|
||||
// Source is already set to 'canvas' in detectChangesInRAF
|
||||
void resizeNode(id, {
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates spatial index for a node if bounds changed
|
||||
*/
|
||||
const updateSpatialIndex = (node: LGraphNode, id: string): void => {
|
||||
const bounds: Bounds = {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
spatialIndex.update(id, bounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates performance metrics after change detection
|
||||
*/
|
||||
const updatePerformanceMetrics = (
|
||||
startTime: number,
|
||||
positionUpdates: number,
|
||||
sizeUpdates: number
|
||||
): void => {
|
||||
const endTime = performance.now()
|
||||
performanceMetrics.updateTime = endTime - startTime
|
||||
performanceMetrics.nodeCount = vueNodeData.size
|
||||
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
|
||||
(state) => state.culled
|
||||
).length
|
||||
spatialMetrics.nodesInIndex = spatialIndex.size
|
||||
|
||||
if (positionUpdates > 0 || sizeUpdates > 0) {
|
||||
performanceMetrics.rafUpdateCount++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main RAF change detection function
|
||||
*/
|
||||
const detectChangesInRAF = () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
if (!graph?._nodes) return
|
||||
|
||||
let positionUpdates = 0
|
||||
let sizeUpdates = 0
|
||||
|
||||
// Set source for all canvas-driven updates
|
||||
setSource(LayoutSource.Canvas)
|
||||
|
||||
// Process each node for changes
|
||||
for (const node of graph._nodes) {
|
||||
const id = String(node.id)
|
||||
|
||||
const posChanged = detectPositionChanges(node, id)
|
||||
const sizeChanged = detectSizeChanges(node, id)
|
||||
|
||||
if (posChanged) positionUpdates++
|
||||
if (sizeChanged) sizeUpdates++
|
||||
|
||||
// Update spatial index if geometry changed
|
||||
if (posChanged || sizeChanged) {
|
||||
updateSpatialIndex(node, id)
|
||||
}
|
||||
}
|
||||
|
||||
updatePerformanceMetrics(startTime, positionUpdates, sizeUpdates)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,11 +629,32 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
// Set up reactive tracking state
|
||||
nodeState.set(id, {
|
||||
visible: true,
|
||||
dirty: false,
|
||||
lastUpdate: performance.now(),
|
||||
culled: false
|
||||
})
|
||||
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
|
||||
nodePositions.set(id, nodePosition)
|
||||
nodeSizes.set(id, nodeSize)
|
||||
attachMetadata(node)
|
||||
|
||||
// Add to spatial index for viewport culling with final positions
|
||||
const nodeBounds: Bounds = {
|
||||
x: nodePosition.x,
|
||||
y: nodePosition.y,
|
||||
width: nodeSize.width,
|
||||
height: nodeSize.height
|
||||
}
|
||||
spatialIndex.insert(id, nodeBounds, id)
|
||||
|
||||
// Add node to layout store with final positions
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
@@ -342,6 +698,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Remove from spatial index
|
||||
spatialIndex.remove(id)
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
@@ -349,6 +708,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
nodeState.delete(id)
|
||||
nodePositions.delete(id)
|
||||
nodeSizes.delete(id)
|
||||
lastNodesSnapshot.delete(id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
@@ -370,9 +733,23 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
|
||||
// Clear pending updates
|
||||
if (batchTimeoutId !== null) {
|
||||
clearTimeout(batchTimeoutId)
|
||||
batchTimeoutId = null
|
||||
}
|
||||
|
||||
// Clear all state maps
|
||||
nodeRefs.clear()
|
||||
vueNodeData.clear()
|
||||
nodeState.clear()
|
||||
nodePositions.clear()
|
||||
nodeSizes.clear()
|
||||
lastNodesSnapshot.clear()
|
||||
pendingUpdates.clear()
|
||||
criticalUpdates.clear()
|
||||
lowPriorityUpdates.clear()
|
||||
spatialIndex.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,28 +812,6 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
action === 'node:slot-errors:changed' &&
|
||||
param &&
|
||||
typeof param === 'object'
|
||||
) {
|
||||
const event = param as { nodeId: string | number }
|
||||
const nodeId = String(event.nodeId)
|
||||
const litegraphNode = nodeRefs.get(nodeId)
|
||||
const currentData = vueNodeData.get(nodeId)
|
||||
|
||||
if (litegraphNode && currentData) {
|
||||
// Re-extract slot data with updated hasErrors properties
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
inputs: litegraphNode.inputs
|
||||
? [...litegraphNode.inputs]
|
||||
: undefined,
|
||||
outputs: litegraphNode.outputs
|
||||
? [...litegraphNode.outputs]
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Call original trigger handler if it exists
|
||||
@@ -490,7 +845,18 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
return {
|
||||
vueNodeData,
|
||||
nodeState,
|
||||
nodePositions,
|
||||
nodeSizes,
|
||||
getNode,
|
||||
cleanup
|
||||
setupEventListeners,
|
||||
cleanup,
|
||||
scheduleUpdate,
|
||||
forceSync: syncWithGraph,
|
||||
detectChangesInRAF,
|
||||
getVisibleNodeIds,
|
||||
performanceMetrics,
|
||||
spatialMetrics,
|
||||
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,27 @@
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
*/
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
import { type Ref, computed } from 'vue'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
export function useViewportCulling() {
|
||||
interface NodeManager {
|
||||
getNode: (id: string) => any
|
||||
}
|
||||
|
||||
export function useViewportCulling(
|
||||
isVueNodesEnabled: Ref<boolean>,
|
||||
vueNodeData: Ref<ReadonlyMap<string, VueNodeData>>,
|
||||
nodeDataTrigger: Ref<number>,
|
||||
nodeManager: Ref<NodeManager | null>
|
||||
) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { vueNodeData, nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const allNodes = computed(() => {
|
||||
if (!isVueNodesEnabled.value) return []
|
||||
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
|
||||
return Array.from(vueNodeData.value.values())
|
||||
})
|
||||
|
||||
@@ -25,7 +34,7 @@ export function useViewportCulling() {
|
||||
* Update visibility of all nodes based on viewport
|
||||
* Queries DOM directly - no cache maintenance needed
|
||||
*/
|
||||
function updateVisibility() {
|
||||
const updateVisibility = () => {
|
||||
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
@@ -67,17 +76,31 @@ export function useViewportCulling() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateVisibilityDebounced = useThrottleFn(updateVisibility, 20)
|
||||
|
||||
// RAF throttling for smooth updates during continuous panning
|
||||
function handleTransformUpdate() {
|
||||
requestAnimationFrame(async () => {
|
||||
await updateVisibilityDebounced()
|
||||
let rafId: number | null = null
|
||||
|
||||
/**
|
||||
* Handle transform update - called by TransformPane event
|
||||
* Uses RAF to batch updates for smooth performance
|
||||
*/
|
||||
const handleTransformUpdate = () => {
|
||||
if (!isVueNodesEnabled.value) return
|
||||
|
||||
// Cancel previous RAF if still pending
|
||||
if (rafId !== null) {
|
||||
cancelAnimationFrame(rafId)
|
||||
}
|
||||
|
||||
// Schedule update in next animation frame
|
||||
rafId = requestAnimationFrame(() => {
|
||||
updateVisibility()
|
||||
rafId = null
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
allNodes,
|
||||
handleTransformUpdate
|
||||
handleTransformUpdate,
|
||||
updateVisibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { readonly, ref, shallowRef, watch } from 'vue'
|
||||
/**
|
||||
* Vue Node Lifecycle Management Composable
|
||||
*
|
||||
* Handles the complete lifecycle of Vue node rendering system including:
|
||||
* - Node manager initialization and cleanup
|
||||
* - Layout store synchronization
|
||||
* - Slot and link sync management
|
||||
* - Reactive state management for node data, positions, and sizes
|
||||
* - Memory management and proper cleanup
|
||||
*/
|
||||
import { type Ref, computed, readonly, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
GraphNodeManager,
|
||||
NodeState,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import type { LGraphCanvas, LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -16,12 +24,13 @@ import { useLinkLayoutSync } from '@/renderer/core/layout/sync/useLinkLayoutSync
|
||||
import { useSlotLayoutSync } from '@/renderer/core/layout/sync/useSlotLayoutSync'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
function useVueNodeLifecycleIndividual() {
|
||||
export function useVueNodeLifecycle(isVueNodesEnabled: Ref<boolean>) {
|
||||
const canvasStore = useCanvasStore()
|
||||
const layoutMutations = useLayoutMutations()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const nodeManager = shallowRef<GraphNodeManager | null>(null)
|
||||
const nodeManager = shallowRef<ReturnType<typeof useGraphNodeManager> | null>(
|
||||
null
|
||||
)
|
||||
const cleanupNodeManager = shallowRef<(() => void) | null>(null)
|
||||
|
||||
// Sync management
|
||||
@@ -31,10 +40,22 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Vue node data state
|
||||
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
|
||||
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
|
||||
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
|
||||
new Map()
|
||||
)
|
||||
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
|
||||
new Map()
|
||||
)
|
||||
|
||||
// Change detection function
|
||||
const detectChangesInRAF = ref<() => void>(() => {})
|
||||
|
||||
// Trigger for forcing computed re-evaluation
|
||||
const nodeDataTrigger = ref(0)
|
||||
|
||||
const isNodeManagerReady = computed(() => nodeManager.value !== null)
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph || comfyApp.graph
|
||||
@@ -47,6 +68,10 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Use the manager's data maps
|
||||
vueNodeData.value = manager.vueNodeData
|
||||
nodeState.value = manager.nodeState
|
||||
nodePositions.value = manager.nodePositions
|
||||
nodeSizes.value = manager.nodeSizes
|
||||
detectChangesInRAF.value = manager.detectChangesInRAF
|
||||
|
||||
// Initialize layout system with existing nodes from active graph
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
@@ -109,12 +134,18 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Reset reactive maps to clean state
|
||||
vueNodeData.value = new Map()
|
||||
nodeState.value = new Map()
|
||||
nodePositions.value = new Map()
|
||||
nodeSizes.value = new Map()
|
||||
|
||||
// Reset change detection function
|
||||
detectChangesInRAF.value = () => {}
|
||||
}
|
||||
|
||||
// Watch for Vue nodes enabled state changes
|
||||
watch(
|
||||
() =>
|
||||
shouldRenderVueNodes.value &&
|
||||
isVueNodesEnabled.value &&
|
||||
Boolean(comfyApp.canvas?.graph || comfyApp.graph),
|
||||
(enabled) => {
|
||||
if (enabled) {
|
||||
@@ -128,7 +159,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Consolidated watch for slot layout sync management
|
||||
watch(
|
||||
[() => canvasStore.canvas, () => shouldRenderVueNodes.value],
|
||||
[() => canvasStore.canvas, () => isVueNodesEnabled.value],
|
||||
([canvas, vueMode], [, oldVueMode]) => {
|
||||
const modeChanged = vueMode !== oldVueMode
|
||||
|
||||
@@ -160,7 +191,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
// Handle case where Vue nodes are enabled but graph starts empty
|
||||
const setupEmptyGraphListener = () => {
|
||||
if (
|
||||
shouldRenderVueNodes.value &&
|
||||
isVueNodesEnabled.value &&
|
||||
comfyApp.graph &&
|
||||
!nodeManager.value &&
|
||||
comfyApp.graph._nodes.length === 0
|
||||
@@ -171,7 +202,7 @@ function useVueNodeLifecycleIndividual() {
|
||||
comfyApp.graph.onNodeAdded = originalOnNodeAdded
|
||||
|
||||
// Initialize node manager if needed
|
||||
if (shouldRenderVueNodes.value && !nodeManager.value) {
|
||||
if (isVueNodesEnabled.value && !nodeManager.value) {
|
||||
initializeNodeManager()
|
||||
}
|
||||
|
||||
@@ -202,7 +233,13 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
return {
|
||||
vueNodeData,
|
||||
nodeState,
|
||||
nodePositions,
|
||||
nodeSizes,
|
||||
nodeDataTrigger: readonly(nodeDataTrigger),
|
||||
nodeManager: readonly(nodeManager),
|
||||
detectChangesInRAF: readonly(detectChangesInRAF),
|
||||
isNodeManagerReady,
|
||||
|
||||
// Lifecycle methods
|
||||
initializeNodeManager,
|
||||
@@ -211,7 +248,3 @@ function useVueNodeLifecycleIndividual() {
|
||||
cleanup
|
||||
}
|
||||
}
|
||||
|
||||
export const useVueNodeLifecycle = createSharedComposable(
|
||||
useVueNodeLifecycleIndividual
|
||||
)
|
||||
|
||||
@@ -1548,71 +1548,6 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
|
||||
},
|
||||
ByteDanceImageReferenceNode: {
|
||||
displayPrice: byteDanceVideoPricingCalculator
|
||||
},
|
||||
WanTextToVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'size'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const resolutionStr = String(resolutionWidget.value).toLowerCase()
|
||||
|
||||
const resKey = resolutionStr.includes('1080')
|
||||
? '1080p'
|
||||
: resolutionStr.includes('720')
|
||||
? '720p'
|
||||
: resolutionStr.includes('480')
|
||||
? '480p'
|
||||
: resolutionStr.match(/^\s*(\d{3,4}p)/)?.[1] ?? ''
|
||||
|
||||
const pricePerSecond: Record<string, number> = {
|
||||
'480p': 0.05,
|
||||
'720p': 0.1,
|
||||
'1080p': 0.15
|
||||
}
|
||||
|
||||
const pps = pricePerSecond[resKey]
|
||||
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
|
||||
|
||||
const cost = (pps * seconds).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
WanImageToVideoApi: {
|
||||
displayPrice: (node: LGraphNode): string => {
|
||||
const durationWidget = node.widgets?.find(
|
||||
(w) => w.name === 'duration'
|
||||
) as IComboWidget
|
||||
const resolutionWidget = node.widgets?.find(
|
||||
(w) => w.name === 'resolution'
|
||||
) as IComboWidget
|
||||
|
||||
if (!durationWidget || !resolutionWidget) return '$0.05-0.15/second'
|
||||
|
||||
const seconds = parseFloat(String(durationWidget.value))
|
||||
const resolution = String(resolutionWidget.value).trim().toLowerCase()
|
||||
|
||||
const pricePerSecond: Record<string, number> = {
|
||||
'480p': 0.05,
|
||||
'720p': 0.1,
|
||||
'1080p': 0.15
|
||||
}
|
||||
|
||||
const pps = pricePerSecond[resolution]
|
||||
if (isNaN(seconds) || !pps) return '$0.05-0.15/second'
|
||||
|
||||
const cost = (pps * seconds).toFixed(2)
|
||||
return `$${cost}/Run`
|
||||
}
|
||||
},
|
||||
WanTextToImageApi: {
|
||||
displayPrice: '$0.03/Run'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1712,9 +1647,7 @@ export const useNodePricing = () => {
|
||||
ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'],
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'],
|
||||
WanTextToVideoApi: ['duration', 'size'],
|
||||
WanImageToVideoApi: ['duration', 'resolution']
|
||||
ByteDanceImageReferenceNode: ['model', 'duration', 'resolution']
|
||||
}
|
||||
return widgetMap[nodeType] || []
|
||||
}
|
||||
|
||||
@@ -2,17 +2,16 @@
|
||||
* Vue-related feature flags composable
|
||||
* Manages local settings-driven flags and LiteGraph integration
|
||||
*/
|
||||
import { createSharedComposable } from '@vueuse/core'
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
import { LiteGraph } from '../lib/litegraph/src/litegraph'
|
||||
|
||||
function useVueFeatureFlagsIndividual() {
|
||||
export const useVueFeatureFlags = () => {
|
||||
const settingStore = useSettingStore()
|
||||
|
||||
const shouldRenderVueNodes = computed(() => {
|
||||
const isVueNodesEnabled = computed(() => {
|
||||
try {
|
||||
return settingStore.get('Comfy.VueNodes.Enabled') ?? false
|
||||
} catch {
|
||||
@@ -20,20 +19,20 @@ function useVueFeatureFlagsIndividual() {
|
||||
}
|
||||
})
|
||||
|
||||
// Whether Vue nodes should render
|
||||
const shouldRenderVueNodes = computed(() => isVueNodesEnabled.value)
|
||||
|
||||
// Sync the Vue nodes flag with LiteGraph global settings
|
||||
const syncVueNodesFlag = () => {
|
||||
LiteGraph.vueNodesMode = isVueNodesEnabled.value
|
||||
}
|
||||
|
||||
// Watch for changes and update LiteGraph immediately
|
||||
watch(
|
||||
shouldRenderVueNodes,
|
||||
() => {
|
||||
LiteGraph.vueNodesMode = shouldRenderVueNodes.value
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(isVueNodesEnabled, syncVueNodesFlag, { immediate: true })
|
||||
|
||||
return {
|
||||
shouldRenderVueNodes
|
||||
isVueNodesEnabled,
|
||||
shouldRenderVueNodes,
|
||||
syncVueNodesFlag
|
||||
}
|
||||
}
|
||||
|
||||
export const useVueFeatureFlags = createSharedComposable(
|
||||
useVueFeatureFlagsIndividual
|
||||
)
|
||||
|
||||
@@ -122,14 +122,14 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
key: '.'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.FitView',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
key: 'p'
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelected.Pin',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
@@ -137,7 +137,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
alt: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Collapse',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
@@ -145,7 +145,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Bypass',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
@@ -153,7 +153,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [
|
||||
ctrl: true
|
||||
},
|
||||
commandId: 'Comfy.Canvas.ToggleSelectedNodes.Mute',
|
||||
targetElementId: 'graph-canvas-container'
|
||||
targetElementId: 'graph-canvas'
|
||||
},
|
||||
{
|
||||
combo: {
|
||||
|
||||
@@ -9,12 +9,6 @@ import { useDialogService } from '@/services/dialogService'
|
||||
import { electronAPI as getElectronAPI, isElectron } from '@/utils/envUtil'
|
||||
import { checkMirrorReachable } from '@/utils/networkUtil'
|
||||
|
||||
// Desktop documentation URLs
|
||||
const DESKTOP_DOCS = {
|
||||
WINDOWS: 'https://docs.comfy.org/installation/desktop/windows',
|
||||
MACOS: 'https://docs.comfy.org/installation/desktop/macos'
|
||||
} as const
|
||||
|
||||
;(async () => {
|
||||
if (!isElectron()) return
|
||||
|
||||
@@ -165,11 +159,7 @@ const DESKTOP_DOCS = {
|
||||
label: 'Desktop User Guide',
|
||||
icon: 'pi pi-book',
|
||||
function() {
|
||||
const docsUrl =
|
||||
electronAPI.getPlatform() === 'darwin'
|
||||
? DESKTOP_DOCS.MACOS
|
||||
: DESKTOP_DOCS.WINDOWS
|
||||
window.open(docsUrl, '_blank')
|
||||
window.open('https://comfyorg.notion.site/', '_blank')
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -3901,19 +3901,6 @@ class UIManager {
|
||||
this.paintBucketSettingsHTML.style.display = 'none'
|
||||
}
|
||||
}
|
||||
if (tool === Tools.MaskColorFill) {
|
||||
this.brushSettingsHTML.style.display = 'none'
|
||||
this.colorSelectSettingsHTML.style.display = 'flex'
|
||||
this.paintBucketSettingsHTML.style.display = 'none'
|
||||
} else if (tool === Tools.MaskBucket) {
|
||||
this.brushSettingsHTML.style.display = 'none'
|
||||
this.colorSelectSettingsHTML.style.display = 'none'
|
||||
this.paintBucketSettingsHTML.style.display = 'flex'
|
||||
} else {
|
||||
this.brushSettingsHTML.style.display = 'flex'
|
||||
this.colorSelectSettingsHTML.style.display = 'none'
|
||||
this.paintBucketSettingsHTML.style.display = 'none'
|
||||
}
|
||||
this.messageBroker.publish('setTool', tool)
|
||||
this.onToolChange()
|
||||
const newActiveLayer = this.toolSettings[tool].newActiveLayerOnSet
|
||||
|
||||
@@ -76,7 +76,7 @@ export const i18n = createI18n({
|
||||
})
|
||||
|
||||
/** Convenience shorthand: i18n.global */
|
||||
export const { t, te, d } = i18n.global
|
||||
export const { t, te } = i18n.global
|
||||
|
||||
/**
|
||||
* Safe translation function that returns the fallback message if the key is not found.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Point, ReadOnlyRect, Rect } from './interfaces'
|
||||
import type { Point, Rect } from './interfaces'
|
||||
import { EaseFunction, Rectangle } from './litegraph'
|
||||
|
||||
export interface DragAndScaleState {
|
||||
@@ -188,10 +188,7 @@ export class DragAndScale {
|
||||
* Fits the view to the specified bounds.
|
||||
* @param bounds The bounds to fit the view to, defined by a rectangle.
|
||||
*/
|
||||
fitToBounds(
|
||||
bounds: ReadOnlyRect,
|
||||
{ zoom = 0.75 }: { zoom?: number } = {}
|
||||
): void {
|
||||
fitToBounds(bounds: Rect, { zoom = 0.75 }: { zoom?: number } = {}): void {
|
||||
const cw = this.element.width / window.devicePixelRatio
|
||||
const ch = this.element.height / window.devicePixelRatio
|
||||
let targetScale = this.scale
|
||||
@@ -223,7 +220,7 @@ export class DragAndScale {
|
||||
* @param bounds The bounds to animate the view to, defined by a rectangle.
|
||||
*/
|
||||
animateToBounds(
|
||||
bounds: ReadOnlyRect,
|
||||
bounds: Rect | Rectangle,
|
||||
setDirty: () => void,
|
||||
{
|
||||
duration = 350,
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
SUBGRAPH_INPUT_ID,
|
||||
SUBGRAPH_OUTPUT_ID
|
||||
} from '@/lib/litegraph/src/constants'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
import type { UUID } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { createUuidv4, zeroUuid } from '@/lib/litegraph/src/utils/uuid'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
@@ -1707,7 +1708,12 @@ export class LGraph
|
||||
...subgraphNode.subgraph.groups
|
||||
].map((p: { pos: Point; size?: Size }): HasBoundingRect => {
|
||||
return {
|
||||
boundingRect: [p.pos[0], p.pos[1], p.size?.[0] ?? 0, p.size?.[1] ?? 0]
|
||||
boundingRect: new Rectangle(
|
||||
p.pos[0],
|
||||
p.pos[1],
|
||||
p.size?.[0] ?? 0,
|
||||
p.size?.[1] ?? 0
|
||||
)
|
||||
}
|
||||
})
|
||||
const bounds = createBounds(positionables) ?? [0, 0, 0, 0]
|
||||
|
||||
@@ -47,8 +47,6 @@ import type {
|
||||
NullableProperties,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect,
|
||||
Size
|
||||
} from './interfaces'
|
||||
@@ -236,11 +234,11 @@ export class LGraphCanvas
|
||||
implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
{
|
||||
// Optimised buffers used during rendering
|
||||
static #temp = new Float32Array(4)
|
||||
static #temp_vec2 = new Float32Array(2)
|
||||
static #tmp_area = new Float32Array(4)
|
||||
static #margin_area = new Float32Array(4)
|
||||
static #link_bounding = new Float32Array(4)
|
||||
static #temp = [0, 0, 0, 0] satisfies Rect
|
||||
static #temp_vec2 = [0, 0] satisfies Point
|
||||
static #tmp_area = [0, 0, 0, 0] satisfies Rect
|
||||
static #margin_area = [0, 0, 0, 0] satisfies Rect
|
||||
static #link_bounding = [0, 0, 0, 0] satisfies Rect
|
||||
|
||||
static DEFAULT_BACKGROUND_IMAGE =
|
||||
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAABkCAIAAAD/gAIDAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAQBJREFUeNrs1rEKwjAUhlETUkj3vP9rdmr1Ysammk2w5wdxuLgcMHyptfawuZX4pJSWZTnfnu/lnIe/jNNxHHGNn//HNbbv+4dr6V+11uF527arU7+u63qfa/bnmh8sWLBgwYJlqRf8MEptXPBXJXa37BSl3ixYsGDBMliwFLyCV/DeLIMFCxYsWLBMwSt4Be/NggXLYMGCBUvBK3iNruC9WbBgwYJlsGApeAWv4L1ZBgsWLFiwYJmCV/AK3psFC5bBggULloJX8BpdwXuzYMGCBctgwVLwCl7Be7MMFixYsGDBsu8FH1FaSmExVfAxBa/gvVmwYMGCZbBg/W4vAQYA5tRF9QYlv/QAAAAASUVORK5CYII='
|
||||
@@ -628,7 +626,7 @@ export class LGraphCanvas
|
||||
dirty_area?: Rect | null
|
||||
/** @deprecated Unused */
|
||||
node_in_panel?: LGraphNode | null
|
||||
last_mouse: ReadOnlyPoint = [0, 0]
|
||||
last_mouse: Point = [0, 0]
|
||||
last_mouseclick: number = 0
|
||||
graph: LGraph | Subgraph | null
|
||||
get _graph(): LGraph | Subgraph {
|
||||
@@ -2633,7 +2631,7 @@ export class LGraphCanvas
|
||||
pointer: CanvasPointer,
|
||||
node?: LGraphNode | undefined
|
||||
): void {
|
||||
const dragRect = new Float32Array(4)
|
||||
const dragRect: [number, number, number, number] = [0, 0, 0, 0]
|
||||
|
||||
dragRect[0] = e.canvasX
|
||||
dragRect[1] = e.canvasY
|
||||
@@ -3166,7 +3164,7 @@ export class LGraphCanvas
|
||||
|
||||
LGraphCanvas.active_canvas = this
|
||||
this.adjustMouseEvent(e)
|
||||
const mouse: ReadOnlyPoint = [e.clientX, e.clientY]
|
||||
const mouse: Point = [e.clientX, e.clientY]
|
||||
this.mouse[0] = mouse[0]
|
||||
this.mouse[1] = mouse[1]
|
||||
const delta = [mouse[0] - this.last_mouse[0], mouse[1] - this.last_mouse[1]]
|
||||
@@ -4032,18 +4030,6 @@ export class LGraphCanvas
|
||||
|
||||
// TODO: Report failures, i.e. `failedNodes`
|
||||
|
||||
const newPositions = created.map((node) => ({
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: node.size?.[1] ?? 200
|
||||
}
|
||||
}))
|
||||
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
this.selectItems(created)
|
||||
|
||||
graph.afterChange()
|
||||
@@ -4067,7 +4053,10 @@ export class LGraphCanvas
|
||||
this.setDirty(true)
|
||||
}
|
||||
|
||||
#handleMultiSelect(e: CanvasPointerEvent, dragRect: Float32Array) {
|
||||
#handleMultiSelect(
|
||||
e: CanvasPointerEvent,
|
||||
dragRect: [number, number, number, number]
|
||||
) {
|
||||
// Process drag
|
||||
// Convert Point pair (pos, offset) to Rect
|
||||
const { graph, selectedItems, subgraph } = this
|
||||
@@ -4838,7 +4827,7 @@ export class LGraphCanvas
|
||||
}
|
||||
|
||||
/** Get the target snap / highlight point in graph space */
|
||||
#getHighlightPosition(): ReadOnlyPoint {
|
||||
#getHighlightPosition(): Point {
|
||||
return LiteGraph.snaps_for_comfy
|
||||
? this.linkConnector.state.snapLinksPos ??
|
||||
this._highlight_pos ??
|
||||
@@ -4853,7 +4842,7 @@ export class LGraphCanvas
|
||||
*/
|
||||
#renderSnapHighlight(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
highlightPos: ReadOnlyPoint
|
||||
highlightPos: Point
|
||||
): void {
|
||||
const linkConnectorSnap = !!this.linkConnector.state.snapLinksPos
|
||||
if (!this._highlight_pos && !linkConnectorSnap) return
|
||||
@@ -5195,7 +5184,8 @@ export class LGraphCanvas
|
||||
// clip if required (mask)
|
||||
const shape = node._shape || RenderShape.BOX
|
||||
const size = LGraphCanvas.#temp_vec2
|
||||
size.set(node.renderingSize)
|
||||
size[0] = node.renderingSize[0]
|
||||
size[1] = node.renderingSize[1]
|
||||
|
||||
if (node.collapsed) {
|
||||
ctx.font = this.inner_text_font
|
||||
@@ -5390,7 +5380,10 @@ export class LGraphCanvas
|
||||
|
||||
// Normalised node dimensions
|
||||
const area = LGraphCanvas.#tmp_area
|
||||
area.set(node.boundingRect)
|
||||
area[0] = node.boundingRect[0]
|
||||
area[1] = node.boundingRect[1]
|
||||
area[2] = node.boundingRect[2]
|
||||
area[3] = node.boundingRect[3]
|
||||
area[0] -= node.pos[0]
|
||||
area[1] -= node.pos[1]
|
||||
|
||||
@@ -5492,7 +5485,10 @@ export class LGraphCanvas
|
||||
shape = RenderShape.ROUND
|
||||
) {
|
||||
const snapGuide = LGraphCanvas.#temp
|
||||
snapGuide.set(item.boundingRect)
|
||||
snapGuide[0] = item.boundingRect[0]
|
||||
snapGuide[1] = item.boundingRect[1]
|
||||
snapGuide[2] = item.boundingRect[2]
|
||||
snapGuide[3] = item.boundingRect[3]
|
||||
|
||||
// Not all items have pos equal to top-left of bounds
|
||||
const { pos } = item
|
||||
@@ -5932,8 +5928,8 @@ export class LGraphCanvas
|
||||
*/
|
||||
renderLink(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
a: ReadOnlyPoint,
|
||||
b: ReadOnlyPoint,
|
||||
a: Point,
|
||||
b: Point,
|
||||
link: LLink | null,
|
||||
skip_border: boolean,
|
||||
flow: number | null,
|
||||
@@ -5950,9 +5946,9 @@ export class LGraphCanvas
|
||||
/** When defined, render data will be saved to this reroute instead of the {@link link}. */
|
||||
reroute?: Reroute
|
||||
/** Offset of the bezier curve control point from {@link a point a} (output side) */
|
||||
startControl?: ReadOnlyPoint
|
||||
startControl?: Point
|
||||
/** Offset of the bezier curve control point from {@link b point b} (input side) */
|
||||
endControl?: ReadOnlyPoint
|
||||
endControl?: Point
|
||||
/** Number of sublines (useful to represent vec3 or rgb) @todo If implemented, refactor calculations out of the loop */
|
||||
num_sublines?: number
|
||||
/** Whether this is a floating link segment */
|
||||
@@ -8423,7 +8419,7 @@ export class LGraphCanvas
|
||||
* Starts an animation to fit the view around the specified selection of nodes.
|
||||
* @param bounds The bounds to animate the view to, defined by a rectangle.
|
||||
*/
|
||||
animateToBounds(bounds: ReadOnlyRect, options: AnimationOptions = {}) {
|
||||
animateToBounds(bounds: Rect | Rectangle, options: AnimationOptions = {}) {
|
||||
const setDirty = () => this.setDirty(true, true)
|
||||
this.ds.animateToBounds(bounds, setDirty, options)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NullGraphError } from '@/lib/litegraph/src/infrastructure/NullGraphError'
|
||||
import { Rectangle } from '@/lib/litegraph/src/infrastructure/Rectangle'
|
||||
|
||||
import type { LGraph } from './LGraph'
|
||||
import { LGraphCanvas } from './LGraphCanvas'
|
||||
@@ -40,15 +41,15 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
title: string
|
||||
font?: string
|
||||
font_size: number = LiteGraph.DEFAULT_GROUP_FONT || 24
|
||||
_bounding: Float32Array = new Float32Array([
|
||||
_bounding: [number, number, number, number] = [
|
||||
10,
|
||||
10,
|
||||
LGraphGroup.minWidth,
|
||||
LGraphGroup.minHeight
|
||||
])
|
||||
]
|
||||
|
||||
_pos: Point = this._bounding.subarray(0, 2)
|
||||
_size: Size = this._bounding.subarray(2, 4)
|
||||
_pos: Point = [10, 10]
|
||||
_size: Size = [LGraphGroup.minWidth, LGraphGroup.minHeight]
|
||||
/** @deprecated See {@link _children} */
|
||||
_nodes: LGraphNode[] = []
|
||||
_children: Set<Positionable> = new Set()
|
||||
@@ -107,8 +108,13 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
this._size[1] = Math.max(LGraphGroup.minHeight, v[1])
|
||||
}
|
||||
|
||||
get boundingRect() {
|
||||
return this._bounding
|
||||
get boundingRect(): Rectangle {
|
||||
return Rectangle.from([
|
||||
this._pos[0],
|
||||
this._pos[1],
|
||||
this._size[0],
|
||||
this._size[1]
|
||||
])
|
||||
}
|
||||
|
||||
get nodes() {
|
||||
@@ -145,14 +151,17 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
configure(o: ISerialisedGroup): void {
|
||||
this.id = o.id
|
||||
this.title = o.title
|
||||
this._bounding.set(o.bounding)
|
||||
this._pos[0] = o.bounding[0]
|
||||
this._pos[1] = o.bounding[1]
|
||||
this._size[0] = o.bounding[2]
|
||||
this._size[1] = o.bounding[3]
|
||||
this.color = o.color
|
||||
this.flags = o.flags || this.flags
|
||||
if (o.font_size) this.font_size = o.font_size
|
||||
}
|
||||
|
||||
serialize(): ISerialisedGroup {
|
||||
const b = this._bounding
|
||||
const b = [this._pos[0], this._pos[1], this._size[0], this._size[1]]
|
||||
return {
|
||||
id: this.id,
|
||||
title: this.title,
|
||||
@@ -210,7 +219,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
)
|
||||
|
||||
if (LiteGraph.highlight_selected_group && this.selected) {
|
||||
strokeShape(ctx, this._bounding, {
|
||||
strokeShape(ctx, this.boundingRect, {
|
||||
title_height: this.titleHeight,
|
||||
padding
|
||||
})
|
||||
@@ -251,7 +260,7 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
|
||||
// Move nodes we overlap the centre point of
|
||||
for (const node of nodes) {
|
||||
if (containsCentre(this._bounding, node.boundingRect)) {
|
||||
if (containsCentre(this.boundingRect, node.boundingRect)) {
|
||||
this._nodes.push(node)
|
||||
children.add(node)
|
||||
}
|
||||
@@ -259,12 +268,13 @@ export class LGraphGroup implements Positionable, IPinnable, IColorable {
|
||||
|
||||
// Move reroutes we overlap the centre point of
|
||||
for (const reroute of reroutes.values()) {
|
||||
if (isPointInRect(reroute.pos, this._bounding)) children.add(reroute)
|
||||
if (isPointInRect(reroute.pos, this.boundingRect)) children.add(reroute)
|
||||
}
|
||||
|
||||
// Move groups we wholly contain
|
||||
for (const group of groups) {
|
||||
if (containsRect(this._bounding, group._bounding)) children.add(group)
|
||||
if (containsRect(this.boundingRect, group.boundingRect))
|
||||
children.add(group)
|
||||
}
|
||||
|
||||
groups.sort((a, b) => {
|
||||
|
||||
@@ -18,7 +18,6 @@ import type { Reroute, RerouteId } from './Reroute'
|
||||
import { getNodeInputOnPos, getNodeOutputOnPos } from './canvas/measureSlots'
|
||||
import type { IDrawBoundingOptions } from './draw'
|
||||
import { NullGraphError } from './infrastructure/NullGraphError'
|
||||
import type { ReadOnlyRectangle } from './infrastructure/Rectangle'
|
||||
import { Rectangle } from './infrastructure/Rectangle'
|
||||
import type {
|
||||
ColorOption,
|
||||
@@ -37,8 +36,6 @@ import type {
|
||||
ISlotType,
|
||||
Point,
|
||||
Positionable,
|
||||
ReadOnlyPoint,
|
||||
ReadOnlyRect,
|
||||
Rect,
|
||||
Size
|
||||
} from './interfaces'
|
||||
@@ -387,7 +384,7 @@ export class LGraphNode
|
||||
* Called once at the start of every frame. Caller may change the values in {@link out}, which will be reflected in {@link boundingRect}.
|
||||
* WARNING: Making changes to boundingRect via onBounding is poorly supported, and will likely result in strange behaviour.
|
||||
*/
|
||||
onBounding?(this: LGraphNode, out: Rect): void
|
||||
onBounding?(this: LGraphNode, out: Rectangle): void
|
||||
console?: string[]
|
||||
_level?: number
|
||||
_shape?: RenderShape
|
||||
@@ -413,12 +410,12 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
/** @inheritdoc {@link renderArea} */
|
||||
#renderArea: Float32Array = new Float32Array(4)
|
||||
#renderArea: [number, number, number, number] = [0, 0, 0, 0]
|
||||
/**
|
||||
* Rect describing the node area, including shadows and any protrusions.
|
||||
* Determines if the node is visible. Calculated once at the start of every frame.
|
||||
*/
|
||||
get renderArea(): ReadOnlyRect {
|
||||
get renderArea(): Rect {
|
||||
return this.#renderArea
|
||||
}
|
||||
|
||||
@@ -429,12 +426,12 @@ export class LGraphNode
|
||||
*
|
||||
* Determines the node hitbox and other rendering effects. Calculated once at the start of every frame.
|
||||
*/
|
||||
get boundingRect(): ReadOnlyRectangle {
|
||||
get boundingRect(): Rectangle {
|
||||
return this.#boundingRect
|
||||
}
|
||||
|
||||
/** The offset from {@link pos} to the top-left of {@link boundingRect}. */
|
||||
get boundingOffset(): ReadOnlyPoint {
|
||||
get boundingOffset(): Point {
|
||||
const {
|
||||
pos: [posX, posY],
|
||||
boundingRect: [bX, bY]
|
||||
@@ -443,9 +440,9 @@ export class LGraphNode
|
||||
}
|
||||
|
||||
/** {@link pos} and {@link size} values are backed by this {@link Rect}. */
|
||||
_posSize: Float32Array = new Float32Array(4)
|
||||
_pos: Point = this._posSize.subarray(0, 2)
|
||||
_size: Size = this._posSize.subarray(2, 4)
|
||||
_posSize: [number, number, number, number] = [0, 0, 0, 0]
|
||||
_pos: Point = [0, 0]
|
||||
_size: Size = [0, 0]
|
||||
|
||||
public get pos() {
|
||||
return this._pos
|
||||
@@ -1653,7 +1650,7 @@ export class LGraphNode
|
||||
inputs ? inputs.filter((input) => !isWidgetInputSlot(input)).length : 1,
|
||||
outputs ? outputs.length : 1
|
||||
)
|
||||
const size = out || new Float32Array([0, 0])
|
||||
const size = out || [0, 0]
|
||||
rows = Math.max(rows, 1)
|
||||
// although it should be graphcanvas.inner_text_font size
|
||||
const font_size = LiteGraph.NODE_TEXT_SIZE
|
||||
@@ -1978,7 +1975,7 @@ export class LGraphNode
|
||||
* @param out `x, y, width, height` are written to this array.
|
||||
* @param ctx The canvas context to use for measuring text.
|
||||
*/
|
||||
measure(out: Rect, ctx: CanvasRenderingContext2D): void {
|
||||
measure(out: Rectangle, ctx: CanvasRenderingContext2D): void {
|
||||
const titleMode = this.title_mode
|
||||
const renderTitle =
|
||||
titleMode != TitleMode.TRANSPARENT_TITLE &&
|
||||
@@ -2004,13 +2001,13 @@ export class LGraphNode
|
||||
|
||||
/**
|
||||
* returns the bounding of the object, used for rendering purposes
|
||||
* @param out {Float32Array[4]?} [optional] a place to store the output, to free garbage
|
||||
* @param out {Rect?} [optional] a place to store the output, to free garbage
|
||||
* @param includeExternal {boolean?} [optional] set to true to
|
||||
* include the shadow and connection points in the bounding calculation
|
||||
* @returns the bounding box in format of [topleft_cornerx, topleft_cornery, width, height]
|
||||
*/
|
||||
getBounding(out?: Rect, includeExternal?: boolean): Rect {
|
||||
out ||= new Float32Array(4)
|
||||
out ||= [0, 0, 0, 0]
|
||||
|
||||
const rect = includeExternal ? this.renderArea : this.boundingRect
|
||||
out[0] = rect[0]
|
||||
@@ -2031,7 +2028,10 @@ export class LGraphNode
|
||||
this.onBounding?.(bounds)
|
||||
|
||||
const renderArea = this.#renderArea
|
||||
renderArea.set(bounds)
|
||||
renderArea[0] = bounds[0]
|
||||
renderArea[1] = bounds[1]
|
||||
renderArea[2] = bounds[2]
|
||||
renderArea[3] = bounds[3]
|
||||
// 4 offset for collapsed node connection points
|
||||
renderArea[0] -= 4
|
||||
renderArea[1] -= 4
|
||||
@@ -3174,7 +3174,7 @@ export class LGraphNode
|
||||
* @returns the position
|
||||
*/
|
||||
getConnectionPos(is_input: boolean, slot_number: number, out?: Point): Point {
|
||||
out ||= new Float32Array(2)
|
||||
out ||= [0, 0]
|
||||
|
||||
const {
|
||||
pos: [nodeX, nodeY],
|
||||
@@ -3841,7 +3841,7 @@ export class LGraphNode
|
||||
slot.boundingRect[3] = LiteGraph.NODE_SLOT_HEIGHT
|
||||
}
|
||||
|
||||
#measureSlots(): ReadOnlyRect | null {
|
||||
#measureSlots(): Rect | null {
|
||||
const slots: (NodeInputSlot | NodeOutputSlot)[] = []
|
||||
|
||||
for (const [slotIndex, slot] of this.#concreteInputs.entries()) {
|
||||
|
||||