diff --git a/.github/workflows/i18n-custom-nodes.yaml b/.github/workflows/i18n-custom-nodes.yaml index a5617c196..f46e9b7ac 100644 --- a/.github/workflows/i18n-custom-nodes.yaml +++ b/.github/workflows/i18n-custom-nodes.yaml @@ -32,11 +32,10 @@ jobs: with: repository: Comfy-Org/ComfyUI_frontend path: ComfyUI_frontend - - name: Checkout ComfyUI_devtools - uses: actions/checkout@v4 - with: - repository: Comfy-Org/ComfyUI_devtools - path: ComfyUI/custom_nodes/ComfyUI_devtools + - 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 custom node repository uses: actions/checkout@v4 with: diff --git a/.github/workflows/test-ui.yaml b/.github/workflows/test-ui.yaml index eaaaefee0..4f05a6d26 100644 --- a/.github/workflows/test-ui.yaml +++ b/.github/workflows/test-ui.yaml @@ -27,12 +27,10 @@ jobs: repository: 'Comfy-Org/ComfyUI_frontend' path: 'ComfyUI_frontend' - - name: Checkout ComfyUI_devtools - uses: actions/checkout@v4 - with: - repository: 'Comfy-Org/ComfyUI_devtools' - path: 'ComfyUI/custom_nodes/ComfyUI_devtools' - ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684' + - 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: Install pnpm uses: pnpm/action-setup@v4 diff --git a/CODEOWNERS b/CODEOWNERS index 8d4e4a90f..cd1b4e508 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1,17 +1,61 @@ -# Admins -* @Comfy-Org/comfy_frontend_devs +# 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 -# Maintainers -*.md @Comfy-Org/comfy_maintainer -/tests-ui/ @Comfy-Org/comfy_maintainer -/browser_tests/ @Comfy-Org/comfy_maintainer -/.env_example @Comfy-Org/comfy_maintainer +# Common UI Components +/src/components/chip/ @viva-jinyi +/src/components/card/ @viva-jinyi +/src/components/button/ @viva-jinyi +/src/components/input/ @viva-jinyi -# Translations (AIGODLIKE team + shinshin86) -/src/locales/ @Yorha4D @KarryCharon @DorotaLuna @shinshin86 @Comfy-Org/comfy_maintainer +# Topbar +/src/components/topbar/ @pythongosssss -# Load 3D extension -/src/extensions/core/load3d.ts @jtydhr88 @Comfy-Org/comfy_frontend_devs +# Thumbnail +/src/renderer/core/thumbnail/ @pythongosssss -# Mask Editor extension -/src/extensions/core/maskeditor.ts @brucew4yn3rp @trsommer @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 diff --git a/browser_tests/README.md b/browser_tests/README.md index ede6a303a..021c063ae 100644 --- a/browser_tests/README.md +++ b/browser_tests/README.md @@ -16,9 +16,14 @@ Without this flag, parallel tests will conflict and fail randomly. ### ComfyUI devtools -Clone to your `custom_nodes` directory. +ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `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: diff --git a/browser_tests/tests/vueNodes/lod.spec.ts b/browser_tests/tests/vueNodes/lod.spec.ts new file mode 100644 index 000000000..9011f91b1 --- /dev/null +++ b/browser_tests/tests/vueNodes/lod.spec.ts @@ -0,0 +1,44 @@ +import { expect } from '@playwright/test' + +import { comfyPageFixture as test } from '../../fixtures/ComfyPage' + +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() + }) +}) diff --git a/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png b/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png new file mode 100644 index 000000000..8e93b88f3 Binary files /dev/null and b/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-default-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png b/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png new file mode 100644 index 000000000..5dfa61c19 Binary files /dev/null and b/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-active-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png b/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png new file mode 100644 index 000000000..59802088f Binary files /dev/null and b/browser_tests/tests/vueNodes/lod.spec.ts-snapshots/vue-nodes-lod-inactive-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/nodeInteractions/selectionState.spec.ts b/browser_tests/tests/vueNodes/nodeInteractions/selectionState.spec.ts new file mode 100644 index 000000000..ff8b6f951 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeInteractions/selectionState.spec.ts @@ -0,0 +1,47 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +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) + }) + } +}) diff --git a/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts new file mode 100644 index 000000000..c80a86503 --- /dev/null +++ b/browser_tests/tests/vueNodes/nodeStates/bypass.spec.ts @@ -0,0 +1,49 @@ +import { + comfyExpect as expect, + comfyPageFixture as test +} from '../../../fixtures/ComfyPage' + +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) + }) +}) diff --git a/browser_tests/tsconfig.json b/browser_tests/tsconfig.json index f600c4a7f..391298333 100644 --- a/browser_tests/tsconfig.json +++ b/browser_tests/tsconfig.json @@ -3,7 +3,6 @@ "compilerOptions": { /* Test files should not be compiled */ "noEmit": true, - // "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, "resolveJsonModule": true diff --git a/build/tsconfig.json b/build/tsconfig.json new file mode 100644 index 000000000..1c24810a8 --- /dev/null +++ b/build/tsconfig.json @@ -0,0 +1,17 @@ +{ + "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" + ] +} \ No newline at end of file diff --git a/eslint.config.ts b/eslint.config.ts index 3073948f2..ab3bf09f5 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -33,7 +33,13 @@ export default defineConfig([ }, parserOptions: { parser: tseslint.parser, - projectService: true, + projectService: { + allowDefaultProject: [ + 'vite.config.mts', + 'vite.electron.config.mts', + 'vite.types.config.mts' + ] + }, tsConfigRootDir: import.meta.dirname, ecmaVersion: 2020, sourceType: 'module', @@ -77,6 +83,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 diff --git a/index.html b/index.html index de7710c63..8684af476 100644 --- a/index.html +++ b/index.html @@ -8,8 +8,8 @@ - - + + diff --git a/package.json b/package.json index 923f04b7e..ec94c881b 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@comfyorg/comfyui-frontend", "private": true, - "version": "1.28.0", + "version": "1.28.1", "type": "module", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "homepage": "https://comfy.org", diff --git a/scripts/collect-i18n-node-defs.ts b/scripts/collect-i18n-node-defs.ts index ed443015a..e16740421 100644 --- a/scripts/collect-i18n-node-defs.ts +++ b/scripts/collect-i18n-node-defs.ts @@ -9,9 +9,18 @@ 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 +} + test('collect-i18n-node-defs', async ({ comfyPage }) => { // Mock view route - comfyPage.page.route('**/view**', async (route) => { + await comfyPage.page.route('**/view**', async (route) => { await route.fulfill({ body: JSON.stringify({}) }) @@ -20,6 +29,7 @@ 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() }) @@ -52,7 +62,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => { ) async function extractWidgetLabels() { - const nodeLabels = {} + const nodeLabels: WidgetLabels = {} for (const nodeDef of nodeDefs) { const inputNames = Object.values(nodeDef.inputs).map( @@ -65,12 +75,15 @@ 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) => w?.name && !inputNames.includes(w.name)) - .map((w) => [w.name, w.label]) + .filter( + (w: WidgetInfo) => w?.name && !inputNames.includes(w.name) + ) + .map((w: WidgetInfo) => [w.name, w.label]) ) }, [nodeDef.name, nodeDef.display_name, inputNames] diff --git a/scripts/diff-i18n.ts b/scripts/diff-i18n.ts index 331333367..7b4ff8da1 100644 --- a/scripts/diff-i18n.ts +++ b/scripts/diff-i18n.ts @@ -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)) + writeFileSync(targetPath, readFileSync(file, 'utf8')) } console.log('Captured current locale files to temp/base/') } diff --git a/scripts/tsconfig.json b/scripts/tsconfig.json new file mode 100644 index 000000000..789e142b8 --- /dev/null +++ b/scripts/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + /* Script files configuration */ + "noEmit": true, + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "resolveJsonModule": true + }, + "include": [ + "**/*.ts" + ] +} \ No newline at end of file diff --git a/src/assets/css/style.css b/src/assets/css/style.css index cad8a1b3b..21d526bac 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -929,48 +929,6 @@ 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 */ @@ -996,23 +954,52 @@ audio.comfy-audio.empty-audio-widget { will-change: transform; } -/* 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; +/* 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; + } -/* 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; +.isLOD .lg-node > * { + pointer-events: none; } + +.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 */ diff --git a/src/components/graph/GraphCanvas.vue b/src/components/graph/GraphCanvas.vue index c5839e1f1..ac33aa280 100644 --- a/src/components/graph/GraphCanvas.vue +++ b/src/components/graph/GraphCanvas.vue @@ -43,8 +43,6 @@ 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 @@ -53,9 +51,6 @@ " :zoom-level="canvasStore.canvas?.ds?.scale || 1" :data-node-id="nodeData.id" - @node-click="handleNodeSelect" - @update:collapsed="handleNodeCollapse" - @update:title="handleNodeTitleUpdate" /> @@ -121,8 +116,6 @@ import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteracti 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' @@ -173,7 +166,6 @@ const { shouldRenderVueNodes } = useVueFeatureFlags() // Vue node system const vueNodeLifecycle = useVueNodeLifecycle() const viewportCulling = useViewportCulling() -const nodeEventHandlers = useNodeEventHandlers() const handleVueNodeLifecycleReset = async () => { if (shouldRenderVueNodes.value) { @@ -195,21 +187,8 @@ watch( } ) -const nodePositions = vueNodeLifecycle.nodePositions -const nodeSizes = vueNodeLifecycle.nodeSizes const allNodes = viewportCulling.allNodes - -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 execution state to all Vue nodes -useExecutionStateProvider() +const handleTransformUpdate = viewportCulling.handleTransformUpdate watchEffect(() => { nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated') diff --git a/src/components/graph/NodeTooltip.vue b/src/components/graph/NodeTooltip.vue index 6419326d6..45cbfc9f3 100644 --- a/src/components/graph/NodeTooltip.vue +++ b/src/components/graph/NodeTooltip.vue @@ -33,9 +33,11 @@ const tooltipText = ref('') const left = ref() const top = ref() -const hideTooltip = () => (tooltipText.value = '') +function hideTooltip() { + return (tooltipText.value = '') +} -const showTooltip = async (tooltip: string | null | undefined) => { +async function showTooltip(tooltip: string | null | undefined) { if (!tooltip) return left.value = comfyApp.canvas.mouse[0] + 'px' @@ -56,9 +58,9 @@ const showTooltip = async (tooltip: string | null | undefined) => { } } -const onIdle = () => { +function 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 } diff --git a/src/components/graph/SelectionToolbox.vue b/src/components/graph/SelectionToolbox.vue index 067b04346..f0a18ed3e 100644 --- a/src/components/graph/SelectionToolbox.vue +++ b/src/components/graph/SelectionToolbox.vue @@ -11,7 +11,7 @@ :style="`backgroundColor: ${containerStyles.backgroundColor};`" :pt="{ header: 'hidden', - content: 'px-1 py-1 h-10 px-1 flex flex-row gap-1' + content: 'p-1 h-10 flex flex-row gap-1' }" @wheel="canvasInteractions.handleWheel" > diff --git a/src/components/searchbox/NodeSearchBoxPopover.vue b/src/components/searchbox/NodeSearchBoxPopover.vue index 58492cf72..470853b08 100644 --- a/src/components/searchbox/NodeSearchBoxPopover.vue +++ b/src/components/searchbox/NodeSearchBoxPopover.vue @@ -64,31 +64,29 @@ const litegraphService = useLitegraphService() const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore) const dismissable = ref(true) -const getNewNodeLocation = (): Point => { +function getNewNodeLocation(): Point { return triggerEvent ? [triggerEvent.canvasX, triggerEvent.canvasY] : litegraphService.getCanvasCenter() } const nodeFilters = ref[]>([]) -const addFilter = (filter: FuseFilterWithValue) => { +function addFilter(filter: FuseFilterWithValue) { nodeFilters.value.push(filter) } -const removeFilter = ( - filter: FuseFilterWithValue -) => { +function removeFilter(filter: FuseFilterWithValue) { nodeFilters.value = nodeFilters.value.filter( (f) => toRaw(f) !== toRaw(filter) ) } -const clearFilters = () => { +function clearFilters() { nodeFilters.value = [] } -const closeDialog = () => { +function closeDialog() { visible.value = false } const canvasStore = useCanvasStore() -const addNode = (nodeDef: ComfyNodeDefImpl) => { +function addNode(nodeDef: ComfyNodeDefImpl) { const node = litegraphService.addNodeOnGraph(nodeDef, { pos: getNewNodeLocation() }) @@ -106,7 +104,7 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => { window.requestAnimationFrame(closeDialog) } -const showSearchBox = (e: CanvasPointerEvent | null) => { +function showSearchBox(e: CanvasPointerEvent | null) { if (newSearchBoxEnabled.value) { if (e?.pointerType === 'touch') { setTimeout(() => { @@ -120,11 +118,12 @@ const showSearchBox = (e: CanvasPointerEvent | null) => { } } -const getFirstLink = () => - canvasStore.getCanvas().linkConnector.renderLinks.at(0) +function getFirstLink() { + return canvasStore.getCanvas().linkConnector.renderLinks.at(0) +} const nodeDefStore = useNodeDefStore() -const showNewSearchBox = (e: CanvasPointerEvent | null) => { +function showNewSearchBox(e: CanvasPointerEvent | null) { const firstLink = getFirstLink() if (firstLink) { const filter = @@ -149,7 +148,7 @@ const showNewSearchBox = (e: CanvasPointerEvent | null) => { }, 300) } -const showContextMenu = (e: CanvasPointerEvent) => { +function showContextMenu(e: CanvasPointerEvent) { const firstLink = getFirstLink() if (!firstLink) return @@ -226,7 +225,7 @@ watchEffect(() => { ) }) -const canvasEventHandler = (e: LiteGraphCanvasEvent) => { +function canvasEventHandler(e: LiteGraphCanvasEvent) { if (e.detail.subType === 'empty-double-click') { showSearchBox(e.detail.originalEvent) } else if (e.detail.subType === 'group-double-click') { @@ -249,8 +248,10 @@ const linkReleaseActionShift = computed(() => ) // Prevent normal LinkConnector reset (called by CanvasPointer.finally) -const preventDefault = (e: Event) => e.preventDefault() -const cancelNextReset = (e: CustomEvent) => { +function preventDefault(e: Event) { + return e.preventDefault() +} +function cancelNextReset(e: CustomEvent) { e.preventDefault() const canvas = canvasStore.getCanvas() @@ -260,7 +261,7 @@ const cancelNextReset = (e: CustomEvent) => { }) } -const handleDroppedOnCanvas = (e: CustomEvent) => { +function handleDroppedOnCanvas(e: CustomEvent) { disconnectOnReset = true const action = e.detail.shiftKey ? linkReleaseActionShift.value @@ -281,7 +282,7 @@ const handleDroppedOnCanvas = (e: CustomEvent) => { } // Resets litegraph state -const reset = () => { +function reset() { listenerController?.abort() listenerController = null triggerEvent = null diff --git a/src/composables/auth/useCurrentUser.ts b/src/composables/auth/useCurrentUser.ts index 2c70be227..37b9e4866 100644 --- a/src/composables/auth/useCurrentUser.ts +++ b/src/composables/auth/useCurrentUser.ts @@ -1,4 +1,5 @@ -import { computed, watch } from 'vue' +import { whenever } from '@vueuse/core' +import { computed } from 'vue' import { useFirebaseAuthActions } from '@/composables/auth/useFirebaseAuthActions' import { t } from '@/i18n' @@ -33,19 +34,8 @@ export const useCurrentUser = () => { return null }) - const onUserResolved = (callback: (user: AuthUserInfo) => void) => { - if (resolvedUserInfo.value) { - callback(resolvedUserInfo.value) - } - - const stop = watch(resolvedUserInfo, (value) => { - if (value) { - callback(value) - } - }) - - return () => stop() - } + const onUserResolved = (callback: (user: AuthUserInfo) => void) => + whenever(resolvedUserInfo, callback, { immediate: true }) const userDisplayName = computed(() => { if (isApiKeyLogin.value) { diff --git a/src/composables/graph/useGraphNodeManager.ts b/src/composables/graph/useGraphNodeManager.ts index 618b3087a..ea3f61a14 100644 --- a/src/composables/graph/useGraphNodeManager.ts +++ b/src/composables/graph/useGraphNodeManager.ts @@ -2,42 +2,15 @@ * Vue node lifecycle management for LiteGraph integration * Provides event-driven reactivity with performance optimizations */ -import { nextTick, reactive } from 'vue' +import { reactive } from 'vue' import { useChainCallback } from '@/composables/functional/useChainCallback' 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 -} - -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 @@ -63,109 +36,26 @@ export interface VueNodeData { } } -interface SpatialMetrics { - queryTime: number - nodesInIndex: number -} - export interface GraphNodeManager { // Reactive state - safe data extracted from LiteGraph nodes vueNodeData: ReadonlyMap - nodeState: ReadonlyMap - nodePositions: ReadonlyMap - nodeSizes: ReadonlyMap // 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 - - // Performance - performanceMetrics: PerformanceMetrics - spatialMetrics: SpatialMetrics - - // Debug - getSpatialIndexDebugInfo(): SpatialIndexDebugInfo | null } -export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { +export function useGraphNodeManager(graph: LGraph): GraphNodeManager { // Get layout mutations composable - const { moveNode, resizeNode, createNode, deleteNode, setSource } = - useLayoutMutations() + const { createNode, deleteNode, setSource } = useLayoutMutations() // Safe reactive data extracted from LiteGraph nodes const vueNodeData = reactive(new Map()) - const nodeState = reactive(new Map()) - const nodePositions = reactive(new Map()) - const nodeSizes = reactive( - new Map() - ) // Non-reactive storage for original LiteGraph nodes const nodeRefs = new Map() - // WeakMap for heavy data that auto-GCs when nodes are removed - const nodeMetadata = new WeakMap() - - // Performance tracking - const performanceMetrics = reactive({ - fps: 0, - frameTime: 0, - updateTime: 0, - nodeCount: 0, - culledCount: 0, - callbackUpdateCount: 0, - rafUpdateCount: 0, - adaptiveQuality: false - }) - - // Spatial indexing using QuadTree - const spatialIndex = new QuadTree( - { x: -10000, y: -10000, width: 20000, height: 20000 }, - { maxDepth: 6, maxItemsPerNode: 4 } - ) - let lastSpatialQueryTime = 0 - - // Spatial metrics - const spatialMetrics = reactive({ - queryTime: 0, - nodesInIndex: 0 - }) - - // Update batching - const pendingUpdates = new Set() - const criticalUpdates = new Set() - const lowPriorityUpdates = new Set() - 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 @@ -286,7 +176,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { ...currentData, widgets: updatedWidgets }) - performanceMetrics.callbackUpdateCount++ } catch (error) { // Ignore widget update errors to prevent cascade failures } @@ -356,71 +245,6 @@ export const 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 @@ -431,11 +255,6 @@ export const 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) } } @@ -451,163 +270,7 @@ export const 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 => { - 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) } /** @@ -629,32 +292,11 @@ export const 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, { @@ -698,9 +340,6 @@ export const 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) @@ -708,10 +347,6 @@ export const 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) { @@ -733,23 +368,9 @@ export const 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() } } @@ -845,18 +466,7 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { return { vueNodeData, - nodeState, - nodePositions, - nodeSizes, getNode, - setupEventListeners, - cleanup, - scheduleUpdate, - forceSync: syncWithGraph, - detectChangesInRAF, - getVisibleNodeIds, - performanceMetrics, - spatialMetrics, - getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo() + cleanup } } diff --git a/src/composables/graph/useViewportCulling.ts b/src/composables/graph/useViewportCulling.ts index f311af01c..6ebebeba3 100644 --- a/src/composables/graph/useViewportCulling.ts +++ b/src/composables/graph/useViewportCulling.ts @@ -6,21 +6,18 @@ * 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 { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' -import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { app as comfyApp } from '@/scripts/app' export function useViewportCulling() { const canvasStore = useCanvasStore() - const { shouldRenderVueNodes } = useVueFeatureFlags() - const { vueNodeData, nodeDataTrigger, nodeManager } = useVueNodeLifecycle() + const { vueNodeData, nodeManager } = useVueNodeLifecycle() const allNodes = computed(() => { - if (!shouldRenderVueNodes.value) return [] - void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes return Array.from(vueNodeData.value.values()) }) @@ -28,7 +25,7 @@ export function useViewportCulling() { * Update visibility of all nodes based on viewport * Queries DOM directly - no cache maintenance needed */ - const updateVisibility = () => { + function updateVisibility() { if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return const canvas = canvasStore.canvas @@ -70,31 +67,17 @@ export function useViewportCulling() { } } + const updateVisibilityDebounced = useThrottleFn(updateVisibility, 20) + // RAF throttling for smooth updates during continuous panning - let rafId: number | null = null - - /** - * Handle transform update - called by TransformPane event - * Uses RAF to batch updates for smooth performance - */ - const handleTransformUpdate = () => { - if (!shouldRenderVueNodes.value) return - - // Cancel previous RAF if still pending - if (rafId !== null) { - cancelAnimationFrame(rafId) - } - - // Schedule update in next animation frame - rafId = requestAnimationFrame(() => { - updateVisibility() - rafId = null + function handleTransformUpdate() { + requestAnimationFrame(async () => { + await updateVisibilityDebounced() }) } return { allNodes, - handleTransformUpdate, - updateVisibility + handleTransformUpdate } } diff --git a/src/composables/graph/useVueNodeLifecycle.ts b/src/composables/graph/useVueNodeLifecycle.ts index d2c1bcfcd..84e095b5f 100644 --- a/src/composables/graph/useVueNodeLifecycle.ts +++ b/src/composables/graph/useVueNodeLifecycle.ts @@ -1,20 +1,9 @@ -/** - * 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 { createSharedComposable } from '@vueuse/core' -import { computed, readonly, ref, shallowRef, watch } from 'vue' +import { 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' @@ -42,22 +31,10 @@ function useVueNodeLifecycleIndividual() { // Vue node data state const vueNodeData = ref>(new Map()) - const nodeState = ref>(new Map()) - const nodePositions = ref>( - new Map() - ) - const nodeSizes = ref>( - 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 @@ -70,10 +47,6 @@ 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) => ({ @@ -136,12 +109,6 @@ 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 @@ -235,13 +202,7 @@ function useVueNodeLifecycleIndividual() { return { vueNodeData, - nodeState, - nodePositions, - nodeSizes, - nodeDataTrigger: readonly(nodeDataTrigger), nodeManager: readonly(nodeManager), - detectChangesInRAF: readonly(detectChangesInRAF), - isNodeManagerReady, // Lifecycle methods initializeNodeManager, diff --git a/src/composables/node/useNodePricing.ts b/src/composables/node/useNodePricing.ts index e85e6adb6..91f957463 100644 --- a/src/composables/node/useNodePricing.ts +++ b/src/composables/node/useNodePricing.ts @@ -1548,6 +1548,71 @@ const apiNodeCosts: Record = }, 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 = { + '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 = { + '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' } } @@ -1647,7 +1712,9 @@ export const useNodePricing = () => { ByteDanceTextToVideoNode: ['model', 'duration', 'resolution'], ByteDanceImageToVideoNode: ['model', 'duration', 'resolution'], ByteDanceFirstLastFrameNode: ['model', 'duration', 'resolution'], - ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'] + ByteDanceImageReferenceNode: ['model', 'duration', 'resolution'], + WanTextToVideoApi: ['duration', 'size'], + WanImageToVideoApi: ['duration', 'resolution'] } return widgetMap[nodeType] || [] } diff --git a/src/constants/coreKeybindings.ts b/src/constants/coreKeybindings.ts index b4245f789..fe2bde835 100644 --- a/src/constants/coreKeybindings.ts +++ b/src/constants/coreKeybindings.ts @@ -122,14 +122,14 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ key: '.' }, commandId: 'Comfy.Canvas.FitView', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { key: 'p' }, commandId: 'Comfy.Canvas.ToggleSelected.Pin', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { @@ -137,7 +137,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ alt: true }, commandId: 'Comfy.Canvas.ToggleSelectedNodes.Collapse', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { @@ -145,7 +145,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ ctrl: true }, commandId: 'Comfy.Canvas.ToggleSelectedNodes.Bypass', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { @@ -153,7 +153,7 @@ export const CORE_KEYBINDINGS: Keybinding[] = [ ctrl: true }, commandId: 'Comfy.Canvas.ToggleSelectedNodes.Mute', - targetElementId: 'graph-canvas' + targetElementId: 'graph-canvas-container' }, { combo: { diff --git a/src/lib/litegraph/src/LGraphCanvas.ts b/src/lib/litegraph/src/LGraphCanvas.ts index 83ce47660..0cf68e3f7 100644 --- a/src/lib/litegraph/src/LGraphCanvas.ts +++ b/src/lib/litegraph/src/LGraphCanvas.ts @@ -4032,6 +4032,18 @@ 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() diff --git a/src/lib/litegraph/src/LLink.ts b/src/lib/litegraph/src/LLink.ts index 58ae4e090..71b41f23d 100644 --- a/src/lib/litegraph/src/LLink.ts +++ b/src/lib/litegraph/src/LLink.ts @@ -205,7 +205,7 @@ export class LLink implements LinkSegment, Serialisable { network: Pick, linkSegment: LinkSegment ): Reroute[] { - if (!linkSegment.parentId) return [] + if (linkSegment.parentId === undefined) return [] return network.reroutes.get(linkSegment.parentId)?.getReroutes() ?? [] } @@ -229,7 +229,7 @@ export class LLink implements LinkSegment, Serialisable { linkSegment: LinkSegment, rerouteId: RerouteId ): Reroute | null | undefined { - if (!linkSegment.parentId) return + if (linkSegment.parentId === undefined) return return network.reroutes .get(linkSegment.parentId) ?.findNextReroute(rerouteId) @@ -498,7 +498,7 @@ export class LLink implements LinkSegment, Serialisable { target_slot: this.target_slot, type: this.type } - if (this.parentId) copy.parentId = this.parentId + if (this.parentId !== undefined) copy.parentId = this.parentId return copy } } diff --git a/src/lib/litegraph/src/interfaces.ts b/src/lib/litegraph/src/interfaces.ts index 359ccfd5f..d8f623712 100644 --- a/src/lib/litegraph/src/interfaces.ts +++ b/src/lib/litegraph/src/interfaces.ts @@ -82,6 +82,7 @@ export interface Positionable extends Parent, HasBoundingRect { * @default 0,0 */ readonly pos: Point + readonly size?: Size /** true if this object is part of the selection, otherwise false. */ selected?: boolean diff --git a/src/lib/litegraph/src/litegraph.ts b/src/lib/litegraph/src/litegraph.ts index 46b094af0..098c30e7a 100644 --- a/src/lib/litegraph/src/litegraph.ts +++ b/src/lib/litegraph/src/litegraph.ts @@ -48,7 +48,6 @@ export interface LinkReleaseContextExtended { links: ConnectingLink[] } -// eslint-disable-next-line @typescript-eslint/no-empty-object-type export interface LiteGraphCanvasEvent extends CustomEvent {} export interface LGraphNodeConstructor { diff --git a/src/main.ts b/src/main.ts index 267de4f44..b15d4067f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,11 +2,6 @@ import { definePreset } from '@primevue/themes' import Aura from '@primevue/themes/aura' import * as Sentry from '@sentry/vue' import { initializeApp } from 'firebase/app' -import { - browserLocalPersistence, - browserSessionPersistence, - indexedDBLocalPersistence -} from 'firebase/auth' import { createPinia } from 'pinia' import 'primeicons/primeicons.css' import PrimeVue from 'primevue/config' @@ -14,7 +9,7 @@ import ConfirmationService from 'primevue/confirmationservice' import ToastService from 'primevue/toastservice' import Tooltip from 'primevue/tooltip' import { createApp } from 'vue' -import { VueFire, VueFireAuthWithDependencies } from 'vuefire' +import { VueFire, VueFireAuth } from 'vuefire' import { FIREBASE_CONFIG } from '@/config/firebase' import '@/lib/litegraph/public/css/litegraph.css' @@ -71,18 +66,6 @@ app .use(i18n) .use(VueFire, { firebaseApp, - modules: [ - // Configure Firebase Auth persistence: localStorage first, IndexedDB last. - // Localstorage is preferred to IndexedDB for mobile Safari compatibility. - VueFireAuthWithDependencies({ - dependencies: { - persistence: [ - browserLocalPersistence, - browserSessionPersistence, - indexedDBLocalPersistence - ] - } - }) - ] + modules: [VueFireAuth()] }) .mount('#vue-app') diff --git a/src/platform/settings/constants/coreSettings.ts b/src/platform/settings/constants/coreSettings.ts index d592a92f0..4adf2db9d 100644 --- a/src/platform/settings/constants/coreSettings.ts +++ b/src/platform/settings/constants/coreSettings.ts @@ -595,7 +595,7 @@ export const CORE_SETTINGS: SettingParams[] = [ migrateDeprecatedValue: (value: any[]) => { return value.map((keybinding) => { if (keybinding['targetSelector'] === '#graph-canvas') { - keybinding['targetElementId'] = 'graph-canvas' + keybinding['targetElementId'] = 'graph-canvas-container' } return keybinding }) diff --git a/src/renderer/core/canvas/injectionKeys.ts b/src/renderer/core/canvas/injectionKeys.ts deleted file mode 100644 index 9c0d25733..000000000 --- a/src/renderer/core/canvas/injectionKeys.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { InjectionKey, Ref } from 'vue' - -import type { NodeProgressState } from '@/schemas/apiSchema' - -/** - * Injection key for providing executing node IDs to Vue node components. - * Contains a reactive Set of currently executing node IDs (as strings). - */ -export const ExecutingNodeIdsKey: InjectionKey>> = - Symbol('executingNodeIds') - -/** - * Injection key for providing node progress states to Vue node components. - * Contains a reactive Record of node IDs to their current progress state. - */ -export const NodeProgressStatesKey: InjectionKey< - Ref> -> = Symbol('nodeProgressStates') diff --git a/src/renderer/core/layout/injectionKeys.ts b/src/renderer/core/layout/injectionKeys.ts index dd6efda21..8e0e0e1d6 100644 --- a/src/renderer/core/layout/injectionKeys.ts +++ b/src/renderer/core/layout/injectionKeys.ts @@ -1,6 +1,6 @@ import type { InjectionKey } from 'vue' -import type { Point } from '@/renderer/core/layout/types' +import type { useTransformState } from '@/renderer/core/layout/transform/useTransformState' /** * Lightweight, injectable transform state used by layout-aware components. @@ -21,29 +21,11 @@ import type { Point } from '@/renderer/core/layout/types' * const state = inject(TransformStateKey)! * const screen = state.canvasToScreen({ x: 100, y: 50 }) */ -interface TransformState { - /** Convert a screen-space point (CSS pixels) to canvas space. */ - screenToCanvas: (p: Point) => Point - /** Convert a canvas-space point to screen space (CSS pixels). */ - canvasToScreen: (p: Point) => Point - /** Current pan/zoom; `x`/`y` are offsets, `z` is scale. */ - camera?: { x: number; y: number; z: number } - /** - * Test whether a node's rectangle intersects the (expanded) viewport. - * Handy for viewport culling and lazy work. - * - * @param nodePos Top-left in canvas space `[x, y]` - * @param nodeSize Size in canvas units `[width, height]` - * @param viewport Screen-space viewport `{ width, height }` - * @param margin Optional fractional margin (e.g. `0.2` = 20%) - */ - isNodeInViewport?: ( - nodePos: ArrayLike, - nodeSize: ArrayLike, - viewport: { width: number; height: number }, - margin?: number - ) => boolean -} +interface TransformState + extends Pick< + ReturnType, + 'screenToCanvas' | 'canvasToScreen' | 'camera' | 'isNodeInViewport' + > {} export const TransformStateKey: InjectionKey = Symbol('transformState') diff --git a/src/renderer/core/layout/store/layoutStore.ts b/src/renderer/core/layout/store/layoutStore.ts index 254b27a2c..15d412814 100644 --- a/src/renderer/core/layout/store/layoutStore.ts +++ b/src/renderer/core/layout/store/layoutStore.ts @@ -1379,6 +1379,7 @@ class LayoutStoreImpl implements LayoutStore { this.spatialIndex.update(nodeId, bounds) ynode.set('bounds', bounds) + ynode.set('position', { x: bounds.x, y: bounds.y }) ynode.set('size', { width: bounds.width, height: bounds.height }) } }, this.currentActor) diff --git a/src/renderer/core/layout/sync/useSlotLayoutSync.ts b/src/renderer/core/layout/sync/useSlotLayoutSync.ts index 618d1857f..281199e8b 100644 --- a/src/renderer/core/layout/sync/useSlotLayoutSync.ts +++ b/src/renderer/core/layout/sync/useSlotLayoutSync.ts @@ -134,7 +134,11 @@ export function useSlotLayoutSync() { restoreHandlers = () => { graph.onNodeAdded = origNodeAdded || undefined graph.onNodeRemoved = origNodeRemoved || undefined - graph.onTrigger = origTrigger || undefined + // Only restore onTrigger if Vue nodes are not active + // Vue node manager sets its own onTrigger handler + if (!LiteGraph.vueNodesMode) { + graph.onTrigger = origTrigger || undefined + } graph.onAfterChange = origAfterChange || undefined } diff --git a/src/renderer/core/layout/transform/TransformPane.vue b/src/renderer/core/layout/transform/TransformPane.vue index 29abc1262..43cc0e328 100644 --- a/src/renderer/core/layout/transform/TransformPane.vue +++ b/src/renderer/core/layout/transform/TransformPane.vue @@ -1,7 +1,12 @@ @@ -119,6 +122,8 @@ import { downloadFile } from '@/base/common/downloadUtil' import { useCommandStore } from '@/stores/commandStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore' +import LODFallback from './LODFallback.vue' + interface ImagePreviewProps { /** Array of image URLs to display */ readonly imageUrls: readonly string[] diff --git a/src/renderer/extensions/vueNodes/components/InputSlot.vue b/src/renderer/extensions/vueNodes/components/InputSlot.vue index ef38c0754..1e8387335 100644 --- a/src/renderer/extensions/vueNodes/components/InputSlot.vue +++ b/src/renderer/extensions/vueNodes/components/InputSlot.vue @@ -10,12 +10,15 @@ /> - - {{ slotData.localized_name || slotData.name || `Input ${index}` }} - +
+ + {{ slotData.localized_name || slotData.name || `Input ${index}` }} + + +
@@ -38,6 +41,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' import { cn } from '@/utils/tailwindUtil' +import LODFallback from './LODFallback.vue' import SlotConnectionDot from './SlotConnectionDot.vue' interface InputSlotProps { diff --git a/src/renderer/extensions/vueNodes/components/LGraphNode.vue b/src/renderer/extensions/vueNodes/components/LGraphNode.vue index ce318e82e..08ff30d3b 100644 --- a/src/renderer/extensions/vueNodes/components/LGraphNode.vue +++ b/src/renderer/extensions/vueNodes/components/LGraphNode.vue @@ -23,7 +23,7 @@ bypassed, 'will-change-transform': isDragging }, - lodCssClass, + shouldHandleNodePointerEvents ? 'pointer-events-auto' : 'pointer-events-none' @@ -31,7 +31,7 @@ " :style="[ { - transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`, + transform: `translate(${position.x ?? 0}px, ${(position.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`, zIndex: zIndex }, dragStyle @@ -48,21 +48,18 @@
-