Merge branch 'main' into feat/new-workflow-templates

This commit is contained in:
Johnpaul Chiwetelu
2025-09-24 20:08:40 +01:00
committed by GitHub
91 changed files with 3036 additions and 2739 deletions

View File

@@ -32,11 +32,10 @@ jobs:
with: with:
repository: Comfy-Org/ComfyUI_frontend repository: Comfy-Org/ComfyUI_frontend
path: ComfyUI_frontend path: ComfyUI_frontend
- name: Checkout ComfyUI_devtools - name: Copy ComfyUI_devtools from frontend repo
uses: actions/checkout@v4 run: |
with: mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
repository: Comfy-Org/ComfyUI_devtools cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
path: ComfyUI/custom_nodes/ComfyUI_devtools
- name: Checkout custom node repository - name: Checkout custom node repository
uses: actions/checkout@v4 uses: actions/checkout@v4
with: with:

View File

@@ -27,12 +27,10 @@ jobs:
repository: 'Comfy-Org/ComfyUI_frontend' repository: 'Comfy-Org/ComfyUI_frontend'
path: 'ComfyUI_frontend' path: 'ComfyUI_frontend'
- name: Checkout ComfyUI_devtools - name: Copy ComfyUI_devtools from frontend repo
uses: actions/checkout@v4 run: |
with: mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
repository: 'Comfy-Org/ComfyUI_devtools' cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@v4 uses: pnpm/action-setup@v4

View File

@@ -16,9 +16,14 @@ Without this flag, parallel tests will conflict and fail randomly.
### ComfyUI devtools ### ComfyUI devtools
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> 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._ _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 ### Node.js & Playwright Prerequisites
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver: Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 53 KiB

After

Width:  |  Height:  |  Size: 54 KiB

View File

@@ -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()
})
})

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -3,7 +3,6 @@
"compilerOptions": { "compilerOptions": {
/* Test files should not be compiled */ /* Test files should not be compiled */
"noEmit": true, "noEmit": true,
// "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"resolveJsonModule": true "resolveJsonModule": true

17
build/tsconfig.json Normal file
View File

@@ -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"
]
}

View File

@@ -33,7 +33,13 @@ export default defineConfig([
}, },
parserOptions: { parserOptions: {
parser: tseslint.parser, parser: tseslint.parser,
projectService: true, projectService: {
allowDefaultProject: [
'vite.config.mts',
'vite.electron.config.mts',
'vite.types.config.mts'
]
},
tsConfigRootDir: import.meta.dirname, tsConfigRootDir: import.meta.dirname,
ecmaVersion: 2020, ecmaVersion: 2020,
sourceType: 'module', sourceType: 'module',

View File

@@ -1,7 +1,7 @@
{ {
"name": "@comfyorg/comfyui-frontend", "name": "@comfyorg/comfyui-frontend",
"private": true, "private": true,
"version": "1.28.0", "version": "1.28.1",
"type": "module", "type": "module",
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend", "repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
"homepage": "https://comfy.org", "homepage": "https://comfy.org",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

View File

@@ -9,9 +9,18 @@ import { normalizeI18nKey } from '../src/utils/formatUtil'
const localePath = './src/locales/en/main.json' const localePath = './src/locales/en/main.json'
const nodeDefsPath = './src/locales/en/nodeDefs.json' const nodeDefsPath = './src/locales/en/nodeDefs.json'
interface WidgetInfo {
name?: string
label?: string
}
interface WidgetLabels {
[key: string]: Record<string, { name: string }>
}
test('collect-i18n-node-defs', async ({ comfyPage }) => { test('collect-i18n-node-defs', async ({ comfyPage }) => {
// Mock view route // Mock view route
comfyPage.page.route('**/view**', async (route) => { await comfyPage.page.route('**/view**', async (route) => {
await route.fulfill({ await route.fulfill({
body: JSON.stringify({}) body: JSON.stringify({})
}) })
@@ -20,6 +29,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const nodeDefs: ComfyNodeDefImpl[] = ( const nodeDefs: ComfyNodeDefImpl[] = (
Object.values( Object.values(
await comfyPage.page.evaluate(async () => { await comfyPage.page.evaluate(async () => {
// @ts-expect-error - app is dynamically added to window
const api = window['app'].api as ComfyApi const api = window['app'].api as ComfyApi
return await api.getNodeDefs() return await api.getNodeDefs()
}) })
@@ -52,7 +62,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
) )
async function extractWidgetLabels() { async function extractWidgetLabels() {
const nodeLabels = {} const nodeLabels: WidgetLabels = {}
for (const nodeDef of nodeDefs) { for (const nodeDef of nodeDefs) {
const inputNames = Object.values(nodeDef.inputs).map( const inputNames = Object.values(nodeDef.inputs).map(
@@ -65,12 +75,15 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
const widgetsMappings = await comfyPage.page.evaluate( const widgetsMappings = await comfyPage.page.evaluate(
(args) => { (args) => {
const [nodeName, displayName, inputNames] = args const [nodeName, displayName, inputNames] = args
// @ts-expect-error - LiteGraph is dynamically added to window
const node = window['LiteGraph'].createNode(nodeName, displayName) const node = window['LiteGraph'].createNode(nodeName, displayName)
if (!node.widgets?.length) return {} if (!node.widgets?.length) return {}
return Object.fromEntries( return Object.fromEntries(
node.widgets node.widgets
.filter((w) => w?.name && !inputNames.includes(w.name)) .filter(
.map((w) => [w.name, w.label]) (w: WidgetInfo) => w?.name && !inputNames.includes(w.name)
)
.map((w: WidgetInfo) => [w.name, w.label])
) )
}, },
[nodeDef.name, nodeDef.display_name, inputNames] [nodeDef.name, nodeDef.display_name, inputNames]

View File

@@ -72,7 +72,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
const relativePath = file.replace(srcLocaleDir, '') const relativePath = file.replace(srcLocaleDir, '')
const targetPath = join(tempBaseDir, relativePath) const targetPath = join(tempBaseDir, relativePath)
ensureDir(dirname(targetPath)) ensureDir(dirname(targetPath))
writeFileSync(targetPath, readFileSync(file)) writeFileSync(targetPath, readFileSync(file, 'utf8'))
} }
console.log('Captured current locale files to temp/base/') console.log('Captured current locale files to temp/base/')
} }

14
scripts/tsconfig.json Normal file
View File

@@ -0,0 +1,14 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
/* Script files configuration */
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": [
"**/*.ts"
]
}

View File

@@ -929,48 +929,6 @@ audio.comfy-audio.empty-audio-widget {
} }
/* End of [Desktop] Electron window specific styles */ /* 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 { .lg-node {
/* Disable text selection on all nodes */ /* Disable text selection on all nodes */
@@ -996,23 +954,52 @@ audio.comfy-audio.empty-audio-widget {
will-change: transform; will-change: transform;
} }
/* Global performance optimizations for LOD */ /* START LOD specific styles */
.lg-node--lod-minimal, /* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
.lg-node--lod-reduced {
/* Remove ALL expensive paint effects */ .isLOD .lg-node {
box-shadow: none !important; box-shadow: none;
filter: none !important; filter: none;
backdrop-filter: none !important; backdrop-filter: none;
text-shadow: none !important; text-shadow: none;
-webkit-mask-image: none !important; -webkit-mask-image: none;
mask-image: none !important; mask-image: none;
clip-path: none !important; clip-path: none;
background-image: none;
text-rendering: optimizeSpeed;
border-radius: 0;
contain: layout style;
transition: none;
} }
/* Reduce paint complexity for minimal LOD */ .isLOD .lg-node > * {
.lg-node--lod-minimal { pointer-events: none;
/* Skip complex borders */
border-radius: 0 !important;
/* Use solid colors only */
background-image: none !important;
} }
.lod-toggle {
visibility: visible;
}
.isLOD .lod-toggle {
visibility: hidden;
}
.lod-fallback {
display: none;
}
.isLOD .lod-fallback {
display: block;
}
.isLOD .image-preview img {
image-rendering: pixelated;
}
.isLOD .slot-dot {
border-radius: 0;
}
/* END LOD specific styles */

View File

@@ -1,5 +1,8 @@
<template> <template>
<div ref="rootEl" class="relative overflow-hidden h-full w-full bg-black"> <div
ref="rootEl"
class="relative overflow-hidden h-full w-full bg-neutral-900"
>
<div class="p-terminal rounded-none h-full w-full p-2"> <div class="p-terminal rounded-none h-full w-full p-2">
<div ref="terminalEl" class="h-full terminal-host" /> <div ref="terminalEl" class="h-full terminal-host" />
</div> </div>
@@ -98,12 +101,13 @@ onUnmounted(() => {
</script> </script>
<style scoped> <style scoped>
@reference '../../../../assets/css/style.css';
:deep(.p-terminal) .xterm { :deep(.p-terminal) .xterm {
overflow-x: auto; @apply overflow-hidden;
} }
:deep(.p-terminal) .xterm-screen { :deep(.p-terminal) .xterm-screen {
background-color: black; @apply bg-neutral-900 overflow-hidden;
overflow-y: hidden;
} }
</style> </style>

View File

@@ -0,0 +1,71 @@
<template>
<div :class="wrapperClass">
<div class="grid grid-rows-2 gap-8">
<!-- Top container: Logo -->
<div class="flex items-end justify-center">
<img
src="/assets/images/comfy-brand-mark.svg"
:alt="t('g.logoAlt')"
class="w-60"
/>
</div>
<!-- Bottom container: Progress and text -->
<div class="flex flex-col items-center justify-center gap-4">
<ProgressBar
v-if="!hideProgress"
:mode="progressMode"
:value="progressPercentage ?? 0"
:show-value="false"
class="w-90 h-2 mt-8"
:pt="{ value: { class: 'bg-brand-yellow' } }"
/>
<h1 v-if="title" class="font-inter font-bold text-3xl text-neutral-300">
{{ title }}
</h1>
<p v-if="statusText" class="text-lg text-neutral-400">
{{ statusText }}
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import ProgressBar from 'primevue/progressbar'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
/** Props for the StartupDisplay component */
interface StartupDisplayProps {
/** Progress: 0-100 for determinate, undefined for indeterminate */
progressPercentage?: number
/** Main title text */
title?: string
/** Status text shown below the title */
statusText?: string
/** Hide the progress bar */
hideProgress?: boolean
/** Use full screen wrapper (default: true) */
fullScreen?: boolean
}
const {
progressPercentage,
title,
statusText,
hideProgress = false,
fullScreen = true
} = defineProps<StartupDisplayProps>()
const progressMode = computed(() =>
progressPercentage === undefined ? 'indeterminate' : 'determinate'
)
const wrapperClass = computed(() =>
fullScreen
? 'flex items-center justify-center min-h-screen'
: 'flex items-center justify-center'
)
</script>

View File

@@ -43,8 +43,6 @@
v-for="nodeData in allNodes" v-for="nodeData in allNodes"
:key="nodeData.id" :key="nodeData.id"
:node-data="nodeData" :node-data="nodeData"
:position="nodePositions.get(nodeData.id)"
:size="nodeSizes.get(nodeData.id)"
:readonly="false" :readonly="false"
:error=" :error="
executionStore.lastExecutionError?.node_id === nodeData.id executionStore.lastExecutionError?.node_id === nodeData.id
@@ -189,15 +187,8 @@ watch(
} }
) )
const nodePositions = vueNodeLifecycle.nodePositions
const nodeSizes = vueNodeLifecycle.nodeSizes
const allNodes = viewportCulling.allNodes const allNodes = viewportCulling.allNodes
const handleTransformUpdate = viewportCulling.handleTransformUpdate
const handleTransformUpdate = () => {
viewportCulling.handleTransformUpdate()
// TODO: Fix paste position sync in separate PR
vueNodeLifecycle.detectChangesInRAF.value()
}
watchEffect(() => { watchEffect(() => {
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated') nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')

View File

@@ -33,9 +33,11 @@ const tooltipText = ref('')
const left = ref<string>() const left = ref<string>()
const top = ref<string>() const top = ref<string>()
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 if (!tooltip) return
left.value = comfyApp.canvas.mouse[0] + 'px' 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 { canvas } = comfyApp
const node = canvas.node_over const node = canvas?.node_over
if (!node) return if (!node) return
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 } const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }

View File

@@ -49,11 +49,11 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import Button from 'primevue/button' import Button from 'primevue/button'
import Popover from 'primevue/popover' import Popover from 'primevue/popover'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import { import {
forceCloseMoreOptionsSignal, forceCloseMoreOptionsSignal,
moreOptionsOpen, moreOptionsOpen,
@@ -152,9 +152,7 @@ const repositionPopover = () => {
} }
} }
const { startSync, stopSync } = useCanvasTransformSync(repositionPopover, { const { resume: startSync, pause: stopSync } = useRafFn(repositionPopover)
autoStart: false
})
function openPopover(triggerEvent?: Event): boolean { function openPopover(triggerEvent?: Event): boolean {
const el = getButtonEl() const el = getButtonEl()

View File

@@ -10,14 +10,14 @@
</p> </p>
</div> </div>
<div class="flex flex-col bg-neutral-800 p-4 rounded-lg"> <div class="flex flex-col bg-neutral-800 p-4 rounded-lg text-sm">
<!-- Auto Update Setting --> <!-- Auto Update Setting -->
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
<div class="flex-1"> <div class="flex-1">
<h3 class="text-lg font-medium text-neutral-100"> <h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.autoUpdate') }} {{ $t('install.settings.autoUpdate') }}
</h3> </h3>
<p class="text-sm text-neutral-400 mt-1"> <p class="text-neutral-400 mt-1">
{{ $t('install.settings.autoUpdateDescription') }} {{ $t('install.settings.autoUpdateDescription') }}
</p> </p>
</div> </div>
@@ -32,14 +32,10 @@
<h3 class="text-lg font-medium text-neutral-100"> <h3 class="text-lg font-medium text-neutral-100">
{{ $t('install.settings.allowMetrics') }} {{ $t('install.settings.allowMetrics') }}
</h3> </h3>
<p class="text-sm text-neutral-400 mt-1"> <p class="text-neutral-400">
{{ $t('install.settings.allowMetricsDescription') }} {{ $t('install.settings.allowMetricsDescription') }}
</p> </p>
<a <a href="#" @click.prevent="showMetricsInfo">
href="#"
class="text-sm text-blue-400 hover:text-blue-300 mt-1 inline-block"
@click.prevent="showMetricsInfo"
>
{{ $t('install.settings.learnMoreAboutData') }} {{ $t('install.settings.learnMoreAboutData') }}
</a> </a>
</div> </div>
@@ -51,7 +47,9 @@
<Dialog <Dialog
v-model:visible="showDialog" v-model:visible="showDialog"
modal modal
dismissable-mask
:header="$t('install.settings.dataCollectionDialog.title')" :header="$t('install.settings.dataCollectionDialog.title')"
class="select-none"
> >
<div class="text-neutral-300"> <div class="text-neutral-300">
<h4 class="font-medium mb-2"> <h4 class="font-medium mb-2">
@@ -110,11 +108,7 @@
</ul> </ul>
<div class="mt-4"> <div class="mt-4">
<a <a href="https://comfy.org/privacy" target="_blank">
href="https://comfy.org/privacy"
target="_blank"
class="text-blue-400 hover:text-blue-300 underline"
>
{{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }} {{ $t('install.settings.dataCollectionDialog.viewFullPolicy') }}
</a> </a>
</div> </div>

View File

@@ -1,130 +1,66 @@
<template> <template>
<div class="flex flex-col gap-6 w-[600px] h-[30rem] select-none"> <div
<!-- Installation Path Section --> class="grid grid-rows-[1fr_auto_auto_1fr] w-full max-w-3xl mx-auto h-[40rem] select-none"
<div class="grow flex flex-col gap-4 text-neutral-300"> >
<h2 class="text-2xl font-semibold text-neutral-100"> <h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.gpuSelection.selectGpu') }} {{ $t('install.gpuPicker.title') }}
</h2> </h2>
<p class="m-1 text-neutral-400"> <!-- GPU Selection buttons - takes up remaining space and centers content -->
{{ $t('install.gpuSelection.selectGpuDescription') }}: <div class="flex-1 flex gap-8 justify-center items-center">
</p> <!-- Apple Metal / NVIDIA -->
<HardwareOption
v-if="platform === 'darwin'"
:image-path="'/assets/images/apple-mps-logo.png'"
placeholder-text="Apple Metal"
subtitle="Apple Metal"
:value="'mps'"
:selected="selected === 'mps'"
:recommended="true"
@click="pickGpu('mps')"
/>
<HardwareOption
v-else
:image-path="'/assets/images/nvidia-logo-square.jpg'"
placeholder-text="NVIDIA"
:subtitle="$t('install.gpuPicker.nvidiaSubtitle')"
:value="'nvidia'"
:selected="selected === 'nvidia'"
:recommended="true"
@click="pickGpu('nvidia')"
/>
<!-- CPU -->
<HardwareOption
placeholder-text="CPU"
:subtitle="$t('install.gpuPicker.cpuSubtitle')"
:value="'cpu'"
:selected="selected === 'cpu'"
@click="pickGpu('cpu')"
/>
<!-- Manual Install -->
<HardwareOption
placeholder-text="Manual Install"
:subtitle="$t('install.gpuPicker.manualSubtitle')"
:value="'unsupported'"
:selected="selected === 'unsupported'"
@click="pickGpu('unsupported')"
/>
</div>
<!-- GPU Selection buttons --> <div class="pt-12 px-24 h-16">
<div <div v-show="showRecommendedBadge" class="flex items-center gap-2">
class="flex gap-2 text-center transition-opacity" <Tag
:class="{ selected: selected }" :value="$t('install.gpuPicker.recommended')"
> class="bg-neutral-300 text-neutral-900 rounded-full text-sm font-bold px-2 py-[1px]"
<!-- NVIDIA --> />
<div <i-lucide:badge-check class="text-neutral-300 text-lg" />
v-if="platform !== 'darwin'"
class="gpu-button"
:class="{ selected: selected === 'nvidia' }"
role="button"
@click="pickGpu('nvidia')"
>
<img
class="m-12"
alt="NVIDIA logo"
width="196"
height="32"
src="/assets/images/nvidia-logo.svg"
/>
</div>
<!-- MPS -->
<div
v-if="platform === 'darwin'"
class="gpu-button"
:class="{ selected: selected === 'mps' }"
role="button"
@click="pickGpu('mps')"
>
<img
class="rounded-lg hover-brighten"
alt="Apple Metal Performance Shaders Logo"
width="292"
ratio
src="/assets/images/apple-mps-logo.png"
/>
</div>
<!-- Manual configuration -->
<div
class="gpu-button"
:class="{ selected: selected === 'unsupported' }"
role="button"
@click="pickGpu('unsupported')"
>
<img
class="m-12"
alt="Manual configuration"
width="196"
src="/assets/images/manual-configuration.svg"
/>
</div>
</div>
<!-- Details on selected GPU -->
<p v-if="selected === 'nvidia'" class="m-1">
<Tag icon="pi pi-check" severity="success" :value="'CUDA'" />
{{ $t('install.gpuSelection.nvidiaDescription') }}
</p>
<p v-if="selected === 'mps'" class="m-1">
<Tag icon="pi pi-check" severity="success" :value="'MPS'" />
{{ $t('install.gpuSelection.mpsDescription') }}
</p>
<div v-if="selected === 'unsupported'" class="text-neutral-300">
<p class="m-1">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
{{ $t('install.gpuSelection.customSkipsPython') }}
</p>
<ul>
<li>
<strong>
{{ $t('install.gpuSelection.customComfyNeedsPython') }}
</strong>
</li>
<li>{{ $t('install.gpuSelection.customManualVenv') }}</li>
<li>{{ $t('install.gpuSelection.customInstallRequirements') }}</li>
<li>{{ $t('install.gpuSelection.customMayNotWork') }}</li>
</ul>
</div>
<div v-if="selected === 'cpu'">
<p class="m-1">
<Tag
icon="pi pi-exclamation-triangle"
severity="warn"
:value="t('icon.exclamation-triangle')"
/>
{{ $t('install.gpuSelection.cpuModeDescription') }}
</p>
<p class="m-1">
{{ $t('install.gpuSelection.cpuModeDescription2') }}
</p>
</div> </div>
</div> </div>
<div <div class="text-neutral-300 px-24">
class="transition-opacity flex gap-3 h-0" <p v-show="descriptionText" class="leading-relaxed">
:class="{ {{ descriptionText }}
'opacity-40': selected && selected !== 'cpu' </p>
}"
>
<ToggleSwitch
v-model="cpuMode"
input-id="cpu-mode"
class="-translate-y-40"
/>
<label for="cpu-mode" class="select-none">
{{ $t('install.gpuSelection.enableCpuMode') }}
</label>
</div> </div>
</div> </div>
</template> </template>
@@ -132,20 +68,12 @@
<script setup lang="ts"> <script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types' import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import Tag from 'primevue/tag' import Tag from 'primevue/tag'
import ToggleSwitch from 'primevue/toggleswitch'
import { computed } from 'vue' import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import HardwareOption from '@/components/install/HardwareOption.vue'
import { st } from '@/i18n'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
const { t } = useI18n()
const cpuMode = computed({
get: () => selected.value === 'cpu',
set: (value) => {
selected.value = value ? 'cpu' : null
}
})
const selected = defineModel<TorchDeviceType | null>('device', { const selected = defineModel<TorchDeviceType | null>('device', {
required: true required: true
}) })
@@ -153,55 +81,23 @@ const selected = defineModel<TorchDeviceType | null>('device', {
const electron = electronAPI() const electron = electronAPI()
const platform = electron.getPlatform() const platform = electron.getPlatform()
const pickGpu = (value: typeof selected.value) => { const showRecommendedBadge = computed(
const newValue = selected.value === value ? null : value () => selected.value === 'mps' || selected.value === 'nvidia'
selected.value = newValue )
const descriptionKeys = {
mps: 'appleMetal',
nvidia: 'nvidia',
cpu: 'cpu',
unsupported: 'manual'
} as const
const descriptionText = computed(() => {
const key = selected.value ? descriptionKeys[selected.value] : undefined
return st(`install.gpuPicker.${key}Description`, '')
})
const pickGpu = (value: TorchDeviceType) => {
selected.value = value
} }
</script> </script>
<style scoped>
@reference '../../assets/css/style.css';
.p-tag {
--p-tag-gap: 0.5rem;
}
.hover-brighten {
@apply transition-colors;
transition-property: filter, box-shadow;
&:hover {
filter: brightness(107%) contrast(105%);
box-shadow: 0 0 0.25rem #ffffff79;
}
}
.p-accordioncontent-content {
@apply bg-neutral-900 rounded-lg transition-colors;
}
div.selected {
.gpu-button:not(.selected) {
@apply opacity-50 hover:opacity-100;
}
}
.gpu-button {
@apply w-1/2 m-0 cursor-pointer rounded-lg flex flex-col items-center justify-around bg-neutral-800/50 hover:bg-neutral-800/75 transition-colors;
&.selected {
@apply opacity-100 bg-neutral-700/50 hover:bg-neutral-700/60;
}
}
.disabled {
@apply pointer-events-none opacity-40;
}
.p-card-header {
@apply text-center grow;
}
.p-card-body {
@apply text-center pt-0;
}
</style>

View File

@@ -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<typeof HardwareOption> = {
title: 'Desktop/Components/HardwareOption',
component: HardwareOption,
parameters: {
layout: 'centered',
backgrounds: {
default: 'dark',
values: [{ name: 'dark', value: '#1a1a1a' }]
}
},
argTypes: {
selected: { control: 'boolean' },
imagePath: { control: 'text' },
placeholderText: { control: 'text' },
subtitle: { control: 'text' }
}
}
export default meta
type Story = StoryObj<typeof meta>
export const AppleMetalSelected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: true
}
}
export const AppleMetalUnselected: Story = {
args: {
imagePath: '/assets/images/apple-mps-logo.png',
placeholderText: 'Apple Metal',
subtitle: 'Apple Metal',
value: 'mps',
selected: false
}
}
export const CPUOption: Story = {
args: {
placeholderText: 'CPU',
subtitle: 'Subtitle',
value: 'cpu',
selected: false
}
}
export const ManualInstall: Story = {
args: {
placeholderText: 'Manual Install',
subtitle: 'Subtitle',
value: 'unsupported',
selected: false
}
}
export const NvidiaSelected: Story = {
args: {
imagePath: '/assets/images/nvidia-logo-square.jpg',
placeholderText: 'NVIDIA',
subtitle: 'NVIDIA',
value: 'nvidia',
selected: true
}
}

View File

@@ -0,0 +1,55 @@
<template>
<div class="relative">
<!-- Recommended Badge -->
<button
:class="
cn(
'hardware-option w-[170px] h-[190px] p-5 flex flex-col items-center rounded-3xl transition-all duration-200 bg-neutral-900/70 border-4',
selected ? 'border-solid border-brand-yellow' : 'border-transparent'
)
"
@click="$emit('click')"
>
<!-- Icon/Logo Area - Rounded square container -->
<div
class="icon-container w-[110px] h-[110px] shrink-0 rounded-2xl bg-neutral-800 flex items-center justify-center overflow-hidden"
>
<img
v-if="imagePath"
:src="imagePath"
:alt="placeholderText"
class="w-full h-full object-cover"
style="object-position: 57% center"
draggable="false"
/>
<span v-else class="text-xl font-medium text-neutral-400">
{{ placeholderText }}
</span>
</div>
<!-- Text Content -->
<div v-if="subtitle" class="text-center mt-4">
<div class="text-sm text-neutral-500">{{ subtitle }}</div>
</div>
</button>
</div>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { cn } from '@/utils/tailwindUtil'
interface Props {
imagePath?: string
placeholderText: string
subtitle?: string
value: TorchDeviceType
selected?: boolean
recommended?: boolean
}
defineProps<Props>()
defineEmits<{ click: [] }>()
</script>

View File

@@ -0,0 +1,79 @@
<template>
<div class="grid grid-cols-[1fr_auto_1fr] items-center gap-4">
<!-- Back button -->
<Button
v-if="currentStep !== '1'"
:label="$t('g.back')"
severity="secondary"
icon="pi pi-arrow-left"
class="font-inter rounded-lg border-0 px-6 py-2 justify-self-start"
@click="$emit('previous')"
/>
<div v-else></div>
<!-- Step indicators in center -->
<StepList class="flex justify-center items-center gap-3 select-none">
<Step value="1" :pt="stepPassthrough">
{{ $t('install.gpu') }}
</Step>
<Step value="2" :disabled="disableLocationStep" :pt="stepPassthrough">
{{ $t('install.installLocation') }}
</Step>
<Step value="3" :disabled="disableSettingsStep" :pt="stepPassthrough">
{{ $t('install.desktopSettings') }}
</Step>
</StepList>
<!-- Next/Install button -->
<Button
:label="currentStep !== '3' ? $t('g.next') : $t('g.install')"
class="px-8 py-2 bg-brand-yellow hover:bg-brand-yellow/90 font-inter rounded-lg border-0 transition-colors justify-self-end"
:pt="{
label: { class: 'text-neutral-900 font-inter font-black' }
}"
:disabled="!canProceed"
@click="currentStep !== '3' ? $emit('next') : $emit('install')"
/>
</div>
</template>
<script setup lang="ts">
import type { PassThrough } from '@primevue/core'
import Button from 'primevue/button'
import Step, { type StepPassThroughOptions } from 'primevue/step'
import StepList from 'primevue/steplist'
defineProps<{
/** Current step index as string ('1', '2', '3', '4') */
currentStep: string
/** Whether the user can proceed to the next step */
canProceed: boolean
/** Whether the location step should be disabled */
disableLocationStep: boolean
/** Whether the migration step should be disabled */
disableMigrationStep: boolean
/** Whether the settings step should be disabled */
disableSettingsStep: boolean
}>()
defineEmits<{
previous: []
next: []
install: []
}>()
const stepPassthrough: PassThrough<StepPassThroughOptions> = {
root: { class: 'flex-none p-0 m-0' },
header: ({ context }) => ({
class: [
'h-2.5 p-0 m-0 border-0 rounded-full transition-all duration-300',
context.active
? 'bg-brand-yellow w-8 rounded-sm'
: 'bg-neutral-700 w-2.5',
context.disabled ? 'opacity-60 cursor-not-allowed' : ''
].join(' ')
}),
number: { class: 'hidden' },
title: { class: 'hidden' }
}
</script>

View File

@@ -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<typeof InstallLocationPicker> = {
title: 'Desktop/Components/InstallLocationPicker',
component: InstallLocationPicker,
parameters: {
layout: 'padded',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
},
decorators: [
() => {
// Mock electron API
;(window as any).electronAPI = {
getSystemPaths: () =>
Promise.resolve({
defaultInstallPath: '/Users/username/ComfyUI'
}),
validateInstallPath: () =>
Promise.resolve({
isValid: true,
exists: false,
canWrite: true,
freeSpace: 100000000000,
requiredSpace: 10000000000,
isNonDefaultDrive: false
}),
validateComfyUISource: () =>
Promise.resolve({
isValid: true
}),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
// Default story with accordion expanded
export const Default: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with different background to test transparency
export const OnNeutral900: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-900 p-8">
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}
// Story with debug overlay showing background colors
export const DebugBackgrounds: Story = {
render: (args) => ({
components: { InstallLocationPicker },
setup() {
const installPath = ref('/Users/username/ComfyUI')
const pathError = ref('')
const migrationSourcePath = ref('/Users/username/ComfyUI-old')
const migrationItemIds = ref<string[]>(['models', 'custom_nodes'])
return {
args,
installPath,
pathError,
migrationSourcePath,
migrationItemIds
}
},
template: `
<div class="min-h-screen bg-neutral-950 p-8 relative">
<div class="absolute top-4 right-4 text-white text-xs space-y-2 z-50">
<div>Parent bg: neutral-950 (#0a0a0a)</div>
<div>Accordion content: bg-transparent</div>
<div>Migration options: bg-transparent + p-4 rounded-lg</div>
</div>
<InstallLocationPicker
v-model:installPath="installPath"
v-model:pathError="pathError"
v-model:migrationSourcePath="migrationSourcePath"
v-model:migrationItemIds="migrationItemIds"
/>
</div>
`
})
}

View File

@@ -1,103 +1,215 @@
<template> <template>
<div class="flex flex-col gap-6 w-[600px]"> <div class="flex flex-col gap-8 w-full max-w-3xl mx-auto select-none">
<!-- Installation Path Section --> <!-- Installation Path Section -->
<div class="flex flex-col gap-4"> <div class="grow flex flex-col gap-6 text-neutral-300">
<h2 class="text-2xl font-semibold text-neutral-100"> <h2 class="font-inter font-bold text-3xl text-neutral-100 text-center">
{{ $t('install.chooseInstallationLocation') }} {{ $t('install.locationPicker.title') }}
</h2> </h2>
<p class="text-neutral-400 my-0"> <p class="text-center text-neutral-400 px-12">
{{ $t('install.installLocationDescription') }} {{ $t('install.locationPicker.subtitle') }}
</p> </p>
<div class="flex gap-2"> <!-- Path Input -->
<IconField class="flex-1"> <div class="flex gap-2 px-12">
<InputText <InputText
v-model="installPath" v-model="installPath"
class="w-full" :placeholder="$t('install.locationPicker.pathPlaceholder')"
:class="{ 'p-invalid': pathError }" class="flex-1 bg-neutral-800/50 border-neutral-700 text-neutral-200 placeholder:text-neutral-500"
@update:model-value="validatePath" :class="{ 'p-invalid': pathError }"
@focus="onFocus" @update:model-value="validatePath"
/> @focus="onFocus"
<InputIcon />
v-tooltip.top="$t('install.installLocationTooltip')" <Button
class="pi pi-info-circle" icon="pi pi-folder-open"
/> severity="secondary"
</IconField> class="bg-neutral-700 hover:bg-neutral-600 border-0"
<Button icon="pi pi-folder" class="w-12" @click="browsePath" /> @click="browsePath"
/>
</div> </div>
<Message v-if="pathError" severity="error" class="whitespace-pre-line"> <!-- Error Messages -->
{{ pathError }} <div v-if="pathError || pathExists || nonDefaultDrive" class="px-12">
</Message> <Message
<Message v-if="pathExists" severity="warn"> v-if="pathError"
{{ $t('install.pathExists') }} severity="error"
</Message> class="whitespace-pre-line w-full"
<Message v-if="nonDefaultDrive" severity="warn"> >
{{ $t('install.nonDefaultDrive') }} {{ pathError }}
</Message> </Message>
</div> <Message v-if="pathExists" severity="warn" class="w-full">
{{ $t('install.pathExists') }}
<!-- System Paths Info --> </Message>
<div class="bg-neutral-800 p-4 rounded-lg"> <Message v-if="nonDefaultDrive" severity="warn" class="w-full">
<h3 class="text-lg font-medium mt-0 mb-3 text-neutral-100"> {{ $t('install.nonDefaultDrive') }}
{{ $t('install.systemLocations') }} </Message>
</h3>
<div class="flex flex-col gap-2">
<div class="flex items-center gap-2">
<i class="pi pi-folder text-neutral-400" />
<span class="text-neutral-400">App Data:</span>
<span class="text-neutral-200">{{ appData }}</span>
<span
v-tooltip="$t('install.appDataLocationTooltip')"
class="pi pi-info-circle"
/>
</div>
<div class="flex items-center gap-2">
<i class="pi pi-desktop text-neutral-400" />
<span class="text-neutral-400">App Path:</span>
<span class="text-neutral-200">{{ appPath }}</span>
<span
v-tooltip="$t('install.appPathLocationTooltip')"
class="pi pi-info-circle"
/>
</div>
</div> </div>
<!-- Collapsible Sections using PrimeVue Accordion -->
<Accordion
v-model:value="activeAccordionIndex"
:multiple="true"
class="location-picker-accordion"
:pt="{
root: 'bg-transparent border-0',
panel: {
root: 'border-0 mb-0'
},
header: {
root: 'border-0',
content:
'text-neutral-400 hover:text-neutral-300 px-4 py-2 flex items-center gap-3',
toggleicon: 'text-xs order-first mr-0'
},
content: {
root: 'bg-transparent border-0',
content: 'text-neutral-500 text-sm pl-11 pb-3 pt-0'
}
}"
>
<AccordionPanel value="0">
<AccordionHeader>
{{ $t('install.locationPicker.migrateFromExisting') }}
</AccordionHeader>
<AccordionContent>
<MigrationPicker
v-model:source-path="migrationSourcePath"
v-model:migration-item-ids="migrationItemIds"
/>
</AccordionContent>
</AccordionPanel>
<AccordionPanel value="1">
<AccordionHeader>
{{ $t('install.locationPicker.chooseDownloadServers') }}
</AccordionHeader>
<AccordionContent>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" class="my-8" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
</AccordionContent>
</AccordionPanel>
</Accordion>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import Accordion from 'primevue/accordion'
import AccordionContent from 'primevue/accordioncontent'
import AccordionHeader from 'primevue/accordionheader'
import AccordionPanel from 'primevue/accordionpanel'
import Button from 'primevue/button' import Button from 'primevue/button'
import IconField from 'primevue/iconfield' import Divider from 'primevue/divider'
import InputIcon from 'primevue/inputicon'
import InputText from 'primevue/inputtext' import InputText from 'primevue/inputtext'
import Message from 'primevue/message' import Message from 'primevue/message'
import { onMounted, ref } from 'vue' import { type ModelRef, computed, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import {
PYPI_MIRROR,
PYTHON_MIRROR,
type UVMirror
} from '@/constants/uvMirrors'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil'
const { t } = useI18n() const { t } = useI18n()
const installPath = defineModel<string>('installPath', { required: true }) const installPath = defineModel<string>('installPath', { required: true })
const pathError = defineModel<string>('pathError', { required: true }) const pathError = defineModel<string>('pathError', { required: true })
const migrationSourcePath = defineModel<string>('migrationSourcePath')
const migrationItemIds = defineModel<string[]>('migrationItemIds')
const pythonMirror = defineModel<string>('pythonMirror', {
default: ''
})
const pypiMirror = defineModel<string>('pypiMirror', {
default: ''
})
const torchMirror = defineModel<string>('torchMirror', {
default: ''
})
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pathExists = ref(false) const pathExists = ref(false)
const nonDefaultDrive = ref(false) const nonDefaultDrive = ref(false)
const appData = ref('')
const appPath = ref('')
const inputTouched = ref(false) const inputTouched = ref(false)
// Accordion state - array of active panel values
const activeAccordionIndex = ref<string[] | undefined>(undefined)
const electron = electronAPI() const electron = electronAPI()
// Get system paths on component mount // Mirror configuration logic
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
// Get default install path on component mount
onMounted(async () => { onMounted(async () => {
const paths = await electron.getSystemPaths() const paths = await electron.getSystemPaths()
appData.value = paths.appData
appPath.value = paths.appPath
installPath.value = paths.defaultInstallPath installPath.value = paths.defaultInstallPath
await validatePath(paths.defaultInstallPath) await validatePath(paths.defaultInstallPath)
userIsInChina.value = await isInChina()
}) })
const validatePath = async (path: string | undefined) => { const validatePath = async (path: string | undefined) => {
@@ -151,3 +263,52 @@ const onFocus = async () => {
await validatePath(installPath.value) await validatePath(installPath.value)
} }
</script> </script>
<style scoped>
@reference '../../assets/css/style.css';
:deep(.location-picker-accordion) {
@apply px-12;
.p-accordionpanel {
@apply border-0 bg-transparent;
}
.p-accordionheader {
@apply bg-neutral-800/50 border-0 rounded-xl mt-2 hover:bg-neutral-700/50;
transition:
background-color 0.2s ease,
border-radius 0.5s ease;
}
/* When panel is expanded, adjust header border radius */
.p-accordionpanel-active {
.p-accordionheader {
@apply rounded-t-xl rounded-b-none;
}
}
.p-accordioncontent {
@apply bg-neutral-800/50 border-0 rounded-b-xl rounded-t-none;
}
.p-accordioncontent-content {
@apply bg-transparent pt-3 pr-5 pb-5 pl-5;
}
/* Override default chevron icons to use up/down */
.p-accordionheader-toggle-icon {
&::before {
content: '\e933';
}
}
.p-accordionpanel-active {
.p-accordionheader-toggle-icon {
&::before {
content: '\e902';
}
}
}
}
</style>

View File

@@ -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<typeof MigrationPicker> = {
title: 'Desktop/Components/MigrationPicker',
component: MigrationPicker,
parameters: {
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' }
]
}
},
decorators: [
() => {
;(window as any).electronAPI = {
validateComfyUISource: () => Promise.resolve({ isValid: true }),
showDirectoryPicker: () => Promise.resolve('/Users/username/ComfyUI')
}
return { template: '<story />' }
}
]
}
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { MigrationPicker },
setup() {
const sourcePath = ref('')
const migrationItemIds = ref<string[]>([])
return { sourcePath, migrationItemIds }
},
template:
'<MigrationPicker v-model:sourcePath="sourcePath" v-model:migrationItemIds="migrationItemIds" />'
})
}

View File

@@ -2,10 +2,6 @@
<div class="flex flex-col gap-6 w-[600px]"> <div class="flex flex-col gap-6 w-[600px]">
<!-- Source Location Section --> <!-- Source Location Section -->
<div class="flex flex-col gap-4"> <div class="flex flex-col gap-4">
<h2 class="text-2xl font-semibold text-neutral-100">
{{ $t('install.migrateFromExistingInstallation') }}
</h2>
<p class="text-neutral-400 my-0"> <p class="text-neutral-400 my-0">
{{ $t('install.migrationSourcePathDescription') }} {{ $t('install.migrationSourcePathDescription') }}
</p> </p>
@@ -13,7 +9,7 @@
<div class="flex gap-2"> <div class="flex gap-2">
<InputText <InputText
v-model="sourcePath" v-model="sourcePath"
placeholder="Select existing ComfyUI installation (optional)" :placeholder="$t('install.locationPicker.migrationPathPlaceholder')"
class="flex-1" class="flex-1"
:class="{ 'p-invalid': pathError }" :class="{ 'p-invalid': pathError }"
@update:model-value="validateSource" @update:model-value="validateSource"
@@ -27,10 +23,7 @@
</div> </div>
<!-- Migration Options --> <!-- Migration Options -->
<div <div v-if="isValidSource" class="flex flex-col gap-4 p-4 rounded-lg">
v-if="isValidSource"
class="flex flex-col gap-4 bg-neutral-800 p-4 rounded-lg"
>
<h3 class="text-lg mt-0 font-medium text-neutral-100"> <h3 class="text-lg mt-0 font-medium text-neutral-100">
{{ $t('install.selectItemsToMigrate') }} {{ $t('install.selectItemsToMigrate') }}
</h3> </h3>

View File

@@ -1,121 +0,0 @@
<template>
<Panel
:header="$t('install.settings.mirrorSettings')"
toggleable
:collapsed="!showMirrorInputs"
pt:root="bg-neutral-800 border-none w-[600px]"
>
<template
v-for="([item, modelValue], index) in mirrors"
:key="item.settingId + item.mirror"
>
<Divider v-if="index > 0" />
<MirrorItem
v-model="modelValue.value"
:item="item"
@state-change="validationStates[index] = $event"
/>
</template>
<template #icons>
<i
v-tooltip="validationStateTooltip"
:class="{
'pi pi-spin pi-spinner text-neutral-400':
validationState === ValidationState.LOADING,
'pi pi-check text-green-500':
validationState === ValidationState.VALID,
'pi pi-times text-red-500':
validationState === ValidationState.INVALID
}"
/>
</template>
</Panel>
</template>
<script setup lang="ts">
import type { TorchDeviceType } from '@comfyorg/comfyui-electron-types'
import { TorchMirrorUrl } from '@comfyorg/comfyui-electron-types'
import Divider from 'primevue/divider'
import Panel from 'primevue/panel'
import type { ModelRef } from 'vue'
import { computed, onMounted, ref } from 'vue'
import MirrorItem from '@/components/install/mirror/MirrorItem.vue'
import type { UVMirror } from '@/constants/uvMirrors'
import { PYPI_MIRROR, PYTHON_MIRROR } from '@/constants/uvMirrors'
import { t } from '@/i18n'
import { isInChina } from '@/utils/networkUtil'
import { ValidationState, mergeValidationStates } from '@/utils/validationUtil'
const showMirrorInputs = ref(false)
const { device } = defineProps<{ device: TorchDeviceType | null }>()
const pythonMirror = defineModel<string>('pythonMirror', { required: true })
const pypiMirror = defineModel<string>('pypiMirror', { required: true })
const torchMirror = defineModel<string>('torchMirror', { required: true })
const getTorchMirrorItem = (device: TorchDeviceType): UVMirror => {
const settingId = 'Comfy-Desktop.UV.TorchInstallMirror'
switch (device) {
case 'mps':
return {
settingId,
mirror: TorchMirrorUrl.NightlyCpu,
fallbackMirror: TorchMirrorUrl.NightlyCpu
}
case 'nvidia':
return {
settingId,
mirror: TorchMirrorUrl.Cuda,
fallbackMirror: TorchMirrorUrl.Cuda
}
case 'cpu':
default:
return {
settingId,
mirror: PYPI_MIRROR.mirror,
fallbackMirror: PYPI_MIRROR.fallbackMirror
}
}
}
const userIsInChina = ref(false)
onMounted(async () => {
userIsInChina.value = await isInChina()
})
const useFallbackMirror = (mirror: UVMirror) => ({
...mirror,
mirror: mirror.fallbackMirror
})
const mirrors = computed<[UVMirror, ModelRef<string>][]>(() =>
(
[
[PYTHON_MIRROR, pythonMirror],
[PYPI_MIRROR, pypiMirror],
[getTorchMirrorItem(device ?? 'cpu'), torchMirror]
] as [UVMirror, ModelRef<string>][]
).map(([item, modelValue]) => [
userIsInChina.value ? useFallbackMirror(item) : item,
modelValue
])
)
const validationStates = ref<ValidationState[]>(
mirrors.value.map(() => ValidationState.IDLE)
)
const validationState = computed(() => {
return mergeValidationStates(validationStates.value)
})
const validationStateTooltip = computed(() => {
switch (validationState.value) {
case ValidationState.INVALID:
return t('install.settings.mirrorsUnreachable')
case ValidationState.VALID:
return t('install.settings.mirrorsReachable')
default:
return t('install.settings.checkingMirrors')
}
})
</script>

View File

@@ -1,10 +1,10 @@
<template> <template>
<div class="flex flex-col items-center gap-4"> <div class="flex flex-col gap-4 text-neutral-400 text-sm">
<div class="w-full"> <div>
<h3 class="text-lg font-medium text-neutral-100"> <h3 class="text-lg font-medium text-neutral-100 mb-3 mt-0">
{{ $t(`settings.${normalizedSettingId}.name`) }} {{ $t(`settings.${normalizedSettingId}.name`) }}
</h3> </h3>
<p class="text-sm text-neutral-400 mt-1"> <p class="my-1">
{{ $t(`settings.${normalizedSettingId}.tooltip`) }} {{ $t(`settings.${normalizedSettingId}.tooltip`) }}
</p> </p>
</div> </div>
@@ -16,18 +16,61 @@
" "
@state-change="validationState = $event" @state-change="validationState = $event"
/> />
<div v-if="secondParagraph" class="mt-2">
<a href="#" @click.prevent="showDialog = true">
{{ $t('g.learnMore') }}
</a>
<Dialog
v-model:visible="showDialog"
modal
dismissable-mask
:header="$t(`settings.${normalizedSettingId}.urlFormatTitle`)"
class="select-none max-w-3xl"
>
<div class="text-neutral-300">
<p class="mt-1 whitespace-pre-wrap">{{ secondParagraph }}</p>
<div class="mt-2 break-all">
<span class="text-neutral-300 font-semibold">
{{ EXAMPLE_URL_FIRST_PART }}
</span>
<span>{{ EXAMPLE_URL_SECOND_PART }}</span>
</div>
<Divider />
<p>
{{ $t(`settings.${normalizedSettingId}.fileUrlDescription`) }}
</p>
<span class="text-neutral-300 font-semibold">
{{ FILE_URL_SCHEME }}
</span>
<span>
{{ EXAMPLE_FILE_URL }}
</span>
</div>
</Dialog>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import Dialog from 'primevue/dialog'
import Divider from 'primevue/divider'
import { computed, onMounted, ref, watch } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import UrlInput from '@/components/common/UrlInput.vue' import UrlInput from '@/components/common/UrlInput.vue'
import type { UVMirror } from '@/constants/uvMirrors' import type { UVMirror } from '@/constants/uvMirrors'
import { st } from '@/i18n'
import { normalizeI18nKey } from '@/utils/formatUtil' import { normalizeI18nKey } from '@/utils/formatUtil'
import { checkMirrorReachable } from '@/utils/networkUtil' import { checkMirrorReachable } from '@/utils/networkUtil'
import { ValidationState } from '@/utils/validationUtil' import { ValidationState } from '@/utils/validationUtil'
const FILE_URL_SCHEME = 'file://'
const EXAMPLE_FILE_URL = '/C:/MyPythonInstallers/'
const EXAMPLE_URL_FIRST_PART =
'https://github.com/astral-sh/python-build-standalone/releases/download'
const EXAMPLE_URL_SECOND_PART =
'/20250902/cpython-3.12.11+20250902-x86_64-pc-windows-msvc-install_only.tar.gz'
const { item } = defineProps<{ const { item } = defineProps<{
item: UVMirror item: UVMirror
}>() }>()
@@ -38,11 +81,16 @@ const emit = defineEmits<{
const modelValue = defineModel<string>('modelValue', { required: true }) const modelValue = defineModel<string>('modelValue', { required: true })
const validationState = ref<ValidationState>(ValidationState.IDLE) const validationState = ref<ValidationState>(ValidationState.IDLE)
const showDialog = ref(false)
const normalizedSettingId = computed(() => { const normalizedSettingId = computed(() => {
return normalizeI18nKey(item.settingId) return normalizeI18nKey(item.settingId)
}) })
const secondParagraph = computed(() =>
st(`settings.${normalizedSettingId.value}.urlDescription`, '')
)
onMounted(() => { onMounted(() => {
modelValue.value = item.mirror modelValue.value = item.mirror
}) })

View File

@@ -64,31 +64,29 @@ const litegraphService = useLitegraphService()
const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore) const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore)
const dismissable = ref(true) const dismissable = ref(true)
const getNewNodeLocation = (): Point => { function getNewNodeLocation(): Point {
return triggerEvent return triggerEvent
? [triggerEvent.canvasX, triggerEvent.canvasY] ? [triggerEvent.canvasX, triggerEvent.canvasY]
: litegraphService.getCanvasCenter() : litegraphService.getCanvasCenter()
} }
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([]) const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
const addFilter = (filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) => { function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
nodeFilters.value.push(filter) nodeFilters.value.push(filter)
} }
const removeFilter = ( function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
) => {
nodeFilters.value = nodeFilters.value.filter( nodeFilters.value = nodeFilters.value.filter(
(f) => toRaw(f) !== toRaw(filter) (f) => toRaw(f) !== toRaw(filter)
) )
} }
const clearFilters = () => { function clearFilters() {
nodeFilters.value = [] nodeFilters.value = []
} }
const closeDialog = () => { function closeDialog() {
visible.value = false visible.value = false
} }
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const addNode = (nodeDef: ComfyNodeDefImpl) => { function addNode(nodeDef: ComfyNodeDefImpl) {
const node = litegraphService.addNodeOnGraph(nodeDef, { const node = litegraphService.addNodeOnGraph(nodeDef, {
pos: getNewNodeLocation() pos: getNewNodeLocation()
}) })
@@ -106,7 +104,7 @@ const addNode = (nodeDef: ComfyNodeDefImpl) => {
window.requestAnimationFrame(closeDialog) window.requestAnimationFrame(closeDialog)
} }
const showSearchBox = (e: CanvasPointerEvent | null) => { function showSearchBox(e: CanvasPointerEvent | null) {
if (newSearchBoxEnabled.value) { if (newSearchBoxEnabled.value) {
if (e?.pointerType === 'touch') { if (e?.pointerType === 'touch') {
setTimeout(() => { setTimeout(() => {
@@ -120,11 +118,12 @@ const showSearchBox = (e: CanvasPointerEvent | null) => {
} }
} }
const getFirstLink = () => function getFirstLink() {
canvasStore.getCanvas().linkConnector.renderLinks.at(0) return canvasStore.getCanvas().linkConnector.renderLinks.at(0)
}
const nodeDefStore = useNodeDefStore() const nodeDefStore = useNodeDefStore()
const showNewSearchBox = (e: CanvasPointerEvent | null) => { function showNewSearchBox(e: CanvasPointerEvent | null) {
const firstLink = getFirstLink() const firstLink = getFirstLink()
if (firstLink) { if (firstLink) {
const filter = const filter =
@@ -149,7 +148,7 @@ const showNewSearchBox = (e: CanvasPointerEvent | null) => {
}, 300) }, 300)
} }
const showContextMenu = (e: CanvasPointerEvent) => { function showContextMenu(e: CanvasPointerEvent) {
const firstLink = getFirstLink() const firstLink = getFirstLink()
if (!firstLink) return if (!firstLink) return
@@ -226,7 +225,7 @@ watchEffect(() => {
) )
}) })
const canvasEventHandler = (e: LiteGraphCanvasEvent) => { function canvasEventHandler(e: LiteGraphCanvasEvent) {
if (e.detail.subType === 'empty-double-click') { if (e.detail.subType === 'empty-double-click') {
showSearchBox(e.detail.originalEvent) showSearchBox(e.detail.originalEvent)
} else if (e.detail.subType === 'group-double-click') { } else if (e.detail.subType === 'group-double-click') {
@@ -249,8 +248,10 @@ const linkReleaseActionShift = computed(() =>
) )
// Prevent normal LinkConnector reset (called by CanvasPointer.finally) // Prevent normal LinkConnector reset (called by CanvasPointer.finally)
const preventDefault = (e: Event) => e.preventDefault() function preventDefault(e: Event) {
const cancelNextReset = (e: CustomEvent<CanvasPointerEvent>) => { return e.preventDefault()
}
function cancelNextReset(e: CustomEvent<CanvasPointerEvent>) {
e.preventDefault() e.preventDefault()
const canvas = canvasStore.getCanvas() const canvas = canvasStore.getCanvas()
@@ -260,7 +261,7 @@ const cancelNextReset = (e: CustomEvent<CanvasPointerEvent>) => {
}) })
} }
const handleDroppedOnCanvas = (e: CustomEvent<CanvasPointerEvent>) => { function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
disconnectOnReset = true disconnectOnReset = true
const action = e.detail.shiftKey const action = e.detail.shiftKey
? linkReleaseActionShift.value ? linkReleaseActionShift.value
@@ -281,7 +282,7 @@ const handleDroppedOnCanvas = (e: CustomEvent<CanvasPointerEvent>) => {
} }
// Resets litegraph state // Resets litegraph state
const reset = () => { function reset() {
listenerController?.abort() listenerController?.abort()
listenerController = null listenerController = null
triggerEvent = null triggerEvent = null

View File

@@ -1,7 +1,7 @@
<template> <template>
<div <div
ref="workflowTabRef" ref="workflowTabRef"
class="flex p-2 gap-2 workflow-tab" class="flex p-2 gap-2 workflow-tab group"
v-bind="$attrs" v-bind="$attrs"
@mouseenter="handleMouseEnter" @mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave" @mouseleave="handleMouseLeave"
@@ -11,9 +11,13 @@
{{ workflowOption.workflow.filename }} {{ workflowOption.workflow.filename }}
</span> </span>
<div class="relative"> <div class="relative">
<span v-if="shouldShowStatusIndicator" class="status-indicator"></span> <span
v-if="shouldShowStatusIndicator"
class="group-hover:hidden absolute font-bold text-2xl top-1/2 left-1/2 -translate-1/2 z-10 bg-(--comfy-menu-secondary-bg) w-4"
></span
>
<Button <Button
class="close-button p-0 w-auto" class="close-button p-0 w-auto invisible"
icon="pi pi-times" icon="pi pi-times"
text text
severity="secondary" severity="secondary"
@@ -174,18 +178,6 @@ onUnmounted(() => {
}) })
</script> </script>
<style scoped>
@reference '../../assets/css/style.css';
.status-indicator {
@apply absolute font-bold;
font-size: 1.5rem;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
</style>
<style> <style>
.p-tooltip.workflow-tab-tooltip { .p-tooltip.workflow-tab-tooltip {
z-index: 1200 !important; z-index: 1200 !important;

View File

@@ -358,14 +358,6 @@ onUpdated(() => {
@apply visible; @apply visible;
} }
:deep(.p-togglebutton:hover) .status-indicator {
@apply hidden;
}
:deep(.p-togglebutton) .close-button {
@apply invisible;
}
:deep(.p-scrollpanel-content) { :deep(.p-scrollpanel-content) {
@apply h-full; @apply h-full;
} }

View File

@@ -9,7 +9,8 @@ export function useTerminal(element: Ref<HTMLElement | undefined>) {
const fitAddon = new FitAddon() const fitAddon = new FitAddon()
const terminal = markRaw( const terminal = markRaw(
new Terminal({ new Terminal({
convertEol: true convertEol: true,
theme: { background: '#171717' }
}) })
) )
terminal.loadAddon(fitAddon) terminal.loadAddon(fitAddon)

View File

@@ -1,136 +0,0 @@
import { onUnmounted, ref } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
interface CanvasTransformSyncOptions {
/**
* Whether to automatically start syncing when canvas is available
* @default true
*/
autoStart?: boolean
/**
* Called when sync starts
*/
onStart?: () => void
/**
* Called when sync stops
*/
onStop?: () => void
}
interface CanvasTransform {
scale: number
offsetX: number
offsetY: number
}
/**
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
*
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
* on every frame. It handles RAF lifecycle management, and ensures proper cleanup.
*
* The sync function typically reads canvas.ds properties like offset and scale to keep
* Vue components aligned with the canvas coordinate system.
*
* @example
* ```ts
* const syncWithCanvas = (canvas: LGraphCanvas) => {
* canvas.ds.scale
* canvas.ds.offset
* }
*
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
* syncWithCanvas,
* {
* autoStart: false,
* onStart: () => emit('rafStatusChange', true),
* onStop: () => emit('rafStatusChange', false)
* }
* )
* ```
*/
export function useCanvasTransformSync(
syncFn: (canvas: LGraphCanvas) => void,
options: CanvasTransformSyncOptions = {}
) {
const { onStart, onStop, autoStart = true } = options
const { getCanvas } = useCanvasStore()
const isActive = ref(false)
let rafId: number | null = null
let lastTransform: CanvasTransform = {
scale: 0,
offsetX: 0,
offsetY: 0
}
const hasTransformChanged = (canvas: LGraphCanvas): boolean => {
const ds = canvas.ds
return (
ds.scale !== lastTransform.scale ||
ds.offset[0] !== lastTransform.offsetX ||
ds.offset[1] !== lastTransform.offsetY
)
}
const sync = () => {
if (!isActive.value) return
const canvas = getCanvas()
if (!canvas) return
try {
// Only run sync if transform actually changed
if (hasTransformChanged(canvas)) {
lastTransform = {
scale: canvas.ds.scale,
offsetX: canvas.ds.offset[0],
offsetY: canvas.ds.offset[1]
}
syncFn(canvas)
}
} catch (error) {
console.error('Canvas transform sync error:', error)
}
rafId = requestAnimationFrame(sync)
}
const startSync = () => {
if (isActive.value) return
isActive.value = true
onStart?.()
// Reset last transform to force initial sync
lastTransform = { scale: 0, offsetX: 0, offsetY: 0 }
sync()
}
const stopSync = () => {
isActive.value = false
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
onStop?.()
}
onUnmounted(stopSync)
if (autoStart) {
startSync()
}
return {
isActive,
startSync,
stopSync
}
}

View File

@@ -1,7 +1,7 @@
import { useRafFn } from '@vueuse/core'
import { computed, onUnmounted, ref, watch } from 'vue' import { computed, onUnmounted, ref, watch } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems' import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces' import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
@@ -128,9 +128,7 @@ export function useSelectionToolboxPosition(
} }
// Sync with canvas transform // Sync with canvas transform
const { startSync, stopSync } = useCanvasTransformSync(updateTransform, { const { resume: startSync, pause: stopSync } = useRafFn(updateTransform)
autoStart: false
})
// Watch for selection changes // Watch for selection changes
watch( watch(

View File

@@ -2,42 +2,15 @@
* Vue node lifecycle management for LiteGraph integration * Vue node lifecycle management for LiteGraph integration
* Provides event-driven reactivity with performance optimizations * Provides event-driven reactivity with performance optimizations
*/ */
import { nextTick, reactive } from 'vue' import { reactive } from 'vue'
import { useChainCallback } from '@/composables/functional/useChainCallback' import { useChainCallback } from '@/composables/functional/useChainCallback'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations' import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { LayoutSource } from '@/renderer/core/layout/types' import { LayoutSource } from '@/renderer/core/layout/types'
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
import type { WidgetValue } from '@/types/simplifiedWidget' import type { WidgetValue } from '@/types/simplifiedWidget'
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph' import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
export interface NodeState {
visible: boolean
dirty: boolean
lastUpdate: number
culled: boolean
}
interface NodeMetadata {
lastRenderTime: number
cachedBounds: DOMRect | null
lodLevel: 'high' | 'medium' | 'low'
spatialIndex?: QuadTree<string>
}
interface PerformanceMetrics {
fps: number
frameTime: number
updateTime: number
nodeCount: number
culledCount: number
callbackUpdateCount: number
rafUpdateCount: number
adaptiveQuality: boolean
}
export interface SafeWidgetData { export interface SafeWidgetData {
name: string name: string
type: string type: string
@@ -63,109 +36,26 @@ export interface VueNodeData {
} }
} }
interface SpatialMetrics {
queryTime: number
nodesInIndex: number
}
export interface GraphNodeManager { export interface GraphNodeManager {
// Reactive state - safe data extracted from LiteGraph nodes // Reactive state - safe data extracted from LiteGraph nodes
vueNodeData: ReadonlyMap<string, VueNodeData> vueNodeData: ReadonlyMap<string, VueNodeData>
nodeState: ReadonlyMap<string, NodeState>
nodePositions: ReadonlyMap<string, { x: number; y: number }>
nodeSizes: ReadonlyMap<string, { width: number; height: number }>
// Access to original LiteGraph nodes (non-reactive) // Access to original LiteGraph nodes (non-reactive)
getNode(id: string): LGraphNode | undefined getNode(id: string): LGraphNode | undefined
// Lifecycle methods // Lifecycle methods
setupEventListeners(): () => void
cleanup(): void cleanup(): void
// Update methods
scheduleUpdate(
nodeId?: string,
priority?: 'critical' | 'normal' | 'low'
): void
forceSync(): void
detectChangesInRAF(): void
// Spatial queries
getVisibleNodeIds(viewportBounds: Bounds): Set<string>
// Performance
performanceMetrics: PerformanceMetrics
spatialMetrics: SpatialMetrics
// Debug
getSpatialIndexDebugInfo(): SpatialIndexDebugInfo | null
} }
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => { export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
// Get layout mutations composable // Get layout mutations composable
const { moveNode, resizeNode, createNode, deleteNode, setSource } = const { createNode, deleteNode, setSource } = useLayoutMutations()
useLayoutMutations()
// Safe reactive data extracted from LiteGraph nodes // Safe reactive data extracted from LiteGraph nodes
const vueNodeData = reactive(new Map<string, VueNodeData>()) const vueNodeData = reactive(new Map<string, VueNodeData>())
const nodeState = reactive(new Map<string, NodeState>())
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
const nodeSizes = reactive(
new Map<string, { width: number; height: number }>()
)
// Non-reactive storage for original LiteGraph nodes // Non-reactive storage for original LiteGraph nodes
const nodeRefs = new Map<string, LGraphNode>() const nodeRefs = new Map<string, LGraphNode>()
// WeakMap for heavy data that auto-GCs when nodes are removed
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
// Performance tracking
const performanceMetrics = reactive<PerformanceMetrics>({
fps: 0,
frameTime: 0,
updateTime: 0,
nodeCount: 0,
culledCount: 0,
callbackUpdateCount: 0,
rafUpdateCount: 0,
adaptiveQuality: false
})
// Spatial indexing using QuadTree
const spatialIndex = new QuadTree<string>(
{ x: -10000, y: -10000, width: 20000, height: 20000 },
{ maxDepth: 6, maxItemsPerNode: 4 }
)
let lastSpatialQueryTime = 0
// Spatial metrics
const spatialMetrics = reactive<SpatialMetrics>({
queryTime: 0,
nodesInIndex: 0
})
// Update batching
const pendingUpdates = new Set<string>()
const criticalUpdates = new Set<string>()
const lowPriorityUpdates = new Set<string>()
let updateScheduled = false
let batchTimeoutId: number | null = null
// Change detection state
const lastNodesSnapshot = new Map<
string,
{ pos: [number, number]; size: [number, number] }
>()
const attachMetadata = (node: LGraphNode) => {
nodeMetadata.set(node, {
lastRenderTime: performance.now(),
cachedBounds: null,
lodLevel: 'high',
spatialIndex: undefined
})
}
// Extract safe data from LiteGraph node for Vue consumption // Extract safe data from LiteGraph node for Vue consumption
const extractVueNodeData = (node: LGraphNode): VueNodeData => { const extractVueNodeData = (node: LGraphNode): VueNodeData => {
// Determine subgraph ID - null for root graph, string for subgraphs // Determine subgraph ID - null for root graph, string for subgraphs
@@ -286,7 +176,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
...currentData, ...currentData,
widgets: updatedWidgets widgets: updatedWidgets
}) })
performanceMetrics.callbackUpdateCount++
} catch (error) { } catch (error) {
// Ignore widget update errors to prevent cascade failures // 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 = () => { const syncWithGraph = () => {
if (!graph?._nodes) return if (!graph?._nodes) return
@@ -431,11 +255,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
if (!currentNodes.has(id)) { if (!currentNodes.has(id)) {
nodeRefs.delete(id) nodeRefs.delete(id)
vueNodeData.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 // Extract and store safe data for Vue
vueNodeData.set(id, extractVueNodeData(node)) vueNodeData.set(id, extractVueNodeData(node))
if (!nodeState.has(id)) {
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
attachMetadata(node)
// Add to spatial index
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.insert(id, bounds, id)
}
}) })
// Update performance metrics
performanceMetrics.nodeCount = vueNodeData.size
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
(s) => s.culled
).length
}
// Most performant: Direct position sync without re-setting entire node
// Query visible nodes using QuadTree spatial index
const getVisibleNodeIds = (viewportBounds: Bounds): Set<string> => {
const startTime = performance.now()
// Use QuadTree for fast spatial query
const results: string[] = spatialIndex.query(viewportBounds)
const visibleIds = new Set(results)
lastSpatialQueryTime = performance.now() - startTime
spatialMetrics.queryTime = lastSpatialQueryTime
return visibleIds
}
/**
* Detects position changes for a single node and updates reactive state
*/
const detectPositionChanges = (node: LGraphNode, id: string): boolean => {
const currentPos = nodePositions.get(id)
if (
!currentPos ||
currentPos.x !== node.pos[0] ||
currentPos.y !== node.pos[1]
) {
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
// Push position change to layout store
// Source is already set to 'canvas' in detectChangesInRAF
void moveNode(id, { x: node.pos[0], y: node.pos[1] })
return true
}
return false
}
/**
* Detects size changes for a single node and updates reactive state
*/
const detectSizeChanges = (node: LGraphNode, id: string): boolean => {
const currentSize = nodeSizes.get(id)
if (
!currentSize ||
currentSize.width !== node.size[0] ||
currentSize.height !== node.size[1]
) {
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
// Push size change to layout store
// Source is already set to 'canvas' in detectChangesInRAF
void resizeNode(id, {
width: node.size[0],
height: node.size[1]
})
return true
}
return false
}
/**
* Updates spatial index for a node if bounds changed
*/
const updateSpatialIndex = (node: LGraphNode, id: string): void => {
const bounds: Bounds = {
x: node.pos[0],
y: node.pos[1],
width: node.size[0],
height: node.size[1]
}
spatialIndex.update(id, bounds)
}
/**
* Updates performance metrics after change detection
*/
const updatePerformanceMetrics = (
startTime: number,
positionUpdates: number,
sizeUpdates: number
): void => {
const endTime = performance.now()
performanceMetrics.updateTime = endTime - startTime
performanceMetrics.nodeCount = vueNodeData.size
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
(state) => state.culled
).length
spatialMetrics.nodesInIndex = spatialIndex.size
if (positionUpdates > 0 || sizeUpdates > 0) {
performanceMetrics.rafUpdateCount++
}
}
/**
* Main RAF change detection function
*/
const detectChangesInRAF = () => {
const startTime = performance.now()
if (!graph?._nodes) return
let positionUpdates = 0
let sizeUpdates = 0
// Set source for all canvas-driven updates
setSource(LayoutSource.Canvas)
// Process each node for changes
for (const node of graph._nodes) {
const id = String(node.id)
const posChanged = detectPositionChanges(node, id)
const sizeChanged = detectSizeChanges(node, id)
if (posChanged) positionUpdates++
if (sizeChanged) sizeUpdates++
// Update spatial index if geometry changed
if (posChanged || sizeChanged) {
updateSpatialIndex(node, id)
}
}
updatePerformanceMetrics(startTime, positionUpdates, sizeUpdates)
} }
/** /**
@@ -629,32 +292,11 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Extract initial data for Vue (may be incomplete during graph configure) // Extract initial data for Vue (may be incomplete during graph configure)
vueNodeData.set(id, extractVueNodeData(node)) vueNodeData.set(id, extractVueNodeData(node))
// Set up reactive tracking state
nodeState.set(id, {
visible: true,
dirty: false,
lastUpdate: performance.now(),
culled: false
})
const initializeVueNodeLayout = () => { const initializeVueNodeLayout = () => {
// Extract actual positions after configure() has potentially updated them // Extract actual positions after configure() has potentially updated them
const nodePosition = { x: node.pos[0], y: node.pos[1] } const nodePosition = { x: node.pos[0], y: node.pos[1] }
const nodeSize = { width: node.size[0], height: node.size[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 // Add node to layout store with final positions
setSource(LayoutSource.Canvas) setSource(LayoutSource.Canvas)
void createNode(id, { void createNode(id, {
@@ -698,9 +340,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
) => { ) => {
const id = String(node.id) const id = String(node.id)
// Remove from spatial index
spatialIndex.remove(id)
// Remove node from layout store // Remove node from layout store
setSource(LayoutSource.Canvas) setSource(LayoutSource.Canvas)
void deleteNode(id) void deleteNode(id)
@@ -708,10 +347,6 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
// Clean up all tracking references // Clean up all tracking references
nodeRefs.delete(id) nodeRefs.delete(id)
vueNodeData.delete(id) vueNodeData.delete(id)
nodeState.delete(id)
nodePositions.delete(id)
nodeSizes.delete(id)
lastNodesSnapshot.delete(id)
// Call original callback if provided // Call original callback if provided
if (originalCallback) { if (originalCallback) {
@@ -733,23 +368,9 @@ export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
graph.onNodeRemoved = originalOnNodeRemoved || undefined graph.onNodeRemoved = originalOnNodeRemoved || undefined
graph.onTrigger = originalOnTrigger || undefined graph.onTrigger = originalOnTrigger || undefined
// Clear pending updates
if (batchTimeoutId !== null) {
clearTimeout(batchTimeoutId)
batchTimeoutId = null
}
// Clear all state maps // Clear all state maps
nodeRefs.clear() nodeRefs.clear()
vueNodeData.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 { return {
vueNodeData, vueNodeData,
nodeState,
nodePositions,
nodeSizes,
getNode, getNode,
setupEventListeners, cleanup
cleanup,
scheduleUpdate,
forceSync: syncWithGraph,
detectChangesInRAF,
getVisibleNodeIds,
performanceMetrics,
spatialMetrics,
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
} }
} }

View File

@@ -6,21 +6,18 @@
* 2. Set display none on element to avoid cascade resolution overhead * 2. Set display none on element to avoid cascade resolution overhead
* 3. Only run when transform changes (event driven) * 3. Only run when transform changes (event driven)
*/ */
import { useThrottleFn } from '@vueuse/core'
import { computed } from 'vue' import { computed } from 'vue'
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle' import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore' import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { app as comfyApp } from '@/scripts/app' import { app as comfyApp } from '@/scripts/app'
export function useViewportCulling() { export function useViewportCulling() {
const canvasStore = useCanvasStore() const canvasStore = useCanvasStore()
const { shouldRenderVueNodes } = useVueFeatureFlags() const { vueNodeData, nodeManager } = useVueNodeLifecycle()
const { vueNodeData, nodeDataTrigger, nodeManager } = useVueNodeLifecycle()
const allNodes = computed(() => { const allNodes = computed(() => {
if (!shouldRenderVueNodes.value) return []
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
return Array.from(vueNodeData.value.values()) return Array.from(vueNodeData.value.values())
}) })
@@ -28,7 +25,7 @@ export function useViewportCulling() {
* Update visibility of all nodes based on viewport * Update visibility of all nodes based on viewport
* Queries DOM directly - no cache maintenance needed * Queries DOM directly - no cache maintenance needed
*/ */
const updateVisibility = () => { function updateVisibility() {
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
const canvas = canvasStore.canvas const canvas = canvasStore.canvas
@@ -70,31 +67,17 @@ export function useViewportCulling() {
} }
} }
const updateVisibilityDebounced = useThrottleFn(updateVisibility, 20)
// RAF throttling for smooth updates during continuous panning // RAF throttling for smooth updates during continuous panning
let rafId: number | null = null function handleTransformUpdate() {
requestAnimationFrame(async () => {
/** await updateVisibilityDebounced()
* 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
}) })
} }
return { return {
allNodes, allNodes,
handleTransformUpdate, handleTransformUpdate
updateVisibility
} }
} }

View File

@@ -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 { 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 { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { import type {
GraphNodeManager, GraphNodeManager,
NodeState,
VueNodeData VueNodeData
} from '@/composables/graph/useGraphNodeManager' } from '@/composables/graph/useGraphNodeManager'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags' import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
@@ -42,22 +31,10 @@ function useVueNodeLifecycleIndividual() {
// Vue node data state // Vue node data state
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map()) const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
new Map()
)
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
new Map()
)
// Change detection function
const detectChangesInRAF = ref<() => void>(() => {})
// Trigger for forcing computed re-evaluation // Trigger for forcing computed re-evaluation
const nodeDataTrigger = ref(0) const nodeDataTrigger = ref(0)
const isNodeManagerReady = computed(() => nodeManager.value !== null)
const initializeNodeManager = () => { const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph // Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph || comfyApp.graph const activeGraph = comfyApp.canvas?.graph || comfyApp.graph
@@ -70,10 +47,6 @@ function useVueNodeLifecycleIndividual() {
// Use the manager's data maps // Use the manager's data maps
vueNodeData.value = manager.vueNodeData 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 // Initialize layout system with existing nodes from active graph
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({ const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
@@ -136,12 +109,6 @@ function useVueNodeLifecycleIndividual() {
// Reset reactive maps to clean state // Reset reactive maps to clean state
vueNodeData.value = new Map() vueNodeData.value = new Map()
nodeState.value = new Map()
nodePositions.value = new Map()
nodeSizes.value = new Map()
// Reset change detection function
detectChangesInRAF.value = () => {}
} }
// Watch for Vue nodes enabled state changes // Watch for Vue nodes enabled state changes
@@ -235,13 +202,7 @@ function useVueNodeLifecycleIndividual() {
return { return {
vueNodeData, vueNodeData,
nodeState,
nodePositions,
nodeSizes,
nodeDataTrigger: readonly(nodeDataTrigger),
nodeManager: readonly(nodeManager), nodeManager: readonly(nodeManager),
detectChangesInRAF: readonly(detectChangesInRAF),
isNodeManagerReady,
// Lifecycle methods // Lifecycle methods
initializeNodeManager, initializeNodeManager,

View File

@@ -3901,6 +3901,19 @@ class UIManager {
this.paintBucketSettingsHTML.style.display = 'none' this.paintBucketSettingsHTML.style.display = 'none'
} }
} }
if (tool === Tools.MaskColorFill) {
this.brushSettingsHTML.style.display = 'none'
this.colorSelectSettingsHTML.style.display = 'flex'
this.paintBucketSettingsHTML.style.display = 'none'
} else if (tool === Tools.MaskBucket) {
this.brushSettingsHTML.style.display = 'none'
this.colorSelectSettingsHTML.style.display = 'none'
this.paintBucketSettingsHTML.style.display = 'flex'
} else {
this.brushSettingsHTML.style.display = 'flex'
this.colorSelectSettingsHTML.style.display = 'none'
this.paintBucketSettingsHTML.style.display = 'none'
}
this.messageBroker.publish('setTool', tool) this.messageBroker.publish('setTool', tool)
this.onToolChange() this.onToolChange()
const newActiveLayer = this.toolSettings[tool].newActiveLayerOnSet const newActiveLayer = this.toolSettings[tool].newActiveLayerOnSet

View File

@@ -4032,6 +4032,18 @@ export class LGraphCanvas
// TODO: Report failures, i.e. `failedNodes` // 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) this.selectItems(created)
graph.afterChange() graph.afterChange()

View File

@@ -82,6 +82,7 @@ export interface Positionable extends Parent<Positionable>, HasBoundingRect {
* @default 0,0 * @default 0,0
*/ */
readonly pos: Point readonly pos: Point
readonly size?: Size
/** true if this object is part of the selection, otherwise false. */ /** true if this object is part of the selection, otherwise false. */
selected?: boolean selected?: boolean

View File

@@ -18,6 +18,7 @@
"calculatingDimensions": "Calculating dimensions", "calculatingDimensions": "Calculating dimensions",
"import": "Import", "import": "Import",
"loadAllFolders": "Load All Folders", "loadAllFolders": "Load All Folders",
"logoAlt": "ComfyUI Logo",
"refresh": "Refresh", "refresh": "Refresh",
"refreshNode": "Refresh Node", "refreshNode": "Refresh Node",
"terminal": "Terminal", "terminal": "Terminal",
@@ -407,6 +408,27 @@
"migration": "Migration", "migration": "Migration",
"desktopSettings": "Desktop Settings", "desktopSettings": "Desktop Settings",
"chooseInstallationLocation": "Choose Installation Location", "chooseInstallationLocation": "Choose Installation Location",
"gpuPicker": {
"title": "Choose your hardware setup",
"recommended": "RECOMMENDED",
"nvidiaSubtitle": "NVIDIA CUDA",
"cpuSubtitle": "CPU Mode",
"manualSubtitle": "Manual Setup",
"appleMetalDescription": "Leverages your Mac's GPU for faster speed and a better overall experience",
"nvidiaDescription": "Use your NVIDIA GPU with CUDA acceleration for the best performance.",
"cpuDescription": "Use CPU mode for compatibility when GPU acceleration is not available",
"manualDescription": "Configure ComfyUI manually for advanced setups or unsupported hardware"
},
"locationPicker": {
"title": "Choose where to install ComfyUI",
"subtitle": "Pick a folder for ComfyUI's files. We'll also set up Python there automatically.",
"pathPlaceholder": "/Users/username/Documents/ComfyUI",
"migrationPathPlaceholder": "Select existing ComfyUI installation (optional)",
"migrateFromExisting": "Migrate from existing installation",
"migrateDescription": "Copy or link your existing models, custom nodes, and configurations from a previous ComfyUI installation.",
"chooseDownloadServers": "Choose download servers manually",
"downloadServersDescription": "Select specific mirror servers for downloading Python, PyPI packages, and PyTorch based on your location."
},
"systemLocations": "System Locations", "systemLocations": "System Locations",
"failedToSelectDirectory": "Failed to select directory", "failedToSelectDirectory": "Failed to select directory",
"pathValidationFailed": "Failed to validate path", "pathValidationFailed": "Failed to validate path",
@@ -491,18 +513,26 @@
"metricsDisabled": "Metrics Disabled", "metricsDisabled": "Metrics Disabled",
"updateConsent": "You previously opted in to reporting crashes. We are now tracking event-based metrics to help identify bugs and improve the app. No personal identifiable information is collected." "updateConsent": "You previously opted in to reporting crashes. We are now tracking event-based metrics to help identify bugs and improve the app. No personal identifiable information is collected."
}, },
"desktopStart": {
"initialising": "Initialising..."
},
"serverStart": { "serverStart": {
"title": "Starting ComfyUI",
"troubleshoot": "Troubleshoot", "troubleshoot": "Troubleshoot",
"reportIssue": "Report Issue", "reportIssue": "Report Issue",
"openLogs": "Open Logs", "openLogs": "Open Logs",
"showTerminal": "Show Terminal", "showTerminal": "Show Terminal",
"copySelectionTooltip": "Copy selection", "copySelectionTooltip": "Copy selection",
"copyAllTooltip": "Copy all", "copyAllTooltip": "Copy all",
"errorMessage": "Unable to start ComfyUI Desktop",
"installation": {
"title": "Installing ComfyUI"
},
"process": { "process": {
"initial-state": "Loading...", "initial-state": "Loading...",
"python-setup": "Setting up Python Environment...", "python-setup": "Setting up Python Environment...",
"starting-server": "Starting ComfyUI server...", "starting-server": "Starting ComfyUI server...",
"ready": "Finishing...", "ready": "Loading Human Interface",
"error": "Unable to start ComfyUI Desktop" "error": "Unable to start ComfyUI Desktop"
} }
}, },

View File

@@ -6,12 +6,15 @@
"name": "Send anonymous usage metrics" "name": "Send anonymous usage metrics"
}, },
"Comfy-Desktop_UV_PypiInstallMirror": { "Comfy-Desktop_UV_PypiInstallMirror": {
"name": "Pypi Install Mirror", "name": "PyPI Install Mirror",
"tooltip": "Default pip install mirror" "tooltip": "Default pip install mirror"
}, },
"Comfy-Desktop_UV_PythonInstallMirror": { "Comfy-Desktop_UV_PythonInstallMirror": {
"name": "Python Install Mirror", "name": "Python Install Mirror",
"tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. This variable can be set to a mirror URL to use a different source for Python installations. The provided URL will replace https://github.com/astral-sh/python-build-standalone/releases/download in, e.g., https://github.com/astral-sh/python-build-standalone/releases/download/20240713/cpython-3.12.4%2B20240713-aarch64-apple-darwin-install_only.tar.gz. Distributions can be read from a local directory by using the file:// URL scheme." "tooltip": "Managed Python installations are downloaded from the Astral python-build-standalone project. To use a different source for Python installations, enter a mirror URL.",
"urlFormatTitle": "Mirror URL Format",
"urlDescription": "This is an example python download URL.\n\nThe mirror URL is the first half, including everything before the date (20250902):",
"fileUrlDescription": "To install from a file you downloaded earlier, you may use a file URL:"
}, },
"Comfy-Desktop_UV_TorchInstallMirror": { "Comfy-Desktop_UV_TorchInstallMirror": {
"name": "Torch Install Mirror", "name": "Torch Install Mirror",
@@ -421,4 +424,4 @@
"pysssss_SnapToGrid": { "pysssss_SnapToGrid": {
"name": "Always snap to grid" "name": "Always snap to grid"
} }
} }

View File

@@ -2,11 +2,6 @@ import { definePreset } from '@primevue/themes'
import Aura from '@primevue/themes/aura' import Aura from '@primevue/themes/aura'
import * as Sentry from '@sentry/vue' import * as Sentry from '@sentry/vue'
import { initializeApp } from 'firebase/app' import { initializeApp } from 'firebase/app'
import {
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
} from 'firebase/auth'
import { createPinia } from 'pinia' import { createPinia } from 'pinia'
import 'primeicons/primeicons.css' import 'primeicons/primeicons.css'
import PrimeVue from 'primevue/config' import PrimeVue from 'primevue/config'
@@ -14,7 +9,7 @@ import ConfirmationService from 'primevue/confirmationservice'
import ToastService from 'primevue/toastservice' import ToastService from 'primevue/toastservice'
import Tooltip from 'primevue/tooltip' import Tooltip from 'primevue/tooltip'
import { createApp } from 'vue' import { createApp } from 'vue'
import { VueFire, VueFireAuthWithDependencies } from 'vuefire' import { VueFire, VueFireAuth } from 'vuefire'
import { FIREBASE_CONFIG } from '@/config/firebase' import { FIREBASE_CONFIG } from '@/config/firebase'
import '@/lib/litegraph/public/css/litegraph.css' import '@/lib/litegraph/public/css/litegraph.css'
@@ -71,18 +66,6 @@ app
.use(i18n) .use(i18n)
.use(VueFire, { .use(VueFire, {
firebaseApp, firebaseApp,
modules: [ modules: [VueFireAuth()]
// Configure Firebase Auth persistence: localStorage first, IndexedDB last.
// Localstorage is preferred to IndexedDB for mobile Safari compatibility.
VueFireAuthWithDependencies({
dependencies: {
persistence: [
browserLocalPersistence,
browserSessionPersistence,
indexedDBLocalPersistence
]
}
})
]
}) })
.mount('#vue-app') .mount('#vue-app')

View File

@@ -1379,6 +1379,7 @@ class LayoutStoreImpl implements LayoutStore {
this.spatialIndex.update(nodeId, bounds) this.spatialIndex.update(nodeId, bounds)
ynode.set('bounds', bounds) ynode.set('bounds', bounds)
ynode.set('position', { x: bounds.x, y: bounds.y })
ynode.set('size', { width: bounds.width, height: bounds.height }) ynode.set('size', { width: bounds.width, height: bounds.height })
} }
}, this.currentActor) }, this.currentActor)

View File

@@ -1,7 +1,12 @@
<template> <template>
<div <div
class="transform-pane" class="absolute inset-0 w-full h-full pointer-events-none"
:class="{ 'transform-pane--interacting': isInteracting }" :class="
cn(
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
isLOD ? 'isLOD' : ''
)
"
:style="transformStyle" :style="transformStyle"
@pointerdown="handlePointerDown" @pointerdown="handlePointerDown"
> >
@@ -11,13 +16,15 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import { computed, provide } from 'vue' import { computed, provide } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph' import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling' import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState' import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { cn } from '@/utils/tailwindUtil'
interface TransformPaneProps { interface TransformPaneProps {
canvas?: LGraphCanvas canvas?: LGraphCanvas
@@ -34,6 +41,8 @@ const {
isNodeInViewport isNodeInViewport
} = useTransformState() } = useTransformState()
const { isLOD } = useLOD(camera)
const canvasElement = computed(() => props.canvas?.canvas) const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, { const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 200, settleDelay: 200,
@@ -59,14 +68,19 @@ const handlePointerDown = (event: PointerEvent) => {
const emit = defineEmits<{ const emit = defineEmits<{
rafStatusChange: [active: boolean] rafStatusChange: [active: boolean]
transformUpdate: [time: number] transformUpdate: []
}>() }>()
useCanvasTransformSync(props.canvas, syncWithCanvas, { useRafFn(
onStart: () => emit('rafStatusChange', true), () => {
onUpdate: (duration) => emit('transformUpdate', duration), if (!props.canvas) {
onStop: () => emit('rafStatusChange', false) return
}) }
syncWithCanvas(props.canvas)
emit('transformUpdate')
},
{ immediate: true }
)
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,115 +0,0 @@
import { onUnmounted, ref } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
interface CanvasTransformSyncOptions {
/**
* Whether to automatically start syncing when canvas is available
* @default true
*/
autoStart?: boolean
}
interface CanvasTransformSyncCallbacks {
/**
* Called when sync starts
*/
onStart?: () => void
/**
* Called after each sync update with timing information
*/
onUpdate?: (duration: number) => void
/**
* Called when sync stops
*/
onStop?: () => void
}
/**
* Manages requestAnimationFrame-based synchronization with LiteGraph canvas transforms.
*
* This composable provides a clean way to sync Vue transform state with LiteGraph canvas
* on every frame. It handles RAF lifecycle management, provides performance timing,
* and ensures proper cleanup.
*
* The sync function typically reads canvas.ds (draw state) properties like offset and scale
* to keep Vue components aligned with the canvas coordinate system.
*
* @example
* ```ts
* const { isActive, startSync, stopSync } = useCanvasTransformSync(
* canvas,
* (canvas) => syncWithCanvas(canvas),
* {
* onStart: () => emit('rafStatusChange', true),
* onUpdate: (time) => emit('transformUpdate', time),
* onStop: () => emit('rafStatusChange', false)
* }
* )
* ```
*/
export function useCanvasTransformSync(
canvas: LGraphCanvas | undefined | null,
syncFn: (canvas: LGraphCanvas) => void,
callbacks: CanvasTransformSyncCallbacks = {},
options: CanvasTransformSyncOptions = {}
) {
const { autoStart = true } = options
const { onStart, onUpdate, onStop } = callbacks
const isActive = ref(false)
let rafId: number | null = null
const startSync = () => {
if (isActive.value || !canvas) return
isActive.value = true
onStart?.()
const sync = () => {
if (!isActive.value || !canvas) return
try {
const startTime = performance.now()
syncFn(canvas)
const endTime = performance.now()
onUpdate?.(endTime - startTime)
} catch (error) {
console.warn('Canvas transform sync error:', error)
}
rafId = requestAnimationFrame(sync)
}
sync()
}
const stopSync = () => {
if (!isActive.value) return
if (rafId !== null) {
cancelAnimationFrame(rafId)
rafId = null
}
isActive.value = false
onStop?.()
}
// Auto-start if canvas is available and autoStart is enabled
if (autoStart && canvas) {
startSync()
}
// Clean up on unmount
onUnmounted(() => {
stopSync()
})
return {
isActive,
startSync,
stopSync
}
}

View File

@@ -1,7 +1,7 @@
import { useRafFn } from '@vueuse/core'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraph } from '@/lib/litegraph/src/litegraph' import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { import {
calculateMinimapScale, calculateMinimapScale,
@@ -124,9 +124,8 @@ export function useMinimapViewport(
c.setDirty(true, true) c.setDirty(true, true)
} }
const { resume: startViewportSync, pause: stopViewportSync } =
const { startSync: startViewportSync, stopSync: stopViewportSync } = useRafFn(updateViewport)
useCanvasTransformSync(updateViewport, { autoStart: false })
return { return {
bounds: computed(() => bounds.value), bounds: computed(() => bounds.value),

View File

@@ -35,7 +35,7 @@
v-else v-else
:src="currentImageUrl" :src="currentImageUrl"
:alt="imageAltText" :alt="imageAltText"
class="w-full h-[352px] object-cover block" class="w-full h-[352px] object-contain block"
@load="handleImageLoad" @load="handleImageLoad"
@error="handleImageError" @error="handleImageError"
/> />
@@ -94,17 +94,20 @@
</div> </div>
</div> </div>
<!-- Image Dimensions --> <div class="relative">
<div class="text-white text-xs text-center mt-2"> <!-- Image Dimensions -->
<span v-if="imageError" class="text-red-400"> <div class="text-white text-xs text-center mt-2">
{{ $t('g.errorLoadingImage') }} <span v-if="imageError" class="text-red-400">
</span> {{ $t('g.errorLoadingImage') }}
<span v-else-if="isLoading" class="text-gray-400"> </span>
{{ $t('g.loading') }}... <span v-else-if="isLoading" class="text-gray-400">
</span> {{ $t('g.loading') }}...
<span v-else> </span>
{{ actualDimensions || $t('g.calculatingDimensions') }} <span v-else>
</span> {{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
</div> </div>
</div> </div>
</template> </template>
@@ -119,6 +122,8 @@ import { downloadFile } from '@/base/common/downloadUtil'
import { useCommandStore } from '@/stores/commandStore' import { useCommandStore } from '@/stores/commandStore'
import { useNodeOutputStore } from '@/stores/imagePreviewStore' import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './LODFallback.vue'
interface ImagePreviewProps { interface ImagePreviewProps {
/** Array of image URLs to display */ /** Array of image URLs to display */
readonly imageUrls: readonly string[] readonly imageUrls: readonly string[]

View File

@@ -10,12 +10,15 @@
/> />
<!-- Slot Name --> <!-- Slot Name -->
<span <div class="relative">
v-if="!dotOnly" <span
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200" v-if="!dotOnly"
> class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
{{ slotData.localized_name || slotData.name || `Input ${index}` }} >
</span> {{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
<LODFallback />
</div>
</div> </div>
</template> </template>
@@ -38,6 +41,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue' import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps { interface InputSlotProps {

View File

@@ -23,7 +23,7 @@
bypassed, bypassed,
'will-change-transform': isDragging 'will-change-transform': isDragging
}, },
lodCssClass,
shouldHandleNodePointerEvents shouldHandleNodePointerEvents
? 'pointer-events-auto' ? 'pointer-events-auto'
: 'pointer-events-none' : 'pointer-events-none'
@@ -31,7 +31,7 @@
" "
:style="[ :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 zIndex: zIndex
}, },
dragStyle dragStyle
@@ -48,10 +48,9 @@
</template> </template>
<!-- Header only updates on title/color changes --> <!-- Header only updates on title/color changes -->
<NodeHeader <NodeHeader
v-memo="[nodeData.title, lodLevel, isCollapsed]" v-memo="[nodeData.title, isCollapsed]"
:node-data="nodeData" :node-data="nodeData"
:readonly="readonly" :readonly="readonly"
:lod-level="lodLevel"
:collapsed="isCollapsed" :collapsed="isCollapsed"
@collapse="handleCollapse" @collapse="handleCollapse"
@update:title="handleHeaderTitleUpdate" @update:title="handleHeaderTitleUpdate"
@@ -60,9 +59,7 @@
</div> </div>
<div <div
v-if=" v-if="isCollapsed && executing && progress !== undefined"
(isMinimalLOD || isCollapsed) && executing && progress !== undefined
"
:class=" :class="
cn( cn(
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full', 'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
@@ -72,7 +69,7 @@
:style="{ width: `${Math.min(progress * 100, 100)}%` }" :style="{ width: `${Math.min(progress * 100, 100)}%` }"
/> />
<template v-if="!isMinimalLOD && !isCollapsed"> <template v-if="!isCollapsed">
<div class="mb-4 relative"> <div class="mb-4 relative">
<div :class="separatorClasses" /> <div :class="separatorClasses" />
<!-- Progress bar for executing state --> <!-- Progress bar for executing state -->
@@ -96,28 +93,24 @@
> >
<!-- Slots only rendered at full detail --> <!-- Slots only rendered at full detail -->
<NodeSlots <NodeSlots
v-if="shouldRenderSlots" v-memo="[nodeData.inputs?.length, nodeData.outputs?.length]"
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length, lodLevel]"
:node-data="nodeData" :node-data="nodeData"
:readonly="readonly" :readonly="readonly"
:lod-level="lodLevel"
/> />
<!-- Widgets rendered at reduced+ detail --> <!-- Widgets rendered at reduced+ detail -->
<NodeWidgets <NodeWidgets
v-if="shouldShowWidgets" v-if="nodeData.widgets?.length"
v-memo="[nodeData.widgets?.length, lodLevel]" v-memo="[nodeData.widgets?.length]"
:node-data="nodeData" :node-data="nodeData"
:readonly="readonly" :readonly="readonly"
:lod-level="lodLevel"
/> />
<!-- Custom content at reduced+ detail --> <!-- Custom content at reduced+ detail -->
<NodeContent <NodeContent
v-if="shouldShowContent" v-if="hasCustomContent"
:node-data="nodeData" :node-data="nodeData"
:readonly="readonly" :readonly="readonly"
:lod-level="lodLevel"
:image-urls="nodeImageUrls" :image-urls="nodeImageUrls"
/> />
<!-- Live preview image --> <!-- Live preview image -->
@@ -152,7 +145,6 @@ import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/compo
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking' import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState' import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout' import { useNodeLayout } from '@/renderer/extensions/vueNodes/layout/useNodeLayout'
import { LODLevel, useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState' import { useNodePreviewState } from '@/renderer/extensions/vueNodes/preview/useNodePreviewState'
import { app } from '@/scripts/app' import { app } from '@/scripts/app'
import { useExecutionStore } from '@/stores/executionStore' import { useExecutionStore } from '@/stores/executionStore'
@@ -172,8 +164,6 @@ import SlotConnectionDot from './SlotConnectionDot.vue'
// Extended props for main node component // Extended props for main node component
interface LGraphNodeProps { interface LGraphNodeProps {
nodeData: VueNodeData nodeData: VueNodeData
position?: { x: number; y: number }
size?: { width: number; height: number }
readonly?: boolean readonly?: boolean
error?: string | null error?: string | null
zoomLevel?: number zoomLevel?: number
@@ -181,11 +171,8 @@ interface LGraphNodeProps {
const { const {
nodeData, nodeData,
position = { x: 0, y: 0 },
size = { width: 100, height: 50 },
error = null, error = null,
readonly = false, readonly = false
zoomLevel = 1
} = defineProps<LGraphNodeProps>() } = defineProps<LGraphNodeProps>()
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } = const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } =
@@ -222,18 +209,6 @@ const bypassed = computed((): boolean => nodeData.mode === 4)
// Use canvas interactions for proper wheel event handling and pointer event capture control // Use canvas interactions for proper wheel event handling and pointer event capture control
const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions() const { handleWheel, shouldHandleNodePointerEvents } = useCanvasInteractions()
// LOD (Level of Detail) system based on zoom level
const {
lodLevel,
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
lodCssClass
} = useLOD(() => zoomLevel)
// Computed properties for template usage
const isMinimalLOD = computed(() => lodLevel.value === LODLevel.MINIMAL)
// Error boundary implementation // Error boundary implementation
const renderError = ref<string | null>(null) const renderError = ref<string | null>(null)
const { toastErrorHandler } = useErrorHandling() const { toastErrorHandler } = useErrorHandling()
@@ -245,11 +220,7 @@ onErrorCaptured((error) => {
}) })
// Use layout system for node position and dragging // Use layout system for node position and dragging
const { const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
position: layoutPosition,
zIndex,
resize
} = useNodeLayout(() => nodeData.id)
const { const {
handlePointerDown, handlePointerDown,
handlePointerUp, handlePointerUp,
@@ -259,11 +230,11 @@ const {
} = useNodePointerInteractions(() => nodeData, handleNodeSelect) } = useNodePointerInteractions(() => nodeData, handleNodeSelect)
onMounted(() => { onMounted(() => {
if (size && transformState?.camera) { if (size.value && transformState?.camera) {
const scale = transformState.camera.z const scale = transformState.camera.z
const screenSize = { const screenSize = {
width: size.width * scale, width: size.value.width * scale,
height: size.height * scale height: size.value.height * scale
} }
resize(screenSize) resize(screenSize)
} }
@@ -279,28 +250,17 @@ const hasCustomContent = computed(() => {
}) })
// Computed classes and conditions for better reusability // Computed classes and conditions for better reusability
const separatorClasses = cn( const separatorClasses =
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full' 'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full lod-toggle'
) const progressClasses = 'h-2 bg-primary-500 transition-all duration-300'
const progressClasses = cn('h-2 bg-primary-500 transition-all duration-300')
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState( const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
() => nodeData.id, () => nodeData.id,
{ {
isMinimalLOD,
isCollapsed isCollapsed
} }
) )
// Common condition computations to avoid repetition
const shouldShowWidgets = computed(
() => shouldRenderWidgets.value && nodeData.widgets?.length
)
const shouldShowContent = computed(
() => shouldRenderContent.value && hasCustomContent.value
)
const borderClass = computed(() => { const borderClass = computed(() => {
if (hasAnyError.value) { if (hasAnyError.value) {
return 'border-error' return 'border-error'

View File

@@ -0,0 +1,3 @@
<template>
<div class="lod-fallback absolute inset-0 w-full h-full bg-zinc-800"></div>
</template>

View File

@@ -21,7 +21,6 @@ import { computed, onErrorCaptured, ref } from 'vue'
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager' import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph' import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
import ImagePreview from './ImagePreview.vue' import ImagePreview from './ImagePreview.vue'
@@ -29,7 +28,6 @@ interface NodeContentProps {
node?: LGraphNode // For backwards compatibility node?: LGraphNode // For backwards compatibility
nodeData?: VueNodeData // New clean data structure nodeData?: VueNodeData // New clean data structure
readonly?: boolean readonly?: boolean
lodLevel?: LODLevel
imageUrls?: string[] imageUrls?: string[]
} }

View File

@@ -4,41 +4,44 @@
</div> </div>
<div <div
v-else v-else
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move w-full" class="lg-node-header p-4 rounded-t-2xl cursor-move"
:data-testid="`node-header-${nodeData?.id || ''}`" :data-testid="`node-header-${nodeData?.id || ''}`"
@dblclick="handleDoubleClick" @dblclick="handleDoubleClick"
> >
<!-- Collapse/Expand Button --> <div class="flex items-center justify-between relative">
<button <!-- Collapse/Expand Button -->
v-show="!readonly" <button
class="bg-transparent border-transparent flex items-center" v-show="!readonly"
data-testid="node-collapse-button" class="bg-transparent border-transparent flex items-center lod-toggle"
@click.stop="handleCollapse" data-testid="node-collapse-button"
@dblclick.stop @click.stop="handleCollapse"
> @dblclick.stop
<i >
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'" <i
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300" :class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
></i> class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
</button> ></i>
</button>
<!-- Node Title --> <!-- Node Title -->
<div <div
v-tooltip.top="tooltipConfig" v-tooltip.top="tooltipConfig"
class="text-sm font-bold truncate flex-1" class="text-sm font-bold truncate flex-1 lod-toggle"
data-testid="node-title" data-testid="node-title"
> >
<EditableText <EditableText
:model-value="displayTitle" :model-value="displayTitle"
:is-editing="isEditing" :is-editing="isEditing"
:input-attrs="{ 'data-testid': 'node-title-input' }" :input-attrs="{ 'data-testid': 'node-title-input' }"
@edit="handleTitleEdit" @edit="handleTitleEdit"
@cancel="handleTitleCancel" @cancel="handleTitleCancel"
/> />
</div>
<LODFallback />
</div> </div>
<!-- Title Buttons --> <!-- Title Buttons -->
<div v-if="!readonly" class="flex items-center"> <div v-if="!readonly" class="flex items-center lod-toggle">
<IconButton <IconButton
v-if="isSubgraphNode" v-if="isSubgraphNode"
size="sm" size="sm"
@@ -69,6 +72,8 @@ import {
getNodeByLocatorId getNodeByLocatorId
} from '@/utils/graphTraversalUtil' } from '@/utils/graphTraversalUtil'
import LODFallback from './LODFallback.vue'
interface NodeHeaderProps { interface NodeHeaderProps {
nodeData?: VueNodeData nodeData?: VueNodeData
readonly?: boolean readonly?: boolean

View File

@@ -19,9 +19,10 @@
<div <div
v-for="(widget, index) in processedWidgets" v-for="(widget, index) in processedWidgets"
:key="`widget-${index}-${widget.name}`" :key="`widget-${index}-${widget.name}`"
class="lg-widget-container relative flex items-center group" class="lg-widget-container flex items-center group"
> >
<!-- Widget Input Slot Dot --> <!-- Widget Input Slot Dot -->
<div <div
class="opacity-0 group-hover:opacity-100 transition-opacity duration-150" class="opacity-0 group-hover:opacity-100 transition-opacity duration-150"
> >
@@ -61,12 +62,10 @@ import type {
import { useErrorHandling } from '@/composables/useErrorHandling' import { useErrorHandling } from '@/composables/useErrorHandling'
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions' import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips' import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
// Import widget components directly // Import widget components directly
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue' import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
import { import {
getComponent, getComponent,
isEssential,
shouldRenderAsVue shouldRenderAsVue
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry' } from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget' import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
@@ -77,10 +76,9 @@ import InputSlot from './InputSlot.vue'
interface NodeWidgetsProps { interface NodeWidgetsProps {
nodeData?: VueNodeData nodeData?: VueNodeData
readonly?: boolean readonly?: boolean
lodLevel?: LODLevel
} }
const { nodeData, readonly, lodLevel } = defineProps<NodeWidgetsProps>() const { nodeData, readonly } = defineProps<NodeWidgetsProps>()
const { shouldHandleNodePointerEvents, forwardEventToCanvas } = const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
useCanvasInteractions() useCanvasInteractions()
@@ -125,18 +123,12 @@ const processedWidgets = computed((): ProcessedWidget[] => {
const widgets = nodeData.widgets as SafeWidgetData[] const widgets = nodeData.widgets as SafeWidgetData[]
const result: ProcessedWidget[] = [] const result: ProcessedWidget[] = []
if (lodLevel === LODLevel.MINIMAL) {
return []
}
for (const widget of widgets) { for (const widget of widgets) {
if (widget.options?.hidden) continue if (widget.options?.hidden) continue
if (widget.options?.canvasOnly) continue if (widget.options?.canvasOnly) continue
if (!widget.type) continue if (!widget.type) continue
if (!shouldRenderAsVue(widget)) continue if (!shouldRenderAsVue(widget)) continue
if (lodLevel === LODLevel.REDUCED && !isEssential(widget.type)) continue
const vueComponent = getComponent(widget.type) || WidgetInputText const vueComponent = getComponent(widget.type) || WidgetInputText
const simplified: SimplifiedWidget = { const simplified: SimplifiedWidget = {

View File

@@ -1,14 +1,16 @@
<template> <template>
<div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div> <div v-if="renderError" class="node-error p-1 text-red-500 text-xs"></div>
<div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass"> <div v-else v-tooltip.right="tooltipConfig" :class="slotWrapperClass">
<!-- Slot Name --> <div class="relative">
<span <!-- Slot Name -->
v-if="!dotOnly" <span
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200" v-if="!dotOnly"
> class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200 lod-toggle"
{{ slotData.name || `Output ${index}` }} >
</span> {{ slotData.name || `Output ${index}` }}
</span>
<LODFallback />
</div>
<!-- Connection Dot --> <!-- Connection Dot -->
<SlotConnectionDot <SlotConnectionDot
ref="connectionDotRef" ref="connectionDotRef"
@@ -38,6 +40,7 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction' import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
import { cn } from '@/utils/tailwindUtil' import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue' import SlotConnectionDot from './SlotConnectionDot.vue'
interface OutputSlotProps { interface OutputSlotProps {

View File

@@ -24,6 +24,7 @@ defineExpose({
> >
<div <div
ref="slot-el" ref="slot-el"
class="slot-dot"
:style="{ backgroundColor: color }" :style="{ backgroundColor: color }"
:class=" :class="
cn( cn(

View File

@@ -1,295 +1,141 @@
# Level of Detail (LOD) Implementation Guide for Widgets # ComfyUI Widget LOD System: Architecture and Implementation
## What is Level of Detail (LOD)? ## Executive Summary
Level of Detail is a technique used to optimize performance by showing different amounts of detail based on how zoomed in the user is. Think of it like Google Maps - when you're zoomed out looking at the whole country, you only see major cities and highways. When you zoom in close, you see street names, building details, and restaurants. The ComfyUI widget Level of Detail (LOD) system has evolved from a reactive, Vue-based approach to a CSS-driven, non-reactive implementation. This architectural shift was driven by performance requirements at scale (300-500+ nodes) and a deeper understanding of browser rendering pipelines. The current system prioritizes consistent performance over granular control, leveraging CSS visibility rules rather than component mounting/unmounting.
For ComfyUI nodes, this means: ## The Two Approaches: Reactive vs. Static LOD
- **Zoomed out** (viewing many nodes): Show only essential controls, hide labels and descriptions
- **Zoomed in** (focusing on specific nodes): Show all details, labels, help text, and visual polish
## Why LOD Matters ### Approach 1: Reactive LOD (Original Design)
Without LOD optimization: The original design envisioned a system where each widget would reactively respond to zoom level changes, controlling its own detail level through Vue's reactivity system. Widgets would import LOD utilities, compute what to show based on zoom level, and conditionally render elements using `v-if` and `v-show` directives.
- 1000+ nodes with full detail = browser lag and poor performance
- Text that's too small to read still gets rendered (wasted work)
- Visual effects that are invisible at distance still consume GPU
With LOD optimization: **The promise of this approach was compelling:** widgets could intelligently manage their complexity, progressively revealing detail as users zoomed in, much like how mapping applications work. Developers would have fine-grained control over performance optimization.
- Smooth performance even with large node graphs
- Battery life improvement on laptops
- Better user experience across different zoom levels
## How to Implement LOD in Your Widget ### Approach 2: Static LOD with CSS (Current Implementation)
### Step 1: Get the LOD Context The implemented system takes a fundamentally different approach. All widget content is loaded and remains in the DOM at all times. Visual simplification happens through CSS rules, primarily using `visibility: hidden` and simplified visual representations (gray rectangles) at distant zoom levels. No reactive updates occur when zoom changes—only CSS rules apply differently.
Every widget component gets a `zoomLevel` prop. Use this to determine how much detail to show: **This approach seems counterintuitive at first:** aren't we wasting resources by keeping everything loaded? The answer reveals a deeper truth about modern browser rendering.
```vue ## The GPU Texture Bottleneck
<script setup lang="ts">
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
const props = defineProps<{ The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
widget: any
zoomLevel: number
// ... other props
}>()
// Get LOD information When you apply a CSS transform to a parent element (the "transformpane" in ComfyUI's case), the browser promotes that entire subtree to a compositor layer. This creates a single GPU texture containing all the transformed content. Here's where traditional performance intuitions break down:
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
</script>
```
**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions ### Traditional Assumption
**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions
### Step 2: Choose What to Show at Different Zoom Levels "If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
#### Understanding the LOD Score ### Actual Browser Behavior
- `lodScore` is a number from 0 to 1
- 0 = completely zoomed out (show minimal detail)
- 1 = fully zoomed in (show everything)
- 0.5 = medium zoom (show some details)
#### Understanding LOD Levels When all nodes are children of a single transformed parent:
- `'minimal'` = zoom level 0.4 or below (very zoomed out)
- `'reduced'` = zoom level 0.4 to 0.8 (medium zoom)
- `'full'` = zoom level 0.8 or above (zoomed in close)
### Step 3: Implement Your Widget's LOD Strategy 1. The browser creates one large GPU texture for the entire node graph
2. The texture dimensions are determined by the bounding box of all content
3. Whether individual pixels are simple (solid rectangles) or complex (detailed widgets) has minimal impact
4. The performance bottleneck is the texture size itself, not the complexity of rasterization
Here's a complete example of a slider widget with LOD: This means that even if we reduce every node to a simple gray rectangle, we're still paying the cost of a massive GPU texture when viewing hundreds of nodes simultaneously. The texture dimensions remain the same whether it contains simple or complex content.
```vue ## Two Distinct Performance Concerns
<template>
<div class="number-widget">
<!-- The main control always shows -->
<input
v-model="value"
type="range"
:min="widget.min"
:max="widget.max"
class="widget-slider"
/>
<!-- Show label only when zoomed in enough to read it -->
<label
v-if="showLabel"
class="widget-label"
>
{{ widget.name }}
</label>
<!-- Show precise value only when fully zoomed in -->
<span
v-if="showValue"
class="widget-value"
>
{{ formattedValue }}
</span>
<!-- Show description only at full detail -->
<div
v-if="showDescription && widget.description"
class="widget-description"
>
{{ widget.description }}
</div>
</div>
</template>
<script setup lang="ts"> The analysis reveals two often-conflated performance considerations that should be understood separately:
import { computed, toRef } from 'vue'
import { useLOD } from '@/composables/graph/useLOD'
const props = defineProps<{ ### 1. Rendering Performance
widget: any
zoomLevel: number
}>()
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel)) **Question:** How fast can the browser paint and composite the node graph during interactions?
// Define when to show each element **Traditional thinking:** Show less content → render faster
const showLabel = computed(() => { **Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
// Show label when user can actually read it
return lodScore.value > 0.4 // Roughly 12px+ text size
})
const showValue = computed(() => { The CSS transform approach means that zoom, pan, and drag operations are already optimized—they're just transforming an existing GPU texture. The cost is in the initial rasterization and texture upload, which happens regardless of content complexity when texture dimensions are fixed.
// Show precise value only when zoomed in close
return lodScore.value > 0.7 // User is focused on this specific widget
})
const showDescription = computed(() => { ### 2. Memory and Lifecycle Management
// Description only at full detail
return lodLevel.value === 'full' // Maximum zoom level
})
// You can also use LOD for styling **Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
const widgetClasses = computed(() => {
const classes = ['number-widget']
if (lodLevel.value === 'minimal') {
classes.push('widget--minimal')
}
return classes
})
</script>
<style scoped> This is where unmounting widgets might theoretically help:
/* Apply different styles based on LOD */
.widget--minimal {
/* Simplified appearance when zoomed out */
.widget-slider {
height: 4px; /* Thinner slider */
opacity: 0.9;
}
}
/* Normal styling */ - Complex widgets (3D viewers, chart renderers) might hold significant memory
.widget-slider { - Event listeners and reactive watchers consume resources
height: 8px; - Some widgets might run background processes or animations
transition: height 0.2s ease;
}
.widget-label { However, the cost of mounting/unmounting hundreds of widgets on zoom changes could create worse performance problems than the memory savings provide. Vue's virtual DOM diffing for hundreds of nodes is expensive, potentially causing noticeable lag during zoom transitions.
font-size: 0.8rem;
color: var(--text-secondary);
}
.widget-value { ## Design Philosophy and Trade-offs
font-family: monospace;
font-size: 0.7rem;
color: var(--text-accent);
}
.widget-description { The current CSS-based approach makes several deliberate trade-offs:
font-size: 0.6rem;
color: var(--text-muted);
margin-top: 4px;
}
</style>
```
## Common LOD Patterns ### What We Optimize For
### Pattern 1: Essential vs. Nice-to-Have 1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
```typescript 2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
// Always show the main functionality 3. **Simple widget development** - Widget authors don't need to implement LOD logic
const showMainControl = computed(() => true) 4. **Reliable state preservation** - Widgets never lose state from unmounting
// Granular control with lodScore ### What We Accept
const showLabels = computed(() => lodScore.value > 0.4)
const labelOpacity = computed(() => Math.max(0.3, lodScore.value))
// Simple control with lodLevel 1. **Higher baseline memory usage** - All widgets remain mounted
const showExtras = computed(() => lodLevel.value === 'full') 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 ## Open Questions and Future Considerations
```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))
})
```
### Pattern 3: Progressive Detail ### Should widgets have any LOD control?
```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'
})
```
## 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 **Scenario:** A widget renders a complex 3D scene or runs expensive computations
- **Always show**: The input field itself **Current behavior:** Hidden via CSS but still mounted
- **Medium zoom**: Show label **Question:** Should such widgets be able to opt into unmounting at distance?
- **High zoom**: Show placeholder text, validation messages
- **Full zoom**: Show character count, format hints
### Button Widgets The challenge is that introducing selective unmounting would require:
- **Always show**: The button
- **Medium zoom**: Show button text
- **High zoom**: Show button description
- **Full zoom**: Show keyboard shortcuts, tooltips
### Selection Widgets (Dropdown, Radio) - Maintaining widget state across mount/unmount cycles
- **Always show**: The current selection - Accepting the performance cost of remounting when zooming in
- **Medium zoom**: Show option labels - Adding complexity to the widget API
- **High zoom**: Show all options when expanded
- **Full zoom**: Show option descriptions, icons
### Complex Widgets (Color Picker, File Browser) ### Could we reduce GPU texture size?
- **Always show**: Simplified representation (color swatch, filename)
- **Medium zoom**: Show basic controls
- **High zoom**: Show full interface
- **Full zoom**: Show advanced options, previews
## Design Collaboration Guidelines Since texture dimensions are the limiting factor, could we:
### For Designers - Use multiple compositor layers for different regions (chunk the transformpane)?
When designing widgets, consider creating variants for different zoom levels: - Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
1. **Minimal Design** (far away view) These approaches would require significant architectural changes and might introduce their own performance trade-offs.
- Essential elements only
- Higher contrast for visibility
- Simplified shapes and fewer details
2. **Standard Design** (normal view) ### Is there a hybrid approach?
- Balanced detail and simplicity
- Clear labels and readable text
- Good for most use cases
3. **Full Detail Design** (close-up view) Could we identify specific threshold scenarios where reactive LOD makes sense?
- All labels, descriptions, and help text
- Rich visual effects and polish
- Maximum information density
### Design Handoff Checklist - When node count is low (< 50 nodes)
- [ ] Specify which elements are essential vs. nice-to-have - For specifically registered "expensive" widgets
- [ ] Define minimum readable sizes for text elements - At extreme zoom levels only
- [ ] Provide simplified versions for distant viewing
- [ ] Consider color contrast at different opacity levels
- [ ] Test designs at multiple zoom levels
## Testing Your LOD Implementation ## Implementation Guidelines
### Manual Testing Given the current architecture, here's how to work within the system:
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
### Performance Considerations ### For Widget Developers
- 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
### Common Mistakes 1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
**Don't**: Hide the main widget functionality at any zoom level 2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
**Don't**: Use complex animations that trigger at every zoom change 3. **Minimize background processing** - Assume your widget is always running
**Don't**: Make LOD thresholds too sensitive (causes flickering) 4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
**Don't**: Forget to test with real content and edge cases
**Do**: Keep essential functionality always visible ### For System Architects
**Do**: Use smooth transitions between LOD levels
**Do**: Test with varying content lengths and types
**Do**: Consider accessibility at all zoom levels
## 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 ## Conclusion
- Ask in the ComfyUI frontend Discord for LOD implementation questions
- Test your changes with the LOD debug panel (top-right in GraphCanvas) 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.
- Profile performance impact using browser dev tools
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.

View File

@@ -82,7 +82,6 @@ function useNodeEventHandlersIndividual() {
const currentCollapsed = node.flags?.collapsed ?? false const currentCollapsed = node.flags?.collapsed ?? false
if (currentCollapsed !== collapsed) { if (currentCollapsed !== collapsed) {
node.collapse() node.collapse()
nodeManager.value.scheduleUpdate(nodeId, 'critical')
} }
} }

View File

@@ -1,5 +1,11 @@
import { storeToRefs } from 'pinia' 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 { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys' import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
@@ -182,14 +188,16 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
endDrag, endDrag,
// Computed styles for Vue templates // Computed styles for Vue templates
nodeStyle: computed(() => ({ nodeStyle: computed(
position: 'absolute' as const, (): CSSProperties => ({
left: `${position.value.x}px`, position: 'absolute' as const,
top: `${position.value.y}px`, left: `${position.value.x}px`,
width: `${size.value.width}px`, top: `${position.value.y}px`,
height: `${size.value.height}px`, width: `${size.value.width}px`,
zIndex: zIndex.value, height: `${size.value.height}px`,
cursor: isDragging ? 'grabbing' : 'grab' zIndex: zIndex.value,
})) cursor: isDragging ? 'grabbing' : 'grab'
})
)
} }
} }

View File

@@ -2,186 +2,33 @@
* Level of Detail (LOD) composable for Vue-based node rendering * Level of Detail (LOD) composable for Vue-based node rendering
* *
* Provides dynamic quality adjustment based on zoom level to maintain * Provides dynamic quality adjustment based on zoom level to maintain
* performance with large node graphs. Uses zoom thresholds to determine * performance with large node graphs. Uses zoom threshold based on DPR
* how much detail to render for each node component. * to determine how much detail to render for each node component.
* * Default minFontSize = 8px
* ## LOD Levels * Default zoomThreshold = 0.57 (On a DPR = 1 monitor)
* **/
* - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content import { useDevicePixelRatio } from '@vueuse/core'
* - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots import { computed } from 'vue'
* - **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
* <NodeWidgets v-if="shouldRenderWidgets" />
* <NodeSlots v-if="shouldRenderSlots" />
* ```
*/
import { type MaybeRefOrGetter, computed, readonly, toRef } from 'vue'
export enum LODLevel { import { useSettingStore } from '@/platform/settings/settingStore'
MINIMAL = 'minimal', // zoom <= 0.4
REDUCED = 'reduced', // 0.4 < zoom <= 0.8 interface Camera {
FULL = 'full' // zoom > 0.8 z: number // zoom level
} }
interface LODConfig { export function useLOD(camera: Camera) {
renderWidgets: boolean const isLOD = computed(() => {
renderSlots: boolean const { pixelRatio } = useDevicePixelRatio()
renderContent: boolean const baseFontSize = 14
renderSlotLabels: boolean const dprAdjustment = Math.sqrt(pixelRatio.value)
renderWidgetLabels: boolean
cssClass: string
}
// LOD configuration for each level const settingStore = useSettingStore()
const LOD_CONFIGS: Record<LODLevel, LODConfig> = { const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8
[LODLevel.FULL]: { const threshold =
renderWidgets: true, Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86
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'
}
}
/** return camera.z < threshold
* 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<number>) {
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))
}) })
// Determine current LOD level based on zoom return { isLOD }
const lodLevel = computed<LODLevel>(() => {
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<LODConfig>(() => 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
} }

View File

@@ -7,7 +7,6 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore'
export const useNodePreviewState = ( export const useNodePreviewState = (
nodeIdMaybe: MaybeRefOrGetter<string>, nodeIdMaybe: MaybeRefOrGetter<string>,
options?: { options?: {
isMinimalLOD?: Ref<boolean>
isCollapsed?: Ref<boolean> isCollapsed?: Ref<boolean>
} }
) => { ) => {
@@ -32,14 +31,10 @@ export const useNodePreviewState = (
}) })
const shouldShowPreviewImg = computed(() => { const shouldShowPreviewImg = computed(() => {
if (!options?.isMinimalLOD || !options?.isCollapsed) { if (!options?.isCollapsed) {
return hasPreview.value return hasPreview.value
} }
return ( return !options.isCollapsed.value && hasPreview.value
!options.isMinimalLOD.value &&
!options.isCollapsed.value &&
hasPreview.value
)
}) })
return { return {

View File

@@ -6,7 +6,7 @@
<!-- Display mode: Rendered markdown --> <!-- Display mode: Rendered markdown -->
<div <div
v-if="!isEditing" v-if="!isEditing"
class="comfy-markdown-content text-xs min-h-[60px] rounded-lg px-4 py-2 overflow-y-auto" class="comfy-markdown-content text-xs min-h-[60px] rounded-lg px-4 py-2 overflow-y-auto lod-toggle"
v-html="renderedHtml" v-html="renderedHtml"
/> />
@@ -28,6 +28,7 @@
@click.stop @click.stop
@keydown.stop @keydown.stop
/> />
<LODFallback />
</div> </div>
</template> </template>
@@ -39,6 +40,8 @@ import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget' import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil' import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import LODFallback from '../../components/LODFallback.vue'
const props = defineProps<{ const props = defineProps<{
widget: SimplifiedWidget<string> widget: SimplifiedWidget<string>
modelValue: string modelValue: string

View File

@@ -1,14 +1,17 @@
<template> <template>
<Textarea <div class="relative">
v-model="localValue" <Textarea
v-bind="filteredProps" v-model="localValue"
:disabled="readonly" v-bind="filteredProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')" :disabled="readonly"
:placeholder="placeholder || widget.name || ''" :class="cn(WidgetInputBaseClass, 'w-full text-xs lod-toggle')"
size="small" :placeholder="placeholder || widget.name || ''"
rows="3" size="small"
@update:model-value="onChange" rows="3"
/> @update:model-value="onChange"
/>
<LODFallback />
</div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -23,6 +26,7 @@ import {
filterWidgetProps filterWidgetProps
} from '@/utils/widgetPropFilter' } from '@/utils/widgetPropFilter'
import LODFallback from '../../components/LODFallback.vue'
import { WidgetInputBaseClass } from './layout' import { WidgetInputBaseClass } from './layout'
const props = defineProps<{ const props = defineProps<{

View File

@@ -3,6 +3,8 @@ import { noop } from 'es-toolkit'
import type { SimplifiedWidget } from '@/types/simplifiedWidget' import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import LODFallback from '../../../components/LODFallback.vue'
defineProps<{ defineProps<{
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name'> widget: Pick<SimplifiedWidget<string | number | undefined>, 'name'>
}>() }>()
@@ -12,19 +14,25 @@ defineProps<{
<div <div
class="flex items-center justify-between gap-2 h-[30px] overscroll-contain" class="flex items-center justify-between gap-2 h-[30px] overscroll-contain"
> >
<p <div class="relative h-6 flex items-center mr-4">
v-if="widget.name" <p
class="text-sm text-stone-200 dark-theme:text-slate-200 font-normal flex-1 truncate w-20" v-if="widget.name"
> class="text-sm text-stone-200 dark-theme:text-slate-200 font-normal flex-1 truncate w-20 lod-toggle"
{{ widget.name }} >
</p> {{ widget.name }}
<div </p>
class="w-75 cursor-default" <LODFallback />
@pointerdown.stop="noop" </div>
@pointermove.stop="noop" <div class="relative">
@pointerup.stop="noop" <div
> class="w-75 cursor-default lod-toggle"
<slot /> @pointerdown.stop="noop"
@pointermove.stop="noop"
@pointerup.stop="noop"
>
<slot />
</div>
<LODFallback />
</div> </div>
</div> </div>
</template> </template>

View File

@@ -6,10 +6,12 @@ import {
GoogleAuthProvider, GoogleAuthProvider,
type User, type User,
type UserCredential, type UserCredential,
browserLocalPersistence,
createUserWithEmailAndPassword, createUserWithEmailAndPassword,
deleteUser, deleteUser,
onAuthStateChanged, onAuthStateChanged,
sendPasswordResetEmail, sendPasswordResetEmail,
setPersistence,
signInWithEmailAndPassword, signInWithEmailAndPassword,
signInWithPopup, signInWithPopup,
signOut, signOut,
@@ -80,6 +82,8 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
// Retrieves the Firebase Auth instance. Returns `null` on the server. // Retrieves the Firebase Auth instance. Returns `null` on the server.
// When using this function on the client in TypeScript, you can force the type with `useFirebaseAuth()!`. // When using this function on the client in TypeScript, you can force the type with `useFirebaseAuth()!`.
const auth = useFirebaseAuth()! const auth = useFirebaseAuth()!
// Set persistence to localStorage (works in both browser and Electron)
void setPersistence(auth, browserLocalPersistence)
onAuthStateChanged(auth, (user) => { onAuthStateChanged(auth, (user) => {
currentUser.value = user currentUser.value = user

View File

@@ -4,16 +4,3 @@ export enum ValidationState {
VALID = 'VALID', VALID = 'VALID',
INVALID = 'INVALID' INVALID = 'INVALID'
} }
export const mergeValidationStates = (states: ValidationState[]) => {
if (states.some((state) => state === ValidationState.INVALID)) {
return ValidationState.INVALID
}
if (states.some((state) => state === ValidationState.LOADING)) {
return ValidationState.LOADING
}
if (states.every((state) => state === ValidationState.VALID)) {
return ValidationState.VALID
}
return ValidationState.IDLE
}

View File

@@ -1,11 +1,11 @@
<template> <template>
<BaseViewTemplate dark> <BaseViewTemplate dark>
<ProgressSpinner class="m-8 w-48 h-48" /> <StartupDisplay :title="$t('desktopStart.initialising')" />
</BaseViewTemplate> </BaseViewTemplate>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import ProgressSpinner from 'primevue/progressspinner' import StartupDisplay from '@/components/common/StartupDisplay.vue'
import BaseViewTemplate from './templates/BaseViewTemplate.vue' import BaseViewTemplate from './templates/BaseViewTemplate.vue'
</script> </script>

View File

@@ -0,0 +1,423 @@
// eslint-disable-next-line storybook/no-renderer-packages
import type { Meta, StoryObj } from '@storybook/vue3'
import { nextTick, provide } from 'vue'
import { createMemoryHistory, createRouter } from 'vue-router'
import InstallView from './InstallView.vue'
// Create a mock router for stories
const createMockRouter = () =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/', component: { template: '<div>Home</div>' } },
{
path: '/server-start',
component: { template: '<div>Server Start</div>' }
},
{
path: '/manual-configuration',
component: { template: '<div>Manual Configuration</div>' }
}
]
})
const meta: Meta<typeof InstallView> = {
title: 'Desktop/Views/InstallView',
component: InstallView,
parameters: {
layout: 'fullscreen',
backgrounds: {
default: 'dark',
values: [
{ name: 'dark', value: '#0a0a0a' },
{ name: 'neutral-900', value: '#171717' },
{ name: 'neutral-950', value: '#0a0a0a' }
]
}
},
decorators: [
(story) => {
// Create router for this story
const router = createMockRouter()
// Mock electron API
;(window as any).electronAPI = {
getPlatform: () => 'darwin',
Config: {
getDetectedGpu: () => Promise.resolve('mps')
},
Events: {
trackEvent: (eventName: string, data?: any) => {
console.log('Track event:', eventName, data)
}
},
installComfyUI: (options: any) => {
console.log('Install ComfyUI with options:', options)
},
changeTheme: (theme: any) => {
console.log('Change theme:', theme)
},
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 {
setup() {
// Provide router for all child components
provide('router', router)
return {
story
}
},
template: '<div style="width: 100vw; height: 100vh;"><story /></div>'
}
}
]
}
export default meta
type Story = StoryObj<typeof meta>
// Default story - start at GPU selection
export const GpuSelection: Story = {
render: () => ({
components: { InstallView },
setup() {
// The component will automatically start at step 1
return {}
},
template: '<InstallView />'
})
}
// Story showing the install location step
export const InstallLocation: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll(
'.p-selectbutton-option'
)
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2
const buttons = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn) {
nextBtn.click()
}
},
template: '<InstallView />'
})
}
// Story showing the migration step (currently empty)
export const MigrationStep: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll(
'.p-selectbutton-option'
)
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2
const buttons1 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn1 = buttons1.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn1) {
nextBtn1.click()
}
await nextTick()
// Click Next again to go to step 3
const buttons2 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn2 = buttons2.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn2) {
nextBtn2.click()
}
},
template: '<InstallView />'
})
}
// Story showing the desktop settings configuration
export const DesktopSettings: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll(
'.p-selectbutton-option'
)
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2
const buttons1 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn1 = buttons1.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn1) {
nextBtn1.click()
}
await nextTick()
// Click Next again to go to step 3
const buttons2 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn2 = buttons2.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn2) {
nextBtn2.click()
}
await nextTick()
// Click Next again to go to step 4
const buttons3 = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn3 = buttons3.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn3) {
nextBtn3.click()
}
},
template: '<InstallView />'
})
}
// Story with Windows platform (no Apple Metal option)
export const WindowsPlatform: Story = {
render: () => {
// Override the platform to Windows
;(window as any).electronAPI.getPlatform = () => 'win32'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('nvidia')
return {
components: { InstallView },
setup() {
return {}
},
template: '<InstallView />'
}
}
}
// Story with macOS platform (Apple Metal option)
export const MacOSPlatform: Story = {
name: 'macOS Platform',
render: () => {
// Override the platform to macOS
;(window as any).electronAPI.getPlatform = () => 'darwin'
;(window as any).electronAPI.Config.getDetectedGpu = () =>
Promise.resolve('mps')
return {
components: { InstallView },
setup() {
return {}
},
template: '<InstallView />'
}
}
}
// Story with CPU selected
export const CpuSelected: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Find and click the CPU hardware option
const hardwareButtons = this.$el.querySelectorAll('.hardware-option')
// CPU is the button with "CPU" text
for (const button of hardwareButtons) {
if (button.textContent?.includes('CPU')) {
button.click()
break
}
}
},
template: '<InstallView />'
})
}
// Story with manual install selected
export const ManualInstall: Story = {
render: () => ({
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Find and click the Manual Install hardware option
const hardwareButtons = this.$el.querySelectorAll('.hardware-option')
// Manual Install is the button with "Manual Install" text
for (const button of hardwareButtons) {
if (button.textContent?.includes('Manual Install')) {
button.click()
break
}
}
},
template: '<InstallView />'
})
}
// Story with error state (invalid install path)
export const ErrorState: Story = {
render: () => {
// Override validation to return an error
;(window as any).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: false,
exists: false,
canWrite: false,
freeSpace: 100000000000,
requiredSpace: 10000000000,
isNonDefaultDrive: false,
error: 'Story mock: Example error state'
})
return {
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll(
'.p-selectbutton-option'
)
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2 where error will be shown
const buttons = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn) {
nextBtn.click()
}
},
template: '<InstallView />'
}
}
}
// Story with warning state (non-default drive)
export const WarningState: Story = {
render: () => {
// Override validation to return a warning about non-default drive
;(window as any).electronAPI.validateInstallPath = () =>
Promise.resolve({
isValid: true,
exists: false,
canWrite: true,
freeSpace: 500_000_000_000,
requiredSpace: 10_000_000_000,
isNonDefaultDrive: true
})
return {
components: { InstallView },
setup() {
return {}
},
async mounted() {
// Wait for component to be fully mounted
await nextTick()
// Select Apple Metal option to enable navigation
const hardwareOptions = this.$el.querySelectorAll('.hardware-option')
if (hardwareOptions.length > 0) {
hardwareOptions[0].click() // Click Apple Metal (first option)
}
await nextTick()
// Click Next to go to step 2 where warning will be shown
const buttons = Array.from(
this.$el.querySelectorAll('button')
) as HTMLButtonElement[]
const nextBtn = buttons.find((btn) => btn.textContent?.includes('Next'))
if (nextBtn) {
nextBtn.click()
}
},
template: '<InstallView />'
}
}
}

View File

@@ -1,111 +1,54 @@
<template> <template>
<BaseViewTemplate dark> <BaseViewTemplate dark>
<!-- h-full to make sure the stepper does not layout shift between steps <!-- Fixed height container with flexbox layout for proper content management -->
as for each step the stepper height is different. Inherit the center element <div class="w-full h-full flex flex-col">
placement from BaseViewTemplate would cause layout shift. --> <Stepper
<Stepper v-model:value="currentStep"
class="h-full p-8 2xl:p-16" class="flex flex-col h-full"
value="0" @update:value="handleStepChange"
@update:value="handleStepChange" >
> <!-- Main content area that grows to fill available space -->
<StepList class="select-none"> <StepPanels
<Step value="0"> class="flex-1 overflow-auto"
{{ $t('install.gpu') }} :style="{ scrollbarGutter: 'stable' }"
</Step> >
<Step value="1" :disabled="noGpu"> <StepPanel value="1" class="flex">
{{ $t('install.installLocation') }} <GpuPicker v-model:device="device" />
</Step> </StepPanel>
<Step value="2" :disabled="noGpu || hasError || highestStep < 1"> <StepPanel value="2">
{{ $t('install.migration') }} <InstallLocationPicker
</Step> v-model:install-path="installPath"
<Step value="3" :disabled="noGpu || hasError || highestStep < 2"> v-model:path-error="pathError"
{{ $t('install.desktopSettings') }} v-model:migration-source-path="migrationSourcePath"
</Step> v-model:migration-item-ids="migrationItemIds"
</StepList> v-model:python-mirror="pythonMirror"
<StepPanels> v-model:pypi-mirror="pypiMirror"
<StepPanel v-slot="{ activateCallback }" value="0"> v-model:torch-mirror="torchMirror"
<GpuPicker v-model:device="device" /> :device="device"
<div class="flex pt-6 justify-end">
<Button
:label="$t('g.next')"
icon="pi pi-arrow-right"
icon-pos="right"
:disabled="typeof device !== 'string'"
@click="activateCallback('1')"
/> />
</div> </StepPanel>
</StepPanel> <StepPanel value="3">
<StepPanel v-slot="{ activateCallback }" value="1"> <DesktopSettingsConfiguration
<InstallLocationPicker v-model:auto-update="autoUpdate"
v-model:install-path="installPath" v-model:allow-metrics="allowMetrics"
v-model:path-error="pathError"
/>
<div class="flex pt-6 justify-between">
<Button
:label="$t('g.back')"
severity="secondary"
icon="pi pi-arrow-left"
@click="activateCallback('0')"
/> />
<Button </StepPanel>
:label="$t('g.next')" </StepPanels>
icon="pi pi-arrow-right"
icon-pos="right" <!-- Install footer with navigation -->
:disabled="pathError !== ''" <InstallFooter
@click="activateCallback('2')" class="w-full max-w-2xl my-6 mx-auto"
/> :current-step
</div> :can-proceed
</StepPanel> :disable-location-step="noGpu"
<StepPanel v-slot="{ activateCallback }" value="2"> :disable-migration-step="noGpu || hasError || highestStep < 2"
<MigrationPicker :disable-settings-step="noGpu || hasError || highestStep < 3"
v-model:source-path="migrationSourcePath" @previous="goToPreviousStep"
v-model:migration-item-ids="migrationItemIds" @next="goToNextStep"
/> @install="install"
<div class="flex pt-6 justify-between"> />
<Button </Stepper>
:label="$t('g.back')" </div>
severity="secondary"
icon="pi pi-arrow-left"
@click="activateCallback('1')"
/>
<Button
:label="$t('g.next')"
icon="pi pi-arrow-right"
icon-pos="right"
@click="activateCallback('3')"
/>
</div>
</StepPanel>
<StepPanel v-slot="{ activateCallback }" value="3">
<DesktopSettingsConfiguration
v-model:auto-update="autoUpdate"
v-model:allow-metrics="allowMetrics"
/>
<MirrorsConfiguration
v-model:python-mirror="pythonMirror"
v-model:pypi-mirror="pypiMirror"
v-model:torch-mirror="torchMirror"
:device="device"
class="mt-6"
/>
<div class="flex mt-6 justify-between">
<Button
:label="$t('g.back')"
severity="secondary"
icon="pi pi-arrow-left"
@click="activateCallback('2')"
/>
<Button
:label="$t('g.install')"
icon="pi pi-check"
icon-pos="right"
:disabled="hasError"
@click="install()"
/>
</div>
</StepPanel>
</StepPanels>
</Stepper>
</BaseViewTemplate> </BaseViewTemplate>
</template> </template>
@@ -114,9 +57,6 @@ import type {
InstallOptions, InstallOptions,
TorchDeviceType TorchDeviceType
} from '@comfyorg/comfyui-electron-types' } from '@comfyorg/comfyui-electron-types'
import Button from 'primevue/button'
import Step from 'primevue/step'
import StepList from 'primevue/steplist'
import StepPanel from 'primevue/steppanel' import StepPanel from 'primevue/steppanel'
import StepPanels from 'primevue/steppanels' import StepPanels from 'primevue/steppanels'
import Stepper from 'primevue/stepper' import Stepper from 'primevue/stepper'
@@ -125,9 +65,8 @@ import { useRouter } from 'vue-router'
import DesktopSettingsConfiguration from '@/components/install/DesktopSettingsConfiguration.vue' import DesktopSettingsConfiguration from '@/components/install/DesktopSettingsConfiguration.vue'
import GpuPicker from '@/components/install/GpuPicker.vue' import GpuPicker from '@/components/install/GpuPicker.vue'
import InstallFooter from '@/components/install/InstallFooter.vue'
import InstallLocationPicker from '@/components/install/InstallLocationPicker.vue' import InstallLocationPicker from '@/components/install/InstallLocationPicker.vue'
import MigrationPicker from '@/components/install/MigrationPicker.vue'
import MirrorsConfiguration from '@/components/install/MirrorsConfiguration.vue'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue' import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
@@ -145,6 +84,9 @@ const pythonMirror = ref('')
const pypiMirror = ref('') const pypiMirror = ref('')
const torchMirror = ref('') const torchMirror = ref('')
/** Current step in the stepper */
const currentStep = ref('1')
/** Forces each install step to be visited at least once. */ /** Forces each install step to be visited at least once. */
const highestStep = ref(0) const highestStep = ref(0)
@@ -164,6 +106,40 @@ const setHighestStep = (value: string | number) => {
const hasError = computed(() => pathError.value !== '') const hasError = computed(() => pathError.value !== '')
const noGpu = computed(() => typeof device.value !== 'string') const noGpu = computed(() => typeof device.value !== 'string')
// Computed property to determine if user can proceed to next step
const regex = /^Insufficient space - minimum free space: \d+ GB$/
const canProceed = computed(() => {
switch (currentStep.value) {
case '1':
return typeof device.value === 'string'
case '2':
return pathError.value === '' || regex.test(pathError.value)
case '3':
return !hasError.value
default:
return false
}
})
// Navigation methods
const goToNextStep = () => {
const nextStep = (parseInt(currentStep.value) + 1).toString()
currentStep.value = nextStep
setHighestStep(nextStep)
electronAPI().Events.trackEvent('install_stepper_change', {
step: nextStep
})
}
const goToPreviousStep = () => {
const prevStep = (parseInt(currentStep.value) - 1).toString()
currentStep.value = prevStep
electronAPI().Events.trackEvent('install_stepper_change', {
step: prevStep
})
}
const electron = electronAPI() const electron = electronAPI()
const router = useRouter() const router = useRouter()
const install = async () => { const install = async () => {
@@ -195,7 +171,7 @@ onMounted(async () => {
} }
electronAPI().Events.trackEvent('install_stepper_change', { electronAPI().Events.trackEvent('install_stepper_change', {
step: '0', step: currentStep.value,
gpu: detectedGpu gpu: detectedGpu
}) })
}) })
@@ -205,6 +181,30 @@ onMounted(async () => {
@reference '../assets/css/style.css'; @reference '../assets/css/style.css';
:deep(.p-steppanel) { :deep(.p-steppanel) {
@apply mt-8 flex justify-center bg-transparent;
}
/* Remove default padding/margin from StepPanels to make scrollbar flush */
:deep(.p-steppanels) {
@apply p-0 m-0;
}
/* Ensure StepPanel content container has no top/bottom padding */
:deep(.p-steppanel-content) {
@apply p-0;
}
/* Custom overlay scrollbar for WebKit browsers (Electron, Chrome) */
:deep(.p-steppanels::-webkit-scrollbar) {
@apply w-4;
}
:deep(.p-steppanels::-webkit-scrollbar-track) {
@apply bg-transparent; @apply bg-transparent;
} }
:deep(.p-steppanels::-webkit-scrollbar-thumb) {
@apply bg-white/20 rounded-lg border-[4px] border-transparent;
background-clip: content-box;
}
</style> </style>

View File

@@ -1,76 +1,205 @@
<template> <template>
<BaseViewTemplate dark class="flex-col"> <BaseViewTemplate dark>
<div class="flex flex-col w-full h-full items-center"> <div class="relative min-h-screen">
<h2 class="text-2xl font-bold"> <!-- Terminal Background Layer (always visible during loading) -->
{{ t(`serverStart.process.${status}`) }} <div v-if="!isError" class="fixed inset-0 overflow-hidden z-0">
<span v-if="status === ProgressStatus.ERROR"> <div class="h-full w-full">
v{{ electronVersion }} <BaseTerminal @created="terminalCreated" />
</span> </div>
</h2> </div>
<div
v-if="status === ProgressStatus.ERROR" <!-- Semi-transparent overlay -->
class="flex flex-col items-center gap-4" <div v-if="!isError" class="fixed inset-0 bg-neutral-900/80 z-5"></div>
>
<div class="flex items-center my-4 gap-2"> <!-- Smooth radial gradient overlay -->
<Button <div
icon="pi pi-flag" v-if="!isError"
severity="secondary" class="fixed inset-0 z-8"
:label="t('serverStart.reportIssue')" style="
@click="reportIssue" background: radial-gradient(
/> ellipse 800px 600px at center,
<Button rgba(23, 23, 23, 0.95) 0%,
icon="pi pi-file" rgba(23, 23, 23, 0.93) 10%,
severity="secondary" rgba(23, 23, 23, 0.9) 20%,
:label="t('serverStart.openLogs')" rgba(23, 23, 23, 0.85) 30%,
@click="openLogs" rgba(23, 23, 23, 0.75) 40%,
/> rgba(23, 23, 23, 0.6) 50%,
<Button rgba(23, 23, 23, 0.4) 60%,
icon="pi pi-wrench" rgba(23, 23, 23, 0.2) 70%,
:label="t('serverStart.troubleshoot')" rgba(23, 23, 23, 0.1) 80%,
@click="troubleshoot" rgba(23, 23, 23, 0.05) 90%,
/> transparent 100%
);
"
></div>
<div class="relative z-10">
<!-- Main startup display using StartupDisplay component -->
<StartupDisplay
:title="displayTitle"
:status-text="displayStatusText"
:progress-percentage="installStageProgress"
:hide-progress="isError"
/>
<!-- Error Section (positioned at bottom) -->
<div
v-if="isError"
class="absolute bottom-20 left-0 right-0 flex flex-col items-center gap-4"
>
<div class="flex gap-4 justify-center">
<Button
icon="pi pi-flag"
:label="$t('serverStart.reportIssue')"
severity="secondary"
@click="reportIssue"
/>
<Button
icon="pi pi-file"
:label="$t('serverStart.openLogs')"
severity="secondary"
@click="openLogs"
/>
<Button
icon="pi pi-wrench"
:label="$t('serverStart.troubleshoot')"
@click="troubleshoot"
/>
</div>
<div class="text-center">
<button
v-if="!terminalVisible"
class="text-sm text-neutral-500 hover:text-neutral-300 transition-colors flex items-center gap-2 mx-auto"
@click="terminalVisible = true"
>
<i class="pi pi-search"></i>
{{ $t('serverStart.showTerminal') }}
</button>
</div>
</div>
<!-- Terminal Output (positioned at bottom when manually toggled in error state) -->
<div
v-if="terminalVisible && isError"
class="absolute bottom-4 left-4 right-4 max-w-4xl mx-auto z-10"
>
<div
class="bg-neutral-900/95 rounded-lg p-4 border border-neutral-700 h-[300px]"
>
<BaseTerminal @created="terminalCreated" />
</div>
</div> </div>
<Button
v-if="!terminalVisible"
icon="pi pi-search"
severity="secondary"
:label="t('serverStart.showTerminal')"
@click="terminalVisible = true"
/>
</div> </div>
<BaseTerminal v-show="terminalVisible" @created="terminalCreated" />
</div> </div>
</BaseViewTemplate> </BaseViewTemplate>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ProgressStatus } from '@comfyorg/comfyui-electron-types' import {
InstallStage,
type InstallStageInfo,
type InstallStageName,
ProgressStatus
} from '@comfyorg/comfyui-electron-types'
import type { Terminal } from '@xterm/xterm' import type { Terminal } from '@xterm/xterm'
import Button from 'primevue/button' import Button from 'primevue/button'
import type { Ref } from 'vue' import type { Ref } from 'vue'
import { onMounted, ref } from 'vue' import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue' import BaseTerminal from '@/components/bottomPanel/tabs/terminal/BaseTerminal.vue'
import StartupDisplay from '@/components/common/StartupDisplay.vue'
import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal' import type { useTerminal } from '@/composables/bottomPanelTabs/useTerminal'
import { electronAPI } from '@/utils/envUtil' import { electronAPI } from '@/utils/envUtil'
import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue' import BaseViewTemplate from '@/views/templates/BaseViewTemplate.vue'
const electron = electronAPI()
const { t } = useI18n() const { t } = useI18n()
const electron = electronAPI()
const status = ref<ProgressStatus>(ProgressStatus.INITIAL_STATE) const status = ref<ProgressStatus>(ProgressStatus.INITIAL_STATE)
const electronVersion = ref<string>('') const electronVersion = ref<string>('')
const terminalVisible = ref(false)
const installStage = ref<InstallStageName | null>(null)
const installStageMessage = ref<string>('')
const installStageProgress = ref<number | undefined>(undefined)
let xterm: Terminal | undefined let xterm: Terminal | undefined
const terminalVisible = ref(true) /**
* Handles installation stage updates from the desktop
*/
const updateInstallStage = (stageInfo: InstallStageInfo) => {
console.warn('[InstallStage.onUpdate] Received:', {
stage: stageInfo.stage,
progress: stageInfo.progress,
message: stageInfo.message,
error: stageInfo.error,
timestamp: stageInfo.timestamp,
fullInfo: stageInfo
})
installStage.value = stageInfo.stage
installStageMessage.value = stageInfo.message || ''
installStageProgress.value = stageInfo.progress
}
const currentStatusLabel = computed(() => {
// Use the message from the Electron API if available
if (installStageMessage.value) {
return installStageMessage.value
}
return t(`serverStart.process.${status.value}`)
})
const isError = computed(
() =>
status.value === ProgressStatus.ERROR ||
installStage.value === InstallStage.ERROR
)
const isInstallationStage = computed(() => {
const installationStages: InstallStageName[] = [
InstallStage.WELCOME_SCREEN,
InstallStage.INSTALL_OPTIONS_SELECTION,
InstallStage.CREATING_DIRECTORIES,
InstallStage.INITIALIZING_CONFIG,
InstallStage.PYTHON_ENVIRONMENT_SETUP,
InstallStage.INSTALLING_REQUIREMENTS,
InstallStage.INSTALLING_PYTORCH,
InstallStage.INSTALLING_COMFYUI_REQUIREMENTS,
InstallStage.INSTALLING_MANAGER_REQUIREMENTS,
InstallStage.MIGRATING_CUSTOM_NODES
]
return (
installStage.value !== null &&
installationStages.includes(installStage.value)
)
})
const displayTitle = computed(() => {
if (isError.value) {
return t('serverStart.errorMessage')
}
if (isInstallationStage.value) {
return t('serverStart.installation.title')
}
return t('serverStart.title')
})
const displayStatusText = computed(() => {
if (isError.value && electronVersion.value) {
return `v${electronVersion.value}`
}
return currentStatusLabel.value
})
const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => { const updateProgress = ({ status: newStatus }: { status: ProgressStatus }) => {
status.value = newStatus status.value = newStatus
// Make critical error screen more obvious. // Make critical error screen more obvious.
if (newStatus === ProgressStatus.ERROR) terminalVisible.value = false if (newStatus === ProgressStatus.ERROR) terminalVisible.value = false
else xterm?.clear()
} }
const terminalCreated = ( const terminalCreated = (
@@ -95,9 +224,30 @@ const reportIssue = () => {
} }
const openLogs = () => electron.openLogsFolder() const openLogs = () => electron.openLogsFolder()
let cleanupInstallStageListener: (() => void) | undefined
onMounted(async () => { onMounted(async () => {
electron.sendReady() electron.sendReady()
electron.onProgressUpdate(updateProgress) electron.onProgressUpdate(updateProgress)
cleanupInstallStageListener =
electron.InstallStage.onUpdate(updateInstallStage)
const stageInfo = await electron.InstallStage.getCurrent()
updateInstallStage(stageInfo)
electronVersion.value = await electron.getElectronVersion() electronVersion.value = await electron.getElectronVersion()
}) })
onUnmounted(() => {
xterm?.dispose()
cleanupInstallStageListener?.()
})
</script> </script>
<style scoped>
@reference '../assets/css/style.css';
/* Hide the xterm scrollbar completely */
:deep(.p-terminal) .xterm-viewport {
overflow: hidden !important;
}
</style>

View File

@@ -1,21 +1,27 @@
<template> <template>
<BaseViewTemplate dark> <BaseViewTemplate dark>
<div class="flex flex-col items-center justify-center gap-8 p-8"> <div class="flex items-center justify-center min-h-screen">
<!-- Header --> <div class="grid grid-rows-2 gap-8">
<h1 class="animated-gradient-text text-glow select-none"> <!-- Top container: Logo -->
{{ $t('welcome.title') }} <div class="flex items-end justify-center">
</h1> <img
src="/assets/images/comfy-brand-mark.svg"
<!-- Get Started Button --> :alt="$t('g.logoAlt')"
<Button class="w-60"
:label="$t('welcome.getStarted')" />
icon="pi pi-arrow-right" </div>
icon-pos="right" <!-- Bottom container: Title and button -->
size="large" <div class="flex flex-col items-center justify-center gap-4">
rounded <Button
class="p-4 text-lg fade-in-up" :label="$t('welcome.getStarted')"
@click="navigateTo('/install')" class="px-8 mt-4 bg-brand-yellow hover:bg-brand-yellow/90 border-0 rounded-lg transition-colors"
/> :pt="{
label: { class: 'font-inter text-neutral-900 font-black' }
}"
@click="navigateTo('/install')"
/>
</div>
</div>
</div> </div>
</BaseViewTemplate> </BaseViewTemplate>
</template> </template>
@@ -31,49 +37,3 @@ const navigateTo = async (path: string) => {
await router.push(path) await router.push(path)
} }
</script> </script>
<style scoped>
@reference '../assets/css/style.css';
.animated-gradient-text {
@apply font-bold;
font-size: clamp(2rem, 8vw, 4rem);
background: linear-gradient(to right, #12c2e9, #c471ed, #f64f59, #12c2e9);
background-size: 300% auto;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
animation: gradient 8s linear infinite;
}
.text-glow {
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.3));
}
@keyframes gradient {
0% {
background-position: 0% center;
}
100% {
background-position: 300% center;
}
}
.fade-in-up {
animation: fadeInUp 1.5s ease-out;
animation-fill-mode: both;
}
@keyframes fadeInUp {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
</style>

View File

@@ -1,129 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
// Mock canvas store
let mockGetCanvas = vi.fn()
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
useCanvasStore: vi.fn(() => ({
getCanvas: mockGetCanvas
}))
}))
describe('useCanvasTransformSync', () => {
let mockCanvas: { ds: { scale: number; offset: [number, number] } }
let syncFn: ReturnType<typeof vi.fn>
beforeEach(() => {
mockCanvas = {
ds: {
scale: 1,
offset: [0, 0]
}
}
syncFn = vi.fn()
mockGetCanvas = vi.fn(() => mockCanvas)
vi.clearAllMocks()
})
it('should not call syncFn when transform has not changed', async () => {
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
// Should call once initially
expect(syncFn).toHaveBeenCalledTimes(1)
// Wait for next RAF cycle
await new Promise((resolve) => requestAnimationFrame(resolve))
// Should not call again since transform didn't change
expect(syncFn).toHaveBeenCalledTimes(1)
})
it('should call syncFn when scale changes', async () => {
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(1)
// Change scale
mockCanvas.ds.scale = 2
// Wait for next RAF cycle
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(syncFn).toHaveBeenCalledTimes(2)
})
it('should call syncFn when offset changes', async () => {
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(1)
// Change offset
mockCanvas.ds.offset = [10, 20]
// Wait for next RAF cycles
await new Promise((resolve) => requestAnimationFrame(resolve))
expect(syncFn).toHaveBeenCalledTimes(2)
})
it('should stop calling syncFn after stopSync is called', async () => {
const { startSync, stopSync } = useCanvasTransformSync(syncFn, {
autoStart: false
})
startSync()
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(1)
stopSync()
// Change transform after stopping
mockCanvas.ds.scale = 2
// Wait for RAF cycle
await new Promise((resolve) => requestAnimationFrame(resolve))
// Should not call again
expect(syncFn).toHaveBeenCalledTimes(1)
})
it('should handle null canvas gracefully', async () => {
mockGetCanvas.mockReturnValue(null)
const { startSync } = useCanvasTransformSync(syncFn, { autoStart: false })
startSync()
await nextTick()
// Should not call syncFn with null canvas
expect(syncFn).not.toHaveBeenCalled()
})
it('should call onStart and onStop callbacks', () => {
const onStart = vi.fn()
const onStop = vi.fn()
const { startSync, stopSync } = useCanvasTransformSync(syncFn, {
autoStart: false,
onStart,
onStop
})
startSync()
expect(onStart).toHaveBeenCalledTimes(1)
stopSync()
expect(onStop).toHaveBeenCalledTimes(1)
})
})

View File

@@ -1,240 +0,0 @@
import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick } from 'vue'
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
import type { LGraphCanvas } from '../../../../src/lib/litegraph/src/litegraph'
// Mock LiteGraph canvas
const createMockCanvas = (): Partial<LGraphCanvas> => ({
canvas: document.createElement('canvas'),
ds: {
offset: [0, 0],
scale: 1
} as any // Mock the DragAndScale type
})
describe('useCanvasTransformSync', () => {
let mockCanvas: LGraphCanvas
let syncFn: ReturnType<typeof vi.fn>
let callbacks: {
onStart: ReturnType<typeof vi.fn>
onUpdate: ReturnType<typeof vi.fn>
onStop: ReturnType<typeof vi.fn>
}
beforeEach(() => {
vi.useFakeTimers()
mockCanvas = createMockCanvas() as LGraphCanvas
syncFn = vi.fn()
callbacks = {
onStart: vi.fn(),
onUpdate: vi.fn(),
onStop: vi.fn()
}
// Mock requestAnimationFrame
global.requestAnimationFrame = vi.fn((cb) => {
setTimeout(cb, 16) // Simulate 60fps
return 1
})
global.cancelAnimationFrame = vi.fn()
})
afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})
it('should auto-start sync when canvas is provided', async () => {
const { isActive } = useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
expect(isActive.value).toBe(true)
expect(callbacks.onStart).toHaveBeenCalledOnce()
expect(syncFn).toHaveBeenCalledWith(mockCanvas)
})
it('should not auto-start when autoStart is false', async () => {
const { isActive } = useCanvasTransformSync(mockCanvas, syncFn, callbacks, {
autoStart: false
})
await nextTick()
expect(isActive.value).toBe(false)
expect(callbacks.onStart).not.toHaveBeenCalled()
expect(syncFn).not.toHaveBeenCalled()
})
it('should not start when canvas is null', async () => {
const { isActive } = useCanvasTransformSync(null, syncFn, callbacks)
await nextTick()
expect(isActive.value).toBe(false)
expect(callbacks.onStart).not.toHaveBeenCalled()
})
it('should manually start and stop sync', async () => {
const { isActive, startSync, stopSync } = useCanvasTransformSync(
mockCanvas,
syncFn,
callbacks,
{ autoStart: false }
)
// Start manually
startSync()
await nextTick()
expect(isActive.value).toBe(true)
expect(callbacks.onStart).toHaveBeenCalledOnce()
// Stop manually
stopSync()
await nextTick()
expect(isActive.value).toBe(false)
expect(callbacks.onStop).toHaveBeenCalledOnce()
})
it('should call sync function on each frame', async () => {
useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
// Advance timers to trigger additional frames (initial call + 3 more = 4 total)
vi.advanceTimersByTime(48) // 3 additional frames at 16ms each
await nextTick()
expect(syncFn).toHaveBeenCalledTimes(4) // Initial call + 3 timed calls
expect(syncFn).toHaveBeenCalledWith(mockCanvas)
})
it('should provide timing information in onUpdate callback', async () => {
// Mock performance.now to return predictable values
const mockNow = vi.spyOn(performance, 'now')
mockNow.mockReturnValueOnce(0).mockReturnValueOnce(5) // 5ms duration
useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
expect(callbacks.onUpdate).toHaveBeenCalledWith(5)
})
it('should handle sync function that throws errors', async () => {
const errorSyncFn = vi.fn().mockImplementation(() => {
throw new Error('Sync failed')
})
// Creating the composable should not throw
expect(() => {
useCanvasTransformSync(mockCanvas, errorSyncFn, callbacks)
}).not.toThrow()
await nextTick()
// Even though sync function throws, the composable should handle it gracefully
expect(errorSyncFn).toHaveBeenCalled()
expect(callbacks.onStart).toHaveBeenCalled()
})
it('should not start if already active', async () => {
const { startSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
// Try to start again
startSync()
await nextTick()
// Should only be called once from auto-start
expect(callbacks.onStart).toHaveBeenCalledOnce()
})
it('should not stop if already inactive', async () => {
const { stopSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks, {
autoStart: false
})
// Try to stop when not started
stopSync()
await nextTick()
expect(callbacks.onStop).not.toHaveBeenCalled()
})
it('should clean up on component unmount', async () => {
const TestComponent = {
setup() {
const { isActive } = useCanvasTransformSync(
mockCanvas,
syncFn,
callbacks
)
return { isActive }
},
template: '<div>{{ isActive }}</div>'
}
const wrapper = mount(TestComponent)
await nextTick()
expect(callbacks.onStart).toHaveBeenCalled()
// Unmount component
wrapper.unmount()
await nextTick()
expect(callbacks.onStop).toHaveBeenCalled()
expect(global.cancelAnimationFrame).toHaveBeenCalled()
})
it('should work without callbacks', async () => {
const { isActive } = useCanvasTransformSync(mockCanvas, syncFn)
await nextTick()
expect(isActive.value).toBe(true)
expect(syncFn).toHaveBeenCalledWith(mockCanvas)
})
it('should stop sync when canvas becomes null during sync', async () => {
let currentCanvas: any = mockCanvas
const dynamicSyncFn = vi.fn(() => {
// Simulate canvas becoming null during sync
currentCanvas = null
})
const { isActive } = useCanvasTransformSync(
currentCanvas,
dynamicSyncFn,
callbacks
)
await nextTick()
expect(isActive.value).toBe(true)
// Advance time to trigger sync
vi.advanceTimersByTime(16)
await nextTick()
// Should handle null canvas gracefully
expect(dynamicSyncFn).toHaveBeenCalled()
})
it('should use cancelAnimationFrame when stopping', async () => {
const { stopSync } = useCanvasTransformSync(mockCanvas, syncFn, callbacks)
await nextTick()
stopSync()
expect(global.cancelAnimationFrame).toHaveBeenCalledWith(1)
})
})

View File

@@ -1,12 +1,12 @@
import { useRafFn } from '@vueuse/core'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue' import { ref } from 'vue'
import { useCanvasTransformSync } from '@/composables/canvas/useCanvasTransformSync'
import type { LGraph } from '@/lib/litegraph/src/litegraph' import type { LGraph } from '@/lib/litegraph/src/litegraph'
import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport' import { useMinimapViewport } from '@/renderer/extensions/minimap/composables/useMinimapViewport'
import type { MinimapCanvas } from '@/renderer/extensions/minimap/types' import type { MinimapCanvas } from '@/renderer/extensions/minimap/types'
vi.mock('@/composables/canvas/useCanvasTransformSync') vi.mock('@vueuse/core')
vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({ vi.mock('@/renderer/core/spatial/boundsCalculator', () => ({
calculateNodeBounds: vi.fn(), calculateNodeBounds: vi.fn(),
calculateMinimapScale: vi.fn(), calculateMinimapScale: vi.fn(),
@@ -41,10 +41,10 @@ describe('useMinimapViewport', () => {
] ]
} as any } as any
vi.mocked(useCanvasTransformSync).mockReturnValue({ vi.mocked(useRafFn, { partial: true }).mockReturnValue({
startSync: vi.fn(), resume: vi.fn(),
stopSync: vi.fn() pause: vi.fn()
} as any) })
}) })
it('should initialize with default bounds', () => { it('should initialize with default bounds', () => {
@@ -206,10 +206,10 @@ describe('useMinimapViewport', () => {
const startSyncMock = vi.fn() const startSyncMock = vi.fn()
const stopSyncMock = vi.fn() const stopSyncMock = vi.fn()
vi.mocked(useCanvasTransformSync).mockReturnValue({ vi.mocked(useRafFn, { partial: true }).mockReturnValue({
startSync: startSyncMock, resume: startSyncMock,
stopSync: stopSyncMock pause: stopSyncMock
} as any) })
const canvasRef = ref(mockCanvas as any) const canvasRef = ref(mockCanvas as any)
const graphRef = ref(mockGraph as any) const graphRef = ref(mockGraph as any)

View File

@@ -50,23 +50,13 @@ vi.mock('@/composables/useErrorHandling', () => ({
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({ vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
useNodeLayout: () => ({ useNodeLayout: () => ({
position: { x: 100, y: 50 }, position: { x: 100, y: 50 },
size: { width: 200, height: 100 },
startDrag: vi.fn(), startDrag: vi.fn(),
handleDrag: vi.fn(), handleDrag: vi.fn(),
endDrag: vi.fn() endDrag: vi.fn()
}) })
})) }))
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
useLOD: () => ({
lodLevel: { value: 0 },
shouldRenderWidgets: { value: true },
shouldRenderSlots: { value: true },
shouldRenderContent: { value: false },
lodCssClass: { value: '' }
}),
LODLevel: { MINIMAL: 0 }
}))
vi.mock( vi.mock(
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState', '@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
() => ({ () => ({

View File

@@ -1,270 +1,69 @@
import { describe, expect, it } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue' import { nextTick, reactive } from 'vue'
import { import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
LODLevel,
LOD_THRESHOLDS, const mockSettingStore = reactive({
supportsFeatureAtZoom, get: vi.fn(() => 8)
useLOD })
} from '@/renderer/extensions/vueNodes/lod/useLOD'
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
describe('useLOD', () => { describe('useLOD', () => {
describe('LOD level detection', () => { beforeEach(() => {
it('should return MINIMAL for zoom <= 0.4', () => { vi.restoreAllMocks()
const zoomRef = ref(0.4) vi.unstubAllGlobals()
const { lodLevel } = useLOD(zoomRef)
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
zoomRef.value = 0.2 mockSettingStore.get.mockReturnValue(8)
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
zoomRef.value = 0.1
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
})
it('should return REDUCED for 0.4 < zoom <= 0.8', () => {
const zoomRef = ref(0.5)
const { lodLevel } = useLOD(zoomRef)
expect(lodLevel.value).toBe(LODLevel.REDUCED)
zoomRef.value = 0.6
expect(lodLevel.value).toBe(LODLevel.REDUCED)
zoomRef.value = 0.8
expect(lodLevel.value).toBe(LODLevel.REDUCED)
})
it('should return FULL for zoom > 0.8', () => {
const zoomRef = ref(0.9)
const { lodLevel } = useLOD(zoomRef)
expect(lodLevel.value).toBe(LODLevel.FULL)
zoomRef.value = 1.0
expect(lodLevel.value).toBe(LODLevel.FULL)
zoomRef.value = 2.5
expect(lodLevel.value).toBe(LODLevel.FULL)
})
it('should be reactive to zoom changes', () => {
const zoomRef = ref(0.2)
const { lodLevel } = useLOD(zoomRef)
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
zoomRef.value = 0.6
expect(lodLevel.value).toBe(LODLevel.REDUCED)
zoomRef.value = 1.0
expect(lodLevel.value).toBe(LODLevel.FULL)
})
}) })
describe('rendering decisions', () => { it('should calculate isLOD value based on zoom threshold correctly', async () => {
it('should disable all rendering for MINIMAL LOD', () => { vi.stubGlobal('devicePixelRatio', 1)
const zoomRef = ref(0.2)
const {
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels
} = useLOD(zoomRef)
expect(shouldRenderWidgets.value).toBe(false) const camera = reactive({ z: 1 })
expect(shouldRenderSlots.value).toBe(false) const { isLOD } = useLOD(camera)
expect(shouldRenderContent.value).toBe(false)
expect(shouldRenderSlotLabels.value).toBe(false)
expect(shouldRenderWidgetLabels.value).toBe(false)
})
it('should enable widgets/slots but disable labels for REDUCED LOD', () => { await nextTick()
const zoomRef = ref(0.6) expect(isLOD.value).toBe(false)
const {
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels
} = useLOD(zoomRef)
expect(shouldRenderWidgets.value).toBe(true) camera.z = 0.55
expect(shouldRenderSlots.value).toBe(true) await nextTick()
expect(shouldRenderContent.value).toBe(false) expect(isLOD.value).toBe(true)
expect(shouldRenderSlotLabels.value).toBe(false)
expect(shouldRenderWidgetLabels.value).toBe(false)
})
it('should enable all rendering for FULL LOD', () => { camera.z = 0.87
const zoomRef = ref(1.0) await nextTick()
const { expect(isLOD.value).toBe(false)
shouldRenderWidgets,
shouldRenderSlots,
shouldRenderContent,
shouldRenderSlotLabels,
shouldRenderWidgetLabels
} = useLOD(zoomRef)
expect(shouldRenderWidgets.value).toBe(true)
expect(shouldRenderSlots.value).toBe(true)
expect(shouldRenderContent.value).toBe(true)
expect(shouldRenderSlotLabels.value).toBe(true)
expect(shouldRenderWidgetLabels.value).toBe(true)
})
}) })
describe('CSS classes', () => { it('should handle a different devicePixelRatio value', async () => {
it('should return correct CSS class for each LOD level', () => { vi.stubGlobal('devicePixelRatio', 3) //Threshold with 8px minFontsize = 0.19
const zoomRef = ref(0.2)
const { lodCssClass } = useLOD(zoomRef)
expect(lodCssClass.value).toBe('lg-node--lod-minimal') const camera = reactive({ z: 1 })
const { isLOD } = useLOD(camera)
zoomRef.value = 0.6 await nextTick()
expect(lodCssClass.value).toBe('lg-node--lod-reduced') expect(isLOD.value).toBe(false)
zoomRef.value = 1.0 camera.z = 0.18
expect(lodCssClass.value).toBe('lg-node--lod-full') await nextTick()
}) expect(isLOD.value).toBe(true)
}) })
describe('essential widgets filtering', () => { it('should respond to different minFontSize settings', async () => {
it('should return all widgets for FULL LOD', () => { vi.stubGlobal('devicePixelRatio', 1)
const zoomRef = ref(1.0)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [ mockSettingStore.get.mockReturnValue(16) //Now threshold is 1.14
{ type: 'combo' },
{ type: 'text' },
{ type: 'button' },
{ type: 'slider' }
]
expect(getEssentialWidgets(widgets)).toEqual(widgets) const camera = reactive({ z: 1 })
}) const { isLOD } = useLOD(camera)
it('should return empty array for MINIMAL LOD', () => { await nextTick()
const zoomRef = ref(0.2) expect(isLOD.value).toBe(true)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [{ type: 'combo' }, { type: 'text' }, { type: 'button' }] camera.z = 1.15
await nextTick()
expect(getEssentialWidgets(widgets)).toEqual([]) expect(isLOD.value).toBe(false)
})
it('should filter to essential types for REDUCED LOD', () => {
const zoomRef = ref(0.6)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [
{ type: 'combo' },
{ type: 'text' },
{ type: 'button' },
{ type: 'slider' },
{ type: 'toggle' },
{ type: 'number' }
]
const essential = getEssentialWidgets(widgets)
expect(essential).toHaveLength(4)
expect(essential.map((w: any) => w.type)).toEqual([
'combo',
'slider',
'toggle',
'number'
])
})
it('should handle case-insensitive widget types', () => {
const zoomRef = ref(0.6)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [
{ type: 'COMBO' },
{ type: 'Select' },
{ type: 'TOGGLE' }
]
const essential = getEssentialWidgets(widgets)
expect(essential).toHaveLength(3)
})
it('should handle widgets with undefined or missing type', () => {
const zoomRef = ref(0.6)
const { getEssentialWidgets } = useLOD(zoomRef)
const widgets = [
{ type: 'combo' },
{ type: undefined },
{},
{ type: 'slider' }
]
const essential = getEssentialWidgets(widgets)
expect(essential).toHaveLength(2)
expect(essential.map((w: any) => w.type)).toEqual(['combo', 'slider'])
})
})
describe('performance metrics', () => {
it('should provide debug metrics', () => {
const zoomRef = ref(0.6)
const { lodMetrics } = useLOD(zoomRef)
expect(lodMetrics.value).toEqual({
level: LODLevel.REDUCED,
zoom: 0.6,
widgetCount: 'full',
slotCount: 'full'
})
})
it('should update metrics when zoom changes', () => {
const zoomRef = ref(0.2)
const { lodMetrics } = useLOD(zoomRef)
expect(lodMetrics.value.level).toBe(LODLevel.MINIMAL)
expect(lodMetrics.value.widgetCount).toBe('none')
expect(lodMetrics.value.slotCount).toBe('none')
zoomRef.value = 1.0
expect(lodMetrics.value.level).toBe(LODLevel.FULL)
expect(lodMetrics.value.widgetCount).toBe('full')
expect(lodMetrics.value.slotCount).toBe('full')
})
})
})
describe('LOD_THRESHOLDS', () => {
it('should export correct threshold values', () => {
expect(LOD_THRESHOLDS.FULL_THRESHOLD).toBe(0.8)
expect(LOD_THRESHOLDS.REDUCED_THRESHOLD).toBe(0.4)
expect(LOD_THRESHOLDS.MINIMAL_THRESHOLD).toBe(0.0)
})
})
describe('supportsFeatureAtZoom', () => {
it('should return correct feature support for different zoom levels', () => {
expect(supportsFeatureAtZoom(1.0, 'renderWidgets')).toBe(true)
expect(supportsFeatureAtZoom(1.0, 'renderSlots')).toBe(true)
expect(supportsFeatureAtZoom(1.0, 'renderContent')).toBe(true)
expect(supportsFeatureAtZoom(0.6, 'renderWidgets')).toBe(true)
expect(supportsFeatureAtZoom(0.6, 'renderSlots')).toBe(true)
expect(supportsFeatureAtZoom(0.6, 'renderContent')).toBe(false)
expect(supportsFeatureAtZoom(0.2, 'renderWidgets')).toBe(false)
expect(supportsFeatureAtZoom(0.2, 'renderSlots')).toBe(false)
expect(supportsFeatureAtZoom(0.2, 'renderContent')).toBe(false)
})
it('should handle threshold boundary values correctly', () => {
expect(supportsFeatureAtZoom(0.8, 'renderWidgets')).toBe(true)
expect(supportsFeatureAtZoom(0.8, 'renderContent')).toBe(false)
expect(supportsFeatureAtZoom(0.81, 'renderContent')).toBe(true)
expect(supportsFeatureAtZoom(0.4, 'renderWidgets')).toBe(false)
expect(supportsFeatureAtZoom(0.41, 'renderWidgets')).toBe(true)
}) })
}) })

View File

@@ -150,6 +150,13 @@ describe('useFirebaseAuthStore', () => {
expect(store.loading).toBe(false) expect(store.loading).toBe(false)
}) })
it('should set persistence to local storage on initialization', () => {
expect(firebaseAuth.setPersistence).toHaveBeenCalledWith(
mockAuth,
firebaseAuth.browserLocalPersistence
)
})
it('should properly clean up error state between operations', async () => { it('should properly clean up error state between operations', async () => {
// First, cause an error // First, cause an error
const mockError = new Error('Invalid password') const mockError = new Error('Invalid password')

28
tools/devtools/README.md Normal file
View File

@@ -0,0 +1,28 @@
# ComfyUI DevTools
This directory contains development tools and test utilities for ComfyUI, previously maintained as a separate repository at `https://github.com/Comfy-Org/ComfyUI_devtools`.
## Contents
- `__init__.py` - Server endpoints for development tools (`/api/devtools/*`)
- `dev_nodes.py` - Development and testing nodes for ComfyUI
- `fake_model.safetensors` - Test fixture for model loading tests
## Purpose
These tools provide:
- Test endpoints for browser automation
- Development nodes for testing various UI features
- Mock data for consistent testing environments
## Usage
During CI/CD, these files are automatically copied to the ComfyUI `custom_nodes` directory. For local development, copy these files to your ComfyUI installation:
```bash
cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
```
## Migration
This directory was created as part of issue #4683 to merge the ComfyUI_devtools repository into the main frontend repository, eliminating the need for separate versioning and simplifying the development workflow.

100
tools/devtools/__init__.py Normal file
View File

@@ -0,0 +1,100 @@
from __future__ import annotations
from .dev_nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
import os
import shutil
import json
from typing import Union
import server
from aiohttp import web
from aiohttp.web_request import Request
import folder_paths
from folder_paths import models_dir
@server.PromptServer.instance.routes.get("/devtools/fake_model.safetensors")
async def fake_model(request: Request):
file_path = os.path.join(os.path.dirname(__file__), "fake_model.safetensors")
return web.FileResponse(file_path)
@server.PromptServer.instance.routes.get("/devtools/cleanup_fake_model")
async def cleanup_fake_model(request: Request):
model_folder = request.query.get("model_folder", "clip")
model_path = os.path.join(models_dir, model_folder, "fake_model.safetensors")
if os.path.exists(model_path):
os.remove(model_path)
return web.Response(status=200, text="Fake model cleaned up")
TreeType = dict[str, Union[str, "TreeType"]]
def write_tree_structure(tree: TreeType, base_path: str):
# Remove existing files and folders in users/workflows
if os.path.exists(base_path):
shutil.rmtree(base_path)
# Recreate the base directory
os.makedirs(base_path, exist_ok=True)
def write_recursive(current_tree: TreeType, current_path: str):
for key, value in current_tree.items():
new_path = os.path.join(current_path, key)
if isinstance(value, dict):
# If it's a dictionary, create a new directory and recurse
os.makedirs(new_path, exist_ok=True)
write_recursive(value, new_path)
else:
# If it's a string, write the content to a file
with open(new_path, "w") as f:
f.write(value)
write_recursive(tree, base_path)
@server.PromptServer.instance.routes.post("/devtools/setup_folder_structure")
async def setup_folder_structure(request: Request):
try:
data = await request.json()
tree_structure = data.get("tree_structure")
base_path = os.path.join(
folder_paths.base_path, data.get("base_path", "users/workflows")
)
if not isinstance(tree_structure, dict):
return web.Response(status=400, text="Invalid tree structure")
write_tree_structure(tree_structure, base_path)
return web.Response(status=200, text=f"Folder structure created at {base_path}")
except json.JSONDecodeError:
return web.Response(status=400, text="Invalid JSON data")
except Exception as e:
return web.Response(status=500, text=f"Error: {str(e)}")
@server.PromptServer.instance.routes.post("/devtools/set_settings")
async def set_settings(request: Request):
"""Directly set the settings for the user specified via `Comfy.userId`,
instead of merging with the existing settings."""
try:
settings: dict[str, str | bool | int | float] = await request.json()
user_root = folder_paths.get_user_directory()
try:
user_id: str = settings.pop("Comfy.userId")
except KeyError:
user_id = "default"
settings_file_path = os.path.join(user_root, user_id, "comfy.settings.json")
# Ensure the directory structure exists
os.makedirs(os.path.dirname(settings_file_path), exist_ok=True)
with open(settings_file_path, "w") as f:
f.write(json.dumps(settings, indent=4))
return web.Response(status=200)
except Exception as e:
return web.Response(status=500, text=f"Error: {str(e)}")
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]

673
tools/devtools/dev_nodes.py Normal file
View File

@@ -0,0 +1,673 @@
import torch
import comfy.utils as utils
from comfy.model_patcher import ModelPatcher
import nodes
import time
import os
import folder_paths
class ErrorRaiseNode:
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "raise_error"
CATEGORY = "DevTools"
DESCRIPTION = "Raise an error for development purposes"
def raise_error(self):
raise Exception("Error node was called!")
class ErrorRaiseNodeWithMessage:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"message": ("STRING", {"multiline": True})}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "raise_error"
CATEGORY = "DevTools"
DESCRIPTION = "Raise an error with message for development purposes"
def raise_error(self, message: str):
raise Exception(message)
class ExperimentalNode:
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "experimental_function"
CATEGORY = "DevTools"
DESCRIPTION = "A experimental node"
EXPERIMENTAL = True
def experimental_function(self):
print("Experimental node was called!")
class DeprecatedNode:
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "deprecated_function"
CATEGORY = "DevTools"
DESCRIPTION = "A deprecated node"
DEPRECATED = True
def deprecated_function(self):
print("Deprecated node was called!")
class LongComboDropdown:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"option": ([f"Option {i}" for i in range(1_000)],)}}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "long_combo_dropdown"
CATEGORY = "DevTools"
DESCRIPTION = "A long combo dropdown"
def long_combo_dropdown(self, option: str):
print(option)
class NodeWithOptionalInput:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {"required_input": ("IMAGE",)},
"optional": {"optional_input": ("IMAGE", {"default": None})},
}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "node_with_optional_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with an optional input"
def node_with_optional_input(self, required_input, optional_input=None):
print(
f"Calling node with required_input: {required_input} and optional_input: {optional_input}"
)
return (required_input,)
class NodeWithOptionalComboInput:
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"optional_combo_input": (
[f"Random Unique Option {time.time()}" for _ in range(8)],
{"default": None},
)
},
}
RETURN_TYPES = ("STRING",)
FUNCTION = "node_with_optional_combo_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with an optional combo input that returns unique values every time INPUT_TYPES is called"
def node_with_optional_combo_input(self, optional_combo_input=None):
print(f"Calling node with optional_combo_input: {optional_combo_input}")
return (optional_combo_input,)
class NodeWithOnlyOptionalInput:
@classmethod
def INPUT_TYPES(s):
return {
"optional": {
"text": ("STRING", {"multiline": True, "dynamicPrompts": True}),
"clip": ("CLIP", {}),
}
}
RETURN_TYPES = ()
FUNCTION = "node_with_only_optional_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with only optional input"
def node_with_only_optional_input(self, clip=None, text=None):
pass
class NodeWithOutputList:
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = (
"INT",
"INT",
)
RETURN_NAMES = (
"INTEGER OUTPUT",
"INTEGER LIST OUTPUT",
)
OUTPUT_IS_LIST = (
False,
True,
)
FUNCTION = "node_with_output_list"
CATEGORY = "DevTools"
DESCRIPTION = "A node with an output list"
def node_with_output_list(self):
return (1, [1, 2, 3])
class NodeWithForceInput:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"int_input": ("INT", {"forceInput": True}),
"int_input_widget": ("INT", {"default": 1}),
},
"optional": {"float_input": ("FLOAT", {"forceInput": True})},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "node_with_force_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with a forced input"
def node_with_force_input(
self, int_input: int, int_input_widget: int, float_input: float = 0.0
):
print(
f"int_input: {int_input}, int_input_widget: {int_input_widget}, float_input: {float_input}"
)
class NodeWithDefaultInput:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"int_input": ("INT", {"defaultInput": True}),
"int_input_widget": ("INT", {"default": 1}),
},
"optional": {"float_input": ("FLOAT", {"defaultInput": True})},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "node_with_default_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with a default input"
def node_with_default_input(
self, int_input: int, int_input_widget: int, float_input: float = 0.0
):
print(
f"int_input: {int_input}, int_input_widget: {int_input_widget}, float_input: {float_input}"
)
class NodeWithStringInput:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"string_input": ("STRING",)}}
RETURN_TYPES = ()
FUNCTION = "node_with_string_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with a string input"
def node_with_string_input(self, string_input: str):
print(f"string_input: {string_input}")
class NodeWithUnionInput:
@classmethod
def INPUT_TYPES(cls):
return {
"optional": {
"string_or_int_input": ("STRING,INT",),
"string_input": ("STRING", {"forceInput": True}),
"int_input": ("INT", {"forceInput": True}),
}
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "node_with_union_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with a union input"
def node_with_union_input(
self,
string_or_int_input: str | int = "",
string_input: str = "",
int_input: int = 0,
):
print(
f"string_or_int_input: {string_or_int_input}, string_input: {string_input}, int_input: {int_input}"
)
return {
"ui": {
"text": string_or_int_input,
}
}
class NodeWithBooleanInput:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"boolean_input": ("BOOLEAN",)}}
RETURN_TYPES = ()
FUNCTION = "node_with_boolean_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with a boolean input"
def node_with_boolean_input(self, boolean_input: bool):
print(f"boolean_input: {boolean_input}")
class SimpleSlider:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"value": (
"FLOAT",
{
"display": "slider",
"default": 0.5,
"min": 0.0,
"max": 1.0,
"step": 0.001,
},
),
},
}
RETURN_TYPES = ("FLOAT",)
FUNCTION = "execute"
CATEGORY = "DevTools"
def execute(self, value):
return (value,)
class NodeWithSeedInput:
@classmethod
def INPUT_TYPES(cls):
return {"required": {"seed": ("INT", {"default": 0})}}
RETURN_TYPES = ()
FUNCTION = "node_with_seed_input"
CATEGORY = "DevTools"
DESCRIPTION = "A node with a seed input"
OUTPUT_NODE = True
def node_with_seed_input(self, seed: int):
print(f"seed: {seed}")
class DummyPatch(torch.nn.Module):
def __init__(self, module: torch.nn.Module, dummy_float: float = 0.0):
super().__init__()
self.module = module
self.dummy_float = dummy_float
def forward(self, *args, **kwargs):
if isinstance(self.module, DummyPatch):
raise Exception(f"Calling nested dummy patch! {self.dummy_float}")
return self.module(*args, **kwargs)
class ObjectPatchNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"model": ("MODEL",),
"target_module": ("STRING", {"multiline": True}),
},
"optional": {
"dummy_float": ("FLOAT", {"default": 0.0}),
},
}
RETURN_TYPES = ("MODEL",)
FUNCTION = "apply_patch"
CATEGORY = "DevTools"
DESCRIPTION = "A node that applies an object patch"
def apply_patch(
self, model: ModelPatcher, target_module: str, dummy_float: float = 0.0
) -> ModelPatcher:
module = utils.get_attr(model.model, target_module)
work_model = model.clone()
work_model.add_object_patch(target_module, DummyPatch(module, dummy_float))
return (work_model,)
class RemoteWidgetNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"remote_widget_value": (
"COMBO",
{
"remote": {
"route": "/api/models/checkpoints",
},
},
),
},
}
FUNCTION = "remote_widget"
CATEGORY = "DevTools"
DESCRIPTION = "A node that lazily fetches options from a remote endpoint"
RETURN_TYPES = ("STRING",)
def remote_widget(self, remote_widget_value: str):
return (remote_widget_value,)
class RemoteWidgetNodeWithParams:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"remote_widget_value": (
"COMBO",
{
"remote": {
"route": "/api/models/checkpoints",
"query_params": {
"sort": "true",
},
},
},
),
},
}
FUNCTION = "remote_widget"
CATEGORY = "DevTools"
DESCRIPTION = (
"A node that lazily fetches options from a remote endpoint with query params"
)
RETURN_TYPES = ("STRING",)
def remote_widget(self, remote_widget_value: str):
return (remote_widget_value,)
class RemoteWidgetNodeWithRefresh:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"remote_widget_value": (
"COMBO",
{
"remote": {
"route": "/api/models/checkpoints",
"refresh": 300,
"max_retries": 10,
"timeout": 256,
},
},
),
},
}
FUNCTION = "remote_widget"
CATEGORY = "DevTools"
DESCRIPTION = "A node that lazily fetches options from a remote endpoint and refresh the options every 300 ms"
RETURN_TYPES = ("STRING",)
def remote_widget(self, remote_widget_value: str):
return (remote_widget_value,)
class RemoteWidgetNodeWithRefreshButton:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"remote_widget_value": (
"COMBO",
{
"remote": {
"route": "/api/models/checkpoints",
"refresh_button": True,
},
},
),
},
}
FUNCTION = "remote_widget"
CATEGORY = "DevTools"
DESCRIPTION = "A node that lazily fetches options from a remote endpoint and has a refresh button to manually reload options"
RETURN_TYPES = ("STRING",)
def remote_widget(self, remote_widget_value: str):
return (remote_widget_value,)
class RemoteWidgetNodeWithControlAfterRefresh:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"remote_widget_value": (
"COMBO",
{
"remote": {
"route": "/api/models/checkpoints",
"refresh_button": True,
"control_after_refresh": "first",
},
},
),
},
}
FUNCTION = "remote_widget"
CATEGORY = "DevTools"
DESCRIPTION = "A node that lazily fetches options from a remote endpoint and has a refresh button to manually reload options and select the first option on refresh"
RETURN_TYPES = ("STRING",)
def remote_widget(self, remote_widget_value: str):
return (remote_widget_value,)
class NodeWithOutputCombo:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"subset_options": (["A", "B"], {"forceInput": True}),
"subset_options_v2": (
"COMBO",
{"options": ["A", "B"], "forceInput": True},
),
}
}
RETURN_TYPES = (["A", "B", "C"],)
FUNCTION = "node_with_output_combo"
CATEGORY = "DevTools"
DESCRIPTION = "A node that outputs a combo type"
def node_with_output_combo(self, subset_options: str):
return (subset_options,)
class MultiSelectNode:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"foo": (
"COMBO",
{
"options": ["A", "B", "C"],
"multi_select": {
"placeholder": "Choose foos",
"chip": True,
},
},
)
}
}
RETURN_TYPES = ("STRING",)
OUTPUT_IS_LIST = [True]
FUNCTION = "multi_select_node"
CATEGORY = "DevTools"
DESCRIPTION = "A node that outputs a multi select type"
def multi_select_node(self, foo: list[str]) -> list[str]:
return (foo,)
class LoadAnimatedImageTest(nodes.LoadImage):
@classmethod
def INPUT_TYPES(s):
input_dir = folder_paths.get_input_directory()
files = [
f
for f in os.listdir(input_dir)
if os.path.isfile(os.path.join(input_dir, f)) and f.endswith(".webp")
]
files = folder_paths.filter_files_content_types(files, ["image"])
return {
"required": {"image": (sorted(files), {"animated_image_upload": True})},
}
class NodeWithValidation:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {"int_input": ("INT",)},
}
@classmethod
def VALIDATE_INPUTS(cls, int_input: int):
if int_input < 0:
raise ValueError("int_input must be greater than 0")
return True
RETURN_TYPES = ()
FUNCTION = "execute"
CATEGORY = "DevTools"
DESCRIPTION = "A node that validates an input"
OUTPUT_NODE = True
def execute(self, int_input: int):
print(f"int_input: {int_input}")
return tuple()
class NodeWithV2ComboInput:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"combo_input": (
"COMBO",
{"options": ["A", "B"]},
),
}
}
RETURN_TYPES = ("COMBO",)
FUNCTION = "node_with_v2_combo_input"
CATEGORY = "DevTools"
DESCRIPTION = (
"A node that outputs a combo type that adheres to the v2 combo input spec"
)
def node_with_v2_combo_input(self, combo_input: str):
return (combo_input,)
NODE_CLASS_MAPPINGS = {
"DevToolsErrorRaiseNode": ErrorRaiseNode,
"DevToolsErrorRaiseNodeWithMessage": ErrorRaiseNodeWithMessage,
"DevToolsExperimentalNode": ExperimentalNode,
"DevToolsDeprecatedNode": DeprecatedNode,
"DevToolsLongComboDropdown": LongComboDropdown,
"DevToolsNodeWithOptionalInput": NodeWithOptionalInput,
"DevToolsNodeWithOptionalComboInput": NodeWithOptionalComboInput,
"DevToolsNodeWithOnlyOptionalInput": NodeWithOnlyOptionalInput,
"DevToolsNodeWithOutputList": NodeWithOutputList,
"DevToolsNodeWithForceInput": NodeWithForceInput,
"DevToolsNodeWithDefaultInput": NodeWithDefaultInput,
"DevToolsNodeWithStringInput": NodeWithStringInput,
"DevToolsNodeWithUnionInput": NodeWithUnionInput,
"DevToolsSimpleSlider": SimpleSlider,
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
"DevToolsObjectPatchNode": ObjectPatchNode,
"DevToolsNodeWithBooleanInput": NodeWithBooleanInput,
"DevToolsRemoteWidgetNode": RemoteWidgetNode,
"DevToolsRemoteWidgetNodeWithParams": RemoteWidgetNodeWithParams,
"DevToolsRemoteWidgetNodeWithRefresh": RemoteWidgetNodeWithRefresh,
"DevToolsRemoteWidgetNodeWithRefreshButton": RemoteWidgetNodeWithRefreshButton,
"DevToolsRemoteWidgetNodeWithControlAfterRefresh": RemoteWidgetNodeWithControlAfterRefresh,
"DevToolsNodeWithOutputCombo": NodeWithOutputCombo,
"DevToolsMultiSelectNode": MultiSelectNode,
"DevToolsLoadAnimatedImageTest": LoadAnimatedImageTest,
"DevToolsNodeWithValidation": NodeWithValidation,
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"DevToolsErrorRaiseNode": "Raise Error",
"DevToolsErrorRaiseNodeWithMessage": "Raise Error with Message",
"DevToolsExperimentalNode": "Experimental Node",
"DevToolsDeprecatedNode": "Deprecated Node",
"DevToolsLongComboDropdown": "Long Combo Dropdown",
"DevToolsNodeWithOptionalInput": "Node With Optional Input",
"DevToolsNodeWithOptionalComboInput": "Node With Optional Combo Input",
"DevToolsNodeWithOnlyOptionalInput": "Node With Only Optional Input",
"DevToolsNodeWithOutputList": "Node With Output List",
"DevToolsNodeWithForceInput": "Node With Force Input",
"DevToolsNodeWithDefaultInput": "Node With Default Input",
"DevToolsNodeWithStringInput": "Node With String Input",
"DevToolsNodeWithUnionInput": "Node With Union Input",
"DevToolsSimpleSlider": "Simple Slider",
"DevToolsNodeWithSeedInput": "Node With Seed Input",
"DevToolsObjectPatchNode": "Object Patch Node",
"DevToolsNodeWithBooleanInput": "Node With Boolean Input",
"DevToolsRemoteWidgetNode": "Remote Widget Node",
"DevToolsRemoteWidgetNodeWithParams": "Remote Widget Node With Sort Query Param",
"DevToolsRemoteWidgetNodeWithRefresh": "Remote Widget Node With 300ms Refresh",
"DevToolsRemoteWidgetNodeWithRefreshButton": "Remote Widget Node With Refresh Button",
"DevToolsRemoteWidgetNodeWithControlAfterRefresh": "Remote Widget Node With Refresh Button and Control After Refresh",
"DevToolsNodeWithOutputCombo": "Node With Output Combo",
"DevToolsMultiSelectNode": "Multi Select Node",
"DevToolsLoadAnimatedImageTest": "Load Animated Image",
"DevToolsNodeWithValidation": "Node With Validation",
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
}

View File

@@ -0,0 +1 @@
This is a fake model file used for testing.

View File

@@ -37,7 +37,5 @@
"src/**/*", "src/**/*",
"src/types/**/*.d.ts", "src/types/**/*.d.ts",
"tests-ui/**/*", "tests-ui/**/*",
"vite.config.mts",
"vitest.config.ts",
] ]
} }

View File

@@ -1,5 +1,5 @@
import { Plugin, defineConfig } from 'vite' import { defineConfig, mergeConfig } from 'vite'
import { mergeConfig } from 'vite' import type { Plugin } from 'vite'
import baseConfig from './vite.config.mts' import baseConfig from './vite.config.mts'