diff --git a/.github/workflows/i18n-custom-nodes.yaml b/.github/workflows/i18n-custom-nodes.yaml index a5617c1964..f46e9b7ac9 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 eaaaefee09..4f05a6d26c 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/browser_tests/README.md b/browser_tests/README.md index ede6a303a9..021c063ae8 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/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png b/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png index 90bc677fcf..2b89be5c53 100644 Binary files a/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png and b/browser_tests/tests/domWidget.spec.ts-snapshots/focus-mode-on-chromium-linux.png differ diff --git a/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png b/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png index 6e63295b85..a9e9926bfa 100644 Binary files a/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png and b/browser_tests/tests/rerouteNode.spec.ts-snapshots/reroute-inserted-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png index d4c32b4ea2..390c7fd3d3 100644 Binary files a/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png and b/browser_tests/tests/vueNodes/linkInteraction.spec.ts-snapshots/vue-node-dragging-link-chromium-linux.png differ diff --git a/browser_tests/tests/vueNodes/lod.spec.ts b/browser_tests/tests/vueNodes/lod.spec.ts new file mode 100644 index 0000000000..9011f91b10 --- /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 0000000000..8e93b88f3f 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 0000000000..5dfa61c196 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 0000000000..59802088fc 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/tsconfig.json b/browser_tests/tsconfig.json index f600c4a7f7..391298333b 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 0000000000..1c24810a8e --- /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 04f4b2578f..ab3bf09f54 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', diff --git a/package.json b/package.json index 9ab7a07033..9f0cc1ebd8 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/public/assets/images/comfy-brand-mark.svg b/public/assets/images/comfy-brand-mark.svg new file mode 100644 index 0000000000..9056ae149c --- /dev/null +++ b/public/assets/images/comfy-brand-mark.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/public/assets/images/nvidia-logo-square.jpg b/public/assets/images/nvidia-logo-square.jpg new file mode 100644 index 0000000000..c57f20b5df Binary files /dev/null and b/public/assets/images/nvidia-logo-square.jpg differ diff --git a/scripts/collect-i18n-node-defs.ts b/scripts/collect-i18n-node-defs.ts index ed443015ae..e167404213 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 3313333675..7b4ff8da11 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 0000000000..789e142b8d --- /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 cad8a1b3b0..21d526bac7 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/bottomPanel/tabs/terminal/BaseTerminal.vue b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue index 8e6a54e041..ca0dcf34ec 100644 --- a/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue +++ b/src/components/bottomPanel/tabs/terminal/BaseTerminal.vue @@ -1,5 +1,8 @@ - - diff --git a/src/components/install/HardwareOption.stories.ts b/src/components/install/HardwareOption.stories.ts new file mode 100644 index 0000000000..d830af49fa --- /dev/null +++ b/src/components/install/HardwareOption.stories.ts @@ -0,0 +1,73 @@ +// eslint-disable-next-line storybook/no-renderer-packages +import type { Meta, StoryObj } from '@storybook/vue3' + +import HardwareOption from './HardwareOption.vue' + +const meta: Meta = { + 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 + +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 + } +} diff --git a/src/components/install/HardwareOption.vue b/src/components/install/HardwareOption.vue new file mode 100644 index 0000000000..ae254fd8f3 --- /dev/null +++ b/src/components/install/HardwareOption.vue @@ -0,0 +1,55 @@ + + + diff --git a/src/components/install/InstallFooter.vue b/src/components/install/InstallFooter.vue new file mode 100644 index 0000000000..ef9ab698c9 --- /dev/null +++ b/src/components/install/InstallFooter.vue @@ -0,0 +1,79 @@ + + + diff --git a/src/components/install/InstallLocationPicker.stories.ts b/src/components/install/InstallLocationPicker.stories.ts new file mode 100644 index 0000000000..e6ef924ae0 --- /dev/null +++ b/src/components/install/InstallLocationPicker.stories.ts @@ -0,0 +1,148 @@ +// 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 = { + 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: '' } + } + ] +} + +export default meta +type Story = StoryObj + +// 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(['models', 'custom_nodes']) + + return { + args, + installPath, + pathError, + migrationSourcePath, + migrationItemIds + } + }, + template: ` +
+ +
+ ` + }) +} + +// 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(['models', 'custom_nodes']) + + return { + args, + installPath, + pathError, + migrationSourcePath, + migrationItemIds + } + }, + template: ` +
+ +
+ ` + }) +} + +// 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(['models', 'custom_nodes']) + + return { + args, + installPath, + pathError, + migrationSourcePath, + migrationItemIds + } + }, + template: ` +
+
+
Parent bg: neutral-950 (#0a0a0a)
+
Accordion content: bg-transparent
+
Migration options: bg-transparent + p-4 rounded-lg
+
+ +
+ ` + }) +} diff --git a/src/components/install/InstallLocationPicker.vue b/src/components/install/InstallLocationPicker.vue index 33b32a5f98..0e22f34a96 100644 --- a/src/components/install/InstallLocationPicker.vue +++ b/src/components/install/InstallLocationPicker.vue @@ -1,103 +1,215 @@ + + diff --git a/src/components/install/MigrationPicker.stories.ts b/src/components/install/MigrationPicker.stories.ts new file mode 100644 index 0000000000..ad09e1871b --- /dev/null +++ b/src/components/install/MigrationPicker.stories.ts @@ -0,0 +1,45 @@ +// 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 = { + 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: '' } + } + ] +} + +export default meta +type Story = StoryObj + +export const Default: Story = { + render: () => ({ + components: { MigrationPicker }, + setup() { + const sourcePath = ref('') + const migrationItemIds = ref([]) + return { sourcePath, migrationItemIds } + }, + template: + '' + }) +} diff --git a/src/components/install/MigrationPicker.vue b/src/components/install/MigrationPicker.vue index 934ffc2f32..ba542ca697 100644 --- a/src/components/install/MigrationPicker.vue +++ b/src/components/install/MigrationPicker.vue @@ -2,10 +2,6 @@
-

- {{ $t('install.migrateFromExistingInstallation') }} -

-

{{ $t('install.migrationSourcePathDescription') }}

@@ -13,7 +9,7 @@
-
+

{{ $t('install.selectItemsToMigrate') }}

diff --git a/src/components/install/MirrorsConfiguration.vue b/src/components/install/MirrorsConfiguration.vue deleted file mode 100644 index c4c3565d69..0000000000 --- a/src/components/install/MirrorsConfiguration.vue +++ /dev/null @@ -1,121 +0,0 @@ - - - diff --git a/src/components/install/mirror/MirrorItem.vue b/src/components/install/mirror/MirrorItem.vue index a4c5b563c4..3665d66c9f 100644 --- a/src/components/install/mirror/MirrorItem.vue +++ b/src/components/install/mirror/MirrorItem.vue @@ -1,10 +1,10 @@ - - -``` +The current CSS-based approach makes several deliberate trade-offs: -## Common LOD Patterns +### What We Optimize For -### Pattern 1: Essential vs. Nice-to-Have -```typescript -// Always show the main functionality -const showMainControl = computed(() => true) +1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs +2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated +3. **Simple widget development** - Widget authors don't need to implement LOD logic +4. **Reliable state preservation** - Widgets never lose state from unmounting -// Granular control with lodScore -const showLabels = computed(() => lodScore.value > 0.4) -const labelOpacity = computed(() => Math.max(0.3, lodScore.value)) +### What We Accept -// Simple control with lodLevel -const showExtras = computed(() => lodLevel.value === 'full') -``` +1. **Higher baseline memory usage** - All widgets remain mounted +2. **Less granular control** - Widgets can't optimize their own LOD behavior +3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden -### Pattern 2: Smooth Opacity Transitions -```typescript -// Gradually fade elements based on zoom -const labelOpacity = computed(() => { - // Fade in from zoom 0.3 to 0.6 - return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3)) -}) -``` +## Open Questions and Future Considerations -### Pattern 3: Progressive Detail -```typescript -const detailLevel = computed(() => { - if (lodScore.value < 0.3) return 'none' - if (lodScore.value < 0.6) return 'basic' - if (lodScore.value < 0.8) return 'standard' - return 'full' -}) -``` +### Should widgets have any LOD control? -## LOD Guidelines by Widget Type +The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions: -### Text Input Widgets -- **Always show**: The input field itself -- **Medium zoom**: Show label -- **High zoom**: Show placeholder text, validation messages -- **Full zoom**: Show character count, format hints +**Scenario:** A widget renders a complex 3D scene or runs expensive computations +**Current behavior:** Hidden via CSS but still mounted +**Question:** Should such widgets be able to opt into unmounting at distance? -### Button Widgets -- **Always show**: The button -- **Medium zoom**: Show button text -- **High zoom**: Show button description -- **Full zoom**: Show keyboard shortcuts, tooltips +The challenge is that introducing selective unmounting would require: -### Selection Widgets (Dropdown, Radio) -- **Always show**: The current selection -- **Medium zoom**: Show option labels -- **High zoom**: Show all options when expanded -- **Full zoom**: Show option descriptions, icons +- Maintaining widget state across mount/unmount cycles +- Accepting the performance cost of remounting when zooming in +- Adding complexity to the widget API -### Complex Widgets (Color Picker, File Browser) -- **Always show**: Simplified representation (color swatch, filename) -- **Medium zoom**: Show basic controls -- **High zoom**: Show full interface -- **Full zoom**: Show advanced options, previews +### Could we reduce GPU texture size? -## Design Collaboration Guidelines +Since texture dimensions are the limiting factor, could we: -### For Designers -When designing widgets, consider creating variants for different zoom levels: +- Use multiple compositor layers for different regions (chunk the transformpane)? +- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom. -1. **Minimal Design** (far away view) - - Essential elements only - - Higher contrast for visibility - - Simplified shapes and fewer details +These approaches would require significant architectural changes and might introduce their own performance trade-offs. -2. **Standard Design** (normal view) - - Balanced detail and simplicity - - Clear labels and readable text - - Good for most use cases +### Is there a hybrid approach? -3. **Full Detail Design** (close-up view) - - All labels, descriptions, and help text - - Rich visual effects and polish - - Maximum information density +Could we identify specific threshold scenarios where reactive LOD makes sense? -### Design Handoff Checklist -- [ ] Specify which elements are essential vs. nice-to-have -- [ ] Define minimum readable sizes for text elements -- [ ] Provide simplified versions for distant viewing -- [ ] Consider color contrast at different opacity levels -- [ ] Test designs at multiple zoom levels +- When node count is low (< 50 nodes) +- For specifically registered "expensive" widgets +- At extreme zoom levels only -## Testing Your LOD Implementation +## Implementation Guidelines -### Manual Testing -1. Create a workflow with your widget -2. Zoom out until nodes are very small -3. Verify essential functionality still works -4. Zoom in gradually and check that details appear smoothly -5. Test performance with 50+ nodes containing your widget +Given the current architecture, here's how to work within the system: -### Performance Considerations -- Avoid complex calculations in LOD computed properties -- Use `v-if` instead of `v-show` for elements that won't render -- Consider using `v-memo` for expensive widget content -- Test on lower-end devices +### For Widget Developers -### Common Mistakes -❌ **Don't**: Hide the main widget functionality at any zoom level -❌ **Don't**: Use complex animations that trigger at every zoom change -❌ **Don't**: Make LOD thresholds too sensitive (causes flickering) -❌ **Don't**: Forget to test with real content and edge cases +1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup +2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes +3. **Minimize background processing** - Assume your widget is always running +4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out -✅ **Do**: Keep essential functionality always visible -✅ **Do**: Use smooth transitions between LOD levels -✅ **Do**: Test with varying content lengths and types -✅ **Do**: Consider accessibility at all zoom levels +### For System Architects -## Getting Help +1. **Monitor GPU memory usage** - The single texture approach has memory implications +2. **Consider viewport culling** - Not rendering off-screen nodes could reduce texture size +3. **Profile real-world workflows** - Theoretical performance differs from actual usage patterns +4. **Document the architecture clearly** - The non-obvious performance characteristics need explanation -- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples -- Ask in the ComfyUI frontend Discord for LOD implementation questions -- Test your changes with the LOD debug panel (top-right in GraphCanvas) -- Profile performance impact using browser dev tools \ No newline at end of file +## Conclusion + +The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities. + +The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale. + +The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions. diff --git a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts index e3ab1c66cd..6adee1e894 100644 --- a/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts +++ b/src/renderer/extensions/vueNodes/composables/useNodeEventHandlers.ts @@ -82,7 +82,6 @@ function useNodeEventHandlersIndividual() { const currentCollapsed = node.flags?.collapsed ?? false if (currentCollapsed !== collapsed) { node.collapse() - nodeManager.value.scheduleUpdate(nodeId, 'critical') } } diff --git a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts index 89718eb8dd..60e5a7fd8d 100644 --- a/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts +++ b/src/renderer/extensions/vueNodes/layout/useNodeLayout.ts @@ -1,5 +1,11 @@ import { storeToRefs } from 'pinia' -import { type MaybeRefOrGetter, computed, inject, toValue } from 'vue' +import { + type CSSProperties, + type MaybeRefOrGetter, + computed, + inject, + toValue +} from 'vue' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' @@ -182,14 +188,16 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter) { endDrag, // Computed styles for Vue templates - nodeStyle: computed(() => ({ - position: 'absolute' as const, - left: `${position.value.x}px`, - top: `${position.value.y}px`, - width: `${size.value.width}px`, - height: `${size.value.height}px`, - zIndex: zIndex.value, - cursor: isDragging ? 'grabbing' : 'grab' - })) + nodeStyle: computed( + (): CSSProperties => ({ + position: 'absolute' as const, + left: `${position.value.x}px`, + top: `${position.value.y}px`, + width: `${size.value.width}px`, + height: `${size.value.height}px`, + zIndex: zIndex.value, + cursor: isDragging ? 'grabbing' : 'grab' + }) + ) } } diff --git a/src/renderer/extensions/vueNodes/lod/useLOD.ts b/src/renderer/extensions/vueNodes/lod/useLOD.ts index 87c1bb8658..2f59ba020b 100644 --- a/src/renderer/extensions/vueNodes/lod/useLOD.ts +++ b/src/renderer/extensions/vueNodes/lod/useLOD.ts @@ -2,186 +2,33 @@ * Level of Detail (LOD) composable for Vue-based node rendering * * Provides dynamic quality adjustment based on zoom level to maintain - * performance with large node graphs. Uses zoom thresholds to determine - * how much detail to render for each node component. - * - * ## LOD Levels - * - * - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content - * - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots - * - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots - * - * ## Performance Benefits - * - * - Reduces DOM element count by up to 80% at low zoom levels - * - Minimizes layout calculations and paint operations - * - Enables smooth performance with 1000+ nodes - * - Maintains visual fidelity when detail is actually visible - * - * @example - * ```typescript - * const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef) - * - * // In template - * - * - * ``` - */ -import { type MaybeRefOrGetter, computed, readonly, toRef } from 'vue' + * performance with large node graphs. Uses zoom threshold based on DPR + * to determine how much detail to render for each node component. + * Default minFontSize = 8px + * Default zoomThreshold = 0.57 (On a DPR = 1 monitor) + **/ +import { useDevicePixelRatio } from '@vueuse/core' +import { computed } from 'vue' -export enum LODLevel { - MINIMAL = 'minimal', // zoom <= 0.4 - REDUCED = 'reduced', // 0.4 < zoom <= 0.8 - FULL = 'full' // zoom > 0.8 +import { useSettingStore } from '@/platform/settings/settingStore' + +interface Camera { + z: number // zoom level } -interface LODConfig { - renderWidgets: boolean - renderSlots: boolean - renderContent: boolean - renderSlotLabels: boolean - renderWidgetLabels: boolean - cssClass: string -} +export function useLOD(camera: Camera) { + const isLOD = computed(() => { + const { pixelRatio } = useDevicePixelRatio() + const baseFontSize = 14 + const dprAdjustment = Math.sqrt(pixelRatio.value) -// LOD configuration for each level -const LOD_CONFIGS: Record = { - [LODLevel.FULL]: { - renderWidgets: true, - renderSlots: true, - renderContent: true, - renderSlotLabels: true, - renderWidgetLabels: true, - cssClass: 'lg-node--lod-full' - }, - [LODLevel.REDUCED]: { - renderWidgets: true, - renderSlots: true, - renderContent: false, - renderSlotLabels: false, - renderWidgetLabels: false, - cssClass: 'lg-node--lod-reduced' - }, - [LODLevel.MINIMAL]: { - renderWidgets: false, - renderSlots: false, - renderContent: false, - renderSlotLabels: false, - renderWidgetLabels: false, - cssClass: 'lg-node--lod-minimal' - } -} + const settingStore = useSettingStore() + const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8 + const threshold = + Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86 -/** - * Create LOD (Level of Detail) state based on zoom level - * - * @param zoomRef - Reactive reference to current zoom level (camera.z) - * @returns LOD state and configuration - */ -export function useLOD(zoomRefMaybe: MaybeRefOrGetter) { - const zoomRef = toRef(zoomRefMaybe) - // Continuous LOD score (0-1) for smooth transitions - const lodScore = computed(() => { - const zoom = zoomRef.value - return Math.max(0, Math.min(1, zoom)) + return camera.z < threshold }) - // Determine current LOD level based on zoom - const lodLevel = computed(() => { - const zoom = zoomRef.value - - if (zoom > 0.8) return LODLevel.FULL - if (zoom > 0.4) return LODLevel.REDUCED - return LODLevel.MINIMAL - }) - - // Get configuration for current LOD level - const lodConfig = computed(() => LOD_CONFIGS[lodLevel.value]) - - // Convenience computed properties for common rendering decisions - const shouldRenderWidgets = computed(() => lodConfig.value.renderWidgets) - const shouldRenderSlots = computed(() => lodConfig.value.renderSlots) - const shouldRenderContent = computed(() => lodConfig.value.renderContent) - const shouldRenderSlotLabels = computed( - () => lodConfig.value.renderSlotLabels - ) - const shouldRenderWidgetLabels = computed( - () => lodConfig.value.renderWidgetLabels - ) - - // CSS class for styling based on LOD level - const lodCssClass = computed(() => lodConfig.value.cssClass) - - // Get essential widgets for reduced LOD (only interactive controls) - const getEssentialWidgets = (widgets: unknown[]): unknown[] => { - if (lodLevel.value === LODLevel.FULL) return widgets - if (lodLevel.value === LODLevel.MINIMAL) return [] - - // For reduced LOD, filter to essential widget types only - return widgets.filter((widget: any) => { - const type = widget?.type?.toLowerCase() - return [ - 'combo', - 'select', - 'toggle', - 'boolean', - 'slider', - 'number' - ].includes(type) - }) - } - - // Performance metrics for debugging - const lodMetrics = computed(() => ({ - level: lodLevel.value, - zoom: zoomRef.value, - widgetCount: shouldRenderWidgets.value ? 'full' : 'none', - slotCount: shouldRenderSlots.value ? 'full' : 'none' - })) - - return { - // Core LOD state - lodLevel: readonly(lodLevel), - lodConfig: readonly(lodConfig), - lodScore: readonly(lodScore), - - // Rendering decisions - shouldRenderWidgets, - shouldRenderSlots, - shouldRenderContent, - shouldRenderSlotLabels, - shouldRenderWidgetLabels, - - // Styling - lodCssClass, - - // Utilities - getEssentialWidgets, - lodMetrics - } -} - -/** - * Get LOD level thresholds for configuration or debugging - */ -export const LOD_THRESHOLDS = { - FULL_THRESHOLD: 0.8, - REDUCED_THRESHOLD: 0.4, - MINIMAL_THRESHOLD: 0.0 -} as const - -/** - * Check if zoom level supports a specific feature - */ -export function supportsFeatureAtZoom( - zoom: number, - feature: keyof LODConfig -): boolean { - const level = - zoom > 0.8 - ? LODLevel.FULL - : zoom > 0.4 - ? LODLevel.REDUCED - : LODLevel.MINIMAL - return LOD_CONFIGS[level][feature] as boolean + return { isLOD } } diff --git a/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts b/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts index 8fc82147ac..cb997f2360 100644 --- a/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts +++ b/src/renderer/extensions/vueNodes/preview/useNodePreviewState.ts @@ -7,7 +7,6 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore' export const useNodePreviewState = ( nodeIdMaybe: MaybeRefOrGetter, options?: { - isMinimalLOD?: Ref isCollapsed?: Ref } ) => { @@ -32,14 +31,10 @@ export const useNodePreviewState = ( }) const shouldShowPreviewImg = computed(() => { - if (!options?.isMinimalLOD || !options?.isCollapsed) { + if (!options?.isCollapsed) { return hasPreview.value } - return ( - !options.isMinimalLOD.value && - !options.isCollapsed.value && - hasPreview.value - ) + return !options.isCollapsed.value && hasPreview.value }) return { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue index 35950aa5cb..f165b0a687 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetMarkdown.vue @@ -6,7 +6,7 @@
@@ -28,6 +28,7 @@ @click.stop @keydown.stop /> +
@@ -39,6 +40,8 @@ import { useStringWidgetValue } from '@/composables/graph/useWidgetValue' import type { SimplifiedWidget } from '@/types/simplifiedWidget' import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil' +import LODFallback from '../../components/LODFallback.vue' + const props = defineProps<{ widget: SimplifiedWidget modelValue: string diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue index 157da563ed..bd7ac78181 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetTextarea.vue @@ -1,14 +1,17 @@