mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-22 05:19:03 +00:00
Compare commits
2 Commits
feat/vue-n
...
add-bypass
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c598d2653d | ||
|
|
13261b3621 |
9
.github/workflows/i18n-custom-nodes.yaml
vendored
9
.github/workflows/i18n-custom-nodes.yaml
vendored
@@ -32,10 +32,11 @@ jobs:
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI_frontend
|
||||
path: ComfyUI_frontend
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
- name: Checkout ComfyUI_devtools
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: Comfy-Org/ComfyUI_devtools
|
||||
path: ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
- name: Checkout custom node repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
|
||||
10
.github/workflows/test-ui.yaml
vendored
10
.github/workflows/test-ui.yaml
vendored
@@ -27,10 +27,12 @@ jobs:
|
||||
repository: 'Comfy-Org/ComfyUI_frontend'
|
||||
path: 'ComfyUI_frontend'
|
||||
|
||||
- name: Copy ComfyUI_devtools from frontend repo
|
||||
run: |
|
||||
mkdir -p ComfyUI/custom_nodes/ComfyUI_devtools
|
||||
cp -r ComfyUI_frontend/tools/devtools/* ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
- name: Checkout ComfyUI_devtools
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: 'Comfy-Org/ComfyUI_devtools'
|
||||
path: 'ComfyUI/custom_nodes/ComfyUI_devtools'
|
||||
ref: 'd05fd48dd787a4192e16802d4244cfcc0e2f9684'
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
|
||||
@@ -16,14 +16,9 @@ Without this flag, parallel tests will conflict and fail randomly.
|
||||
|
||||
### ComfyUI devtools
|
||||
|
||||
ComfyUI_devtools is now included in this repository under `tools/devtools/`. During CI/CD, these files are automatically copied to the `custom_nodes` directory.
|
||||
Clone <https://github.com/Comfy-Org/ComfyUI_devtools> to your `custom_nodes` directory.
|
||||
_ComfyUI_devtools adds additional API endpoints and nodes to ComfyUI for browser testing._
|
||||
|
||||
For local development, copy the devtools files to your ComfyUI installation:
|
||||
```bash
|
||||
cp -r tools/devtools/* /path/to/your/ComfyUI/custom_nodes/ComfyUI_devtools/
|
||||
```
|
||||
|
||||
### Node.js & Playwright Prerequisites
|
||||
|
||||
Ensure you have Node.js v20 or v22 installed. Then, set up the Chromium test driver:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 53 KiB |
@@ -1,44 +0,0 @@
|
||||
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.
|
Before Width: | Height: | Size: 106 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 106 KiB |
@@ -3,6 +3,7 @@
|
||||
"compilerOptions": {
|
||||
/* Test files should not be compiled */
|
||||
"noEmit": true,
|
||||
// "strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
/* Build scripts configuration */
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"moduleResolution": "node",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -33,13 +33,7 @@ export default defineConfig([
|
||||
},
|
||||
parserOptions: {
|
||||
parser: tseslint.parser,
|
||||
projectService: {
|
||||
allowDefaultProject: [
|
||||
'vite.config.mts',
|
||||
'vite.electron.config.mts',
|
||||
'vite.types.config.mts'
|
||||
]
|
||||
},
|
||||
projectService: true,
|
||||
tsConfigRootDir: import.meta.dirname,
|
||||
ecmaVersion: 2020,
|
||||
sourceType: 'module',
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"private": true,
|
||||
"version": "1.28.1",
|
||||
"version": "1.28.0",
|
||||
"type": "module",
|
||||
"repository": "https://github.com/Comfy-Org/ComfyUI_frontend",
|
||||
"homepage": "https://comfy.org",
|
||||
|
||||
@@ -9,18 +9,9 @@ import { normalizeI18nKey } from '../src/utils/formatUtil'
|
||||
const localePath = './src/locales/en/main.json'
|
||||
const nodeDefsPath = './src/locales/en/nodeDefs.json'
|
||||
|
||||
interface WidgetInfo {
|
||||
name?: string
|
||||
label?: string
|
||||
}
|
||||
|
||||
interface WidgetLabels {
|
||||
[key: string]: Record<string, { name: string }>
|
||||
}
|
||||
|
||||
test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
// Mock view route
|
||||
await comfyPage.page.route('**/view**', async (route) => {
|
||||
comfyPage.page.route('**/view**', async (route) => {
|
||||
await route.fulfill({
|
||||
body: JSON.stringify({})
|
||||
})
|
||||
@@ -29,7 +20,6 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const nodeDefs: ComfyNodeDefImpl[] = (
|
||||
Object.values(
|
||||
await comfyPage.page.evaluate(async () => {
|
||||
// @ts-expect-error - app is dynamically added to window
|
||||
const api = window['app'].api as ComfyApi
|
||||
return await api.getNodeDefs()
|
||||
})
|
||||
@@ -62,7 +52,7 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
)
|
||||
|
||||
async function extractWidgetLabels() {
|
||||
const nodeLabels: WidgetLabels = {}
|
||||
const nodeLabels = {}
|
||||
|
||||
for (const nodeDef of nodeDefs) {
|
||||
const inputNames = Object.values(nodeDef.inputs).map(
|
||||
@@ -75,15 +65,12 @@ test('collect-i18n-node-defs', async ({ comfyPage }) => {
|
||||
const widgetsMappings = await comfyPage.page.evaluate(
|
||||
(args) => {
|
||||
const [nodeName, displayName, inputNames] = args
|
||||
// @ts-expect-error - LiteGraph is dynamically added to window
|
||||
const node = window['LiteGraph'].createNode(nodeName, displayName)
|
||||
if (!node.widgets?.length) return {}
|
||||
return Object.fromEntries(
|
||||
node.widgets
|
||||
.filter(
|
||||
(w: WidgetInfo) => w?.name && !inputNames.includes(w.name)
|
||||
)
|
||||
.map((w: WidgetInfo) => [w.name, w.label])
|
||||
.filter((w) => w?.name && !inputNames.includes(w.name))
|
||||
.map((w) => [w.name, w.label])
|
||||
)
|
||||
},
|
||||
[nodeDef.name, nodeDef.display_name, inputNames]
|
||||
|
||||
@@ -72,7 +72,7 @@ function capture(srcLocaleDir: string, tempBaseDir: string) {
|
||||
const relativePath = file.replace(srcLocaleDir, '')
|
||||
const targetPath = join(tempBaseDir, relativePath)
|
||||
ensureDir(dirname(targetPath))
|
||||
writeFileSync(targetPath, readFileSync(file, 'utf8'))
|
||||
writeFileSync(targetPath, readFileSync(file))
|
||||
}
|
||||
console.log('Captured current locale files to temp/base/')
|
||||
}
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
/* Script files configuration */
|
||||
"noEmit": true,
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
@@ -929,6 +929,48 @@ audio.comfy-audio.empty-audio-widget {
|
||||
}
|
||||
/* End of [Desktop] Electron window specific styles */
|
||||
|
||||
/* Vue Node LOD (Level of Detail) System */
|
||||
/* These classes control rendering detail based on zoom level */
|
||||
|
||||
/* Minimal LOD (zoom <= 0.4) - Title only for performance */
|
||||
.lg-node--lod-minimal {
|
||||
min-height: 32px;
|
||||
transition: min-height 0.2s ease;
|
||||
/* Performance optimizations */
|
||||
text-shadow: none;
|
||||
backdrop-filter: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-minimal .lg-node-body {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* Reduced LOD (0.4 < zoom <= 0.8) - Essential widgets, simplified styling */
|
||||
.lg-node--lod-reduced {
|
||||
transition: opacity 0.1s ease;
|
||||
/* Performance optimizations */
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-widget-label,
|
||||
.lg-node--lod-reduced .lg-slot-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-slot {
|
||||
opacity: 0.6;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.lg-node--lod-reduced .lg-widget {
|
||||
margin: 2px 0;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Full LOD (zoom > 0.8) - Complete detail rendering */
|
||||
.lg-node--lod-full {
|
||||
/* Uses default styling - no overrides needed */
|
||||
}
|
||||
|
||||
.lg-node {
|
||||
/* Disable text selection on all nodes */
|
||||
@@ -954,52 +996,23 @@ audio.comfy-audio.empty-audio-widget {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* START LOD specific styles */
|
||||
/* LOD styles - Custom CSS avoids 100+ Tailwind selectors that would slow style recalculation when .isLOD toggles */
|
||||
|
||||
.isLOD .lg-node {
|
||||
box-shadow: none;
|
||||
filter: none;
|
||||
backdrop-filter: none;
|
||||
text-shadow: none;
|
||||
-webkit-mask-image: none;
|
||||
mask-image: none;
|
||||
clip-path: none;
|
||||
background-image: none;
|
||||
text-rendering: optimizeSpeed;
|
||||
border-radius: 0;
|
||||
contain: layout style;
|
||||
transition: none;
|
||||
|
||||
/* Global performance optimizations for LOD */
|
||||
.lg-node--lod-minimal,
|
||||
.lg-node--lod-reduced {
|
||||
/* Remove ALL expensive paint effects */
|
||||
box-shadow: none !important;
|
||||
filter: none !important;
|
||||
backdrop-filter: none !important;
|
||||
text-shadow: none !important;
|
||||
-webkit-mask-image: none !important;
|
||||
mask-image: none !important;
|
||||
clip-path: none !important;
|
||||
}
|
||||
|
||||
.isLOD .lg-node > * {
|
||||
pointer-events: none;
|
||||
/* Reduce paint complexity for minimal LOD */
|
||||
.lg-node--lod-minimal {
|
||||
/* Skip complex borders */
|
||||
border-radius: 0 !important;
|
||||
/* Use solid colors only */
|
||||
background-image: none !important;
|
||||
}
|
||||
|
||||
.lod-toggle {
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
.isLOD .lod-toggle {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
.lod-fallback {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.isLOD .lod-fallback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.isLOD .image-preview img {
|
||||
image-rendering: pixelated;
|
||||
}
|
||||
|
||||
|
||||
.isLOD .slot-dot {
|
||||
border-radius: 0;
|
||||
}
|
||||
/* END LOD specific styles */
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
v-for="nodeData in allNodes"
|
||||
:key="nodeData.id"
|
||||
:node-data="nodeData"
|
||||
:position="nodePositions.get(nodeData.id)"
|
||||
:size="nodeSizes.get(nodeData.id)"
|
||||
:readonly="false"
|
||||
:error="
|
||||
executionStore.lastExecutionError?.node_id === nodeData.id
|
||||
@@ -187,8 +189,15 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
const nodePositions = vueNodeLifecycle.nodePositions
|
||||
const nodeSizes = vueNodeLifecycle.nodeSizes
|
||||
const allNodes = viewportCulling.allNodes
|
||||
const handleTransformUpdate = viewportCulling.handleTransformUpdate
|
||||
|
||||
const handleTransformUpdate = () => {
|
||||
viewportCulling.handleTransformUpdate()
|
||||
// TODO: Fix paste position sync in separate PR
|
||||
vueNodeLifecycle.detectChangesInRAF.value()
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
nodeDefStore.showDeprecated = settingStore.get('Comfy.Node.ShowDeprecated')
|
||||
|
||||
@@ -33,11 +33,9 @@ const tooltipText = ref('')
|
||||
const left = ref<string>()
|
||||
const top = ref<string>()
|
||||
|
||||
function hideTooltip() {
|
||||
return (tooltipText.value = '')
|
||||
}
|
||||
const hideTooltip = () => (tooltipText.value = '')
|
||||
|
||||
async function showTooltip(tooltip: string | null | undefined) {
|
||||
const showTooltip = async (tooltip: string | null | undefined) => {
|
||||
if (!tooltip) return
|
||||
|
||||
left.value = comfyApp.canvas.mouse[0] + 'px'
|
||||
@@ -58,9 +56,9 @@ async function showTooltip(tooltip: string | null | undefined) {
|
||||
}
|
||||
}
|
||||
|
||||
function onIdle() {
|
||||
const onIdle = () => {
|
||||
const { canvas } = comfyApp
|
||||
const node = canvas?.node_over
|
||||
const node = canvas.node_over
|
||||
if (!node) return
|
||||
|
||||
const ctor = node.constructor as { title_mode?: 0 | 1 | 2 | 3 }
|
||||
|
||||
@@ -112,12 +112,16 @@
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="transition-opacity flex gap-3 h-0 items-center"
|
||||
class="transition-opacity flex gap-3 h-0"
|
||||
:class="{
|
||||
'opacity-40': selected && selected !== 'cpu'
|
||||
}"
|
||||
>
|
||||
<ToggleSwitch v-model="cpuMode" input-id="cpu-mode" />
|
||||
<ToggleSwitch
|
||||
v-model="cpuMode"
|
||||
input-id="cpu-mode"
|
||||
class="-translate-y-40"
|
||||
/>
|
||||
<label for="cpu-mode" class="select-none">
|
||||
{{ $t('install.gpuSelection.enableCpuMode') }}
|
||||
</label>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<!-- Reference:
|
||||
https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c683087a3e168db/app/js/functions/sb_fn.js#L149
|
||||
-->
|
||||
|
||||
<template>
|
||||
<LGraphNodePreview v-if="shouldRenderVueNodes" :node-def="nodeDef" />
|
||||
<div v-else class="_sb_node_preview">
|
||||
<div class="_sb_node_preview">
|
||||
<div class="_sb_table">
|
||||
<div
|
||||
class="node_header text-ellipsis mr-4"
|
||||
@@ -85,8 +85,6 @@ https://github.com/Nuked88/ComfyUI-N-Sidebar/blob/7ae7da4a9761009fb6629bc04c6830
|
||||
import _ from 'es-toolkit/compat'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
import { useColorPaletteStore } from '@/stores/workspace/colorPaletteStore'
|
||||
@@ -96,8 +94,6 @@ const { nodeDef } = defineProps<{
|
||||
nodeDef: ComfyNodeDefV2
|
||||
}>()
|
||||
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
|
||||
const colorPaletteStore = useColorPaletteStore()
|
||||
const litegraphColors = computed(
|
||||
() => colorPaletteStore.completedActivePalette.colors.litegraph_base
|
||||
|
||||
@@ -64,29 +64,31 @@ const litegraphService = useLitegraphService()
|
||||
|
||||
const { visible, newSearchBoxEnabled } = storeToRefs(searchBoxStore)
|
||||
const dismissable = ref(true)
|
||||
function getNewNodeLocation(): Point {
|
||||
const getNewNodeLocation = (): Point => {
|
||||
return triggerEvent
|
||||
? [triggerEvent.canvasX, triggerEvent.canvasY]
|
||||
: litegraphService.getCanvasCenter()
|
||||
}
|
||||
const nodeFilters = ref<FuseFilterWithValue<ComfyNodeDefImpl, string>[]>([])
|
||||
function addFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
const addFilter = (filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) => {
|
||||
nodeFilters.value.push(filter)
|
||||
}
|
||||
function removeFilter(filter: FuseFilterWithValue<ComfyNodeDefImpl, string>) {
|
||||
const removeFilter = (
|
||||
filter: FuseFilterWithValue<ComfyNodeDefImpl, string>
|
||||
) => {
|
||||
nodeFilters.value = nodeFilters.value.filter(
|
||||
(f) => toRaw(f) !== toRaw(filter)
|
||||
)
|
||||
}
|
||||
function clearFilters() {
|
||||
const clearFilters = () => {
|
||||
nodeFilters.value = []
|
||||
}
|
||||
function closeDialog() {
|
||||
const closeDialog = () => {
|
||||
visible.value = false
|
||||
}
|
||||
const canvasStore = useCanvasStore()
|
||||
|
||||
function addNode(nodeDef: ComfyNodeDefImpl) {
|
||||
const addNode = (nodeDef: ComfyNodeDefImpl) => {
|
||||
const node = litegraphService.addNodeOnGraph(nodeDef, {
|
||||
pos: getNewNodeLocation()
|
||||
})
|
||||
@@ -104,7 +106,7 @@ function addNode(nodeDef: ComfyNodeDefImpl) {
|
||||
window.requestAnimationFrame(closeDialog)
|
||||
}
|
||||
|
||||
function showSearchBox(e: CanvasPointerEvent | null) {
|
||||
const showSearchBox = (e: CanvasPointerEvent | null) => {
|
||||
if (newSearchBoxEnabled.value) {
|
||||
if (e?.pointerType === 'touch') {
|
||||
setTimeout(() => {
|
||||
@@ -118,12 +120,11 @@ function showSearchBox(e: CanvasPointerEvent | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function getFirstLink() {
|
||||
return canvasStore.getCanvas().linkConnector.renderLinks.at(0)
|
||||
}
|
||||
const getFirstLink = () =>
|
||||
canvasStore.getCanvas().linkConnector.renderLinks.at(0)
|
||||
|
||||
const nodeDefStore = useNodeDefStore()
|
||||
function showNewSearchBox(e: CanvasPointerEvent | null) {
|
||||
const showNewSearchBox = (e: CanvasPointerEvent | null) => {
|
||||
const firstLink = getFirstLink()
|
||||
if (firstLink) {
|
||||
const filter =
|
||||
@@ -148,7 +149,7 @@ function showNewSearchBox(e: CanvasPointerEvent | null) {
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function showContextMenu(e: CanvasPointerEvent) {
|
||||
const showContextMenu = (e: CanvasPointerEvent) => {
|
||||
const firstLink = getFirstLink()
|
||||
if (!firstLink) return
|
||||
|
||||
@@ -225,7 +226,7 @@ watchEffect(() => {
|
||||
)
|
||||
})
|
||||
|
||||
function canvasEventHandler(e: LiteGraphCanvasEvent) {
|
||||
const canvasEventHandler = (e: LiteGraphCanvasEvent) => {
|
||||
if (e.detail.subType === 'empty-double-click') {
|
||||
showSearchBox(e.detail.originalEvent)
|
||||
} else if (e.detail.subType === 'group-double-click') {
|
||||
@@ -248,10 +249,8 @@ const linkReleaseActionShift = computed(() =>
|
||||
)
|
||||
|
||||
// Prevent normal LinkConnector reset (called by CanvasPointer.finally)
|
||||
function preventDefault(e: Event) {
|
||||
return e.preventDefault()
|
||||
}
|
||||
function cancelNextReset(e: CustomEvent<CanvasPointerEvent>) {
|
||||
const preventDefault = (e: Event) => e.preventDefault()
|
||||
const cancelNextReset = (e: CustomEvent<CanvasPointerEvent>) => {
|
||||
e.preventDefault()
|
||||
|
||||
const canvas = canvasStore.getCanvas()
|
||||
@@ -261,7 +260,7 @@ function cancelNextReset(e: CustomEvent<CanvasPointerEvent>) {
|
||||
})
|
||||
}
|
||||
|
||||
function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
|
||||
const handleDroppedOnCanvas = (e: CustomEvent<CanvasPointerEvent>) => {
|
||||
disconnectOnReset = true
|
||||
const action = e.detail.shiftKey
|
||||
? linkReleaseActionShift.value
|
||||
@@ -282,7 +281,7 @@ function handleDroppedOnCanvas(e: CustomEvent<CanvasPointerEvent>) {
|
||||
}
|
||||
|
||||
// Resets litegraph state
|
||||
function reset() {
|
||||
const reset = () => {
|
||||
listenerController?.abort()
|
||||
listenerController = null
|
||||
triggerEvent = null
|
||||
|
||||
@@ -2,15 +2,42 @@
|
||||
* Vue node lifecycle management for LiteGraph integration
|
||||
* Provides event-driven reactivity with performance optimizations
|
||||
*/
|
||||
import { reactive } from 'vue'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
|
||||
import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
|
||||
import { LayoutSource } from '@/renderer/core/layout/types'
|
||||
import { type Bounds, QuadTree } from '@/renderer/core/spatial/QuadTree'
|
||||
import type { WidgetValue } from '@/types/simplifiedWidget'
|
||||
import type { SpatialIndexDebugInfo } from '@/types/spatialIndex'
|
||||
|
||||
import type { LGraph, LGraphNode } from '../../lib/litegraph/src/litegraph'
|
||||
|
||||
export interface NodeState {
|
||||
visible: boolean
|
||||
dirty: boolean
|
||||
lastUpdate: number
|
||||
culled: boolean
|
||||
}
|
||||
|
||||
interface NodeMetadata {
|
||||
lastRenderTime: number
|
||||
cachedBounds: DOMRect | null
|
||||
lodLevel: 'high' | 'medium' | 'low'
|
||||
spatialIndex?: QuadTree<string>
|
||||
}
|
||||
|
||||
interface PerformanceMetrics {
|
||||
fps: number
|
||||
frameTime: number
|
||||
updateTime: number
|
||||
nodeCount: number
|
||||
culledCount: number
|
||||
callbackUpdateCount: number
|
||||
rafUpdateCount: number
|
||||
adaptiveQuality: boolean
|
||||
}
|
||||
|
||||
export interface SafeWidgetData {
|
||||
name: string
|
||||
type: string
|
||||
@@ -36,26 +63,109 @@ export interface VueNodeData {
|
||||
}
|
||||
}
|
||||
|
||||
interface SpatialMetrics {
|
||||
queryTime: number
|
||||
nodesInIndex: number
|
||||
}
|
||||
|
||||
export interface GraphNodeManager {
|
||||
// Reactive state - safe data extracted from LiteGraph nodes
|
||||
vueNodeData: ReadonlyMap<string, VueNodeData>
|
||||
nodeState: ReadonlyMap<string, NodeState>
|
||||
nodePositions: ReadonlyMap<string, { x: number; y: number }>
|
||||
nodeSizes: ReadonlyMap<string, { width: number; height: number }>
|
||||
|
||||
// Access to original LiteGraph nodes (non-reactive)
|
||||
getNode(id: string): LGraphNode | undefined
|
||||
|
||||
// Lifecycle methods
|
||||
setupEventListeners(): () => void
|
||||
cleanup(): void
|
||||
|
||||
// Update methods
|
||||
scheduleUpdate(
|
||||
nodeId?: string,
|
||||
priority?: 'critical' | 'normal' | 'low'
|
||||
): void
|
||||
forceSync(): void
|
||||
detectChangesInRAF(): void
|
||||
|
||||
// Spatial queries
|
||||
getVisibleNodeIds(viewportBounds: Bounds): Set<string>
|
||||
|
||||
// Performance
|
||||
performanceMetrics: PerformanceMetrics
|
||||
spatialMetrics: SpatialMetrics
|
||||
|
||||
// Debug
|
||||
getSpatialIndexDebugInfo(): SpatialIndexDebugInfo | null
|
||||
}
|
||||
|
||||
export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
export const useGraphNodeManager = (graph: LGraph): GraphNodeManager => {
|
||||
// Get layout mutations composable
|
||||
const { createNode, deleteNode, setSource } = useLayoutMutations()
|
||||
const { moveNode, resizeNode, createNode, deleteNode, setSource } =
|
||||
useLayoutMutations()
|
||||
// Safe reactive data extracted from LiteGraph nodes
|
||||
const vueNodeData = reactive(new Map<string, VueNodeData>())
|
||||
const nodeState = reactive(new Map<string, NodeState>())
|
||||
const nodePositions = reactive(new Map<string, { x: number; y: number }>())
|
||||
const nodeSizes = reactive(
|
||||
new Map<string, { width: number; height: number }>()
|
||||
)
|
||||
|
||||
// Non-reactive storage for original LiteGraph nodes
|
||||
const nodeRefs = new Map<string, LGraphNode>()
|
||||
|
||||
// WeakMap for heavy data that auto-GCs when nodes are removed
|
||||
const nodeMetadata = new WeakMap<LGraphNode, NodeMetadata>()
|
||||
|
||||
// Performance tracking
|
||||
const performanceMetrics = reactive<PerformanceMetrics>({
|
||||
fps: 0,
|
||||
frameTime: 0,
|
||||
updateTime: 0,
|
||||
nodeCount: 0,
|
||||
culledCount: 0,
|
||||
callbackUpdateCount: 0,
|
||||
rafUpdateCount: 0,
|
||||
adaptiveQuality: false
|
||||
})
|
||||
|
||||
// Spatial indexing using QuadTree
|
||||
const spatialIndex = new QuadTree<string>(
|
||||
{ x: -10000, y: -10000, width: 20000, height: 20000 },
|
||||
{ maxDepth: 6, maxItemsPerNode: 4 }
|
||||
)
|
||||
let lastSpatialQueryTime = 0
|
||||
|
||||
// Spatial metrics
|
||||
const spatialMetrics = reactive<SpatialMetrics>({
|
||||
queryTime: 0,
|
||||
nodesInIndex: 0
|
||||
})
|
||||
|
||||
// Update batching
|
||||
const pendingUpdates = new Set<string>()
|
||||
const criticalUpdates = new Set<string>()
|
||||
const lowPriorityUpdates = new Set<string>()
|
||||
let updateScheduled = false
|
||||
let batchTimeoutId: number | null = null
|
||||
|
||||
// Change detection state
|
||||
const lastNodesSnapshot = new Map<
|
||||
string,
|
||||
{ pos: [number, number]; size: [number, number] }
|
||||
>()
|
||||
|
||||
const attachMetadata = (node: LGraphNode) => {
|
||||
nodeMetadata.set(node, {
|
||||
lastRenderTime: performance.now(),
|
||||
cachedBounds: null,
|
||||
lodLevel: 'high',
|
||||
spatialIndex: undefined
|
||||
})
|
||||
}
|
||||
|
||||
// Extract safe data from LiteGraph node for Vue consumption
|
||||
const extractVueNodeData = (node: LGraphNode): VueNodeData => {
|
||||
// Determine subgraph ID - null for root graph, string for subgraphs
|
||||
@@ -176,6 +286,7 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
...currentData,
|
||||
widgets: updatedWidgets
|
||||
})
|
||||
performanceMetrics.callbackUpdateCount++
|
||||
} catch (error) {
|
||||
// Ignore widget update errors to prevent cascade failures
|
||||
}
|
||||
@@ -245,6 +356,71 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
})
|
||||
}
|
||||
|
||||
// Uncomment when needed for future features
|
||||
// const getNodeMetadata = (node: LGraphNode): NodeMetadata => {
|
||||
// let metadata = nodeMetadata.get(node)
|
||||
// if (!metadata) {
|
||||
// attachMetadata(node)
|
||||
// metadata = nodeMetadata.get(node)!
|
||||
// }
|
||||
// return metadata
|
||||
// }
|
||||
|
||||
const scheduleUpdate = (
|
||||
nodeId?: string,
|
||||
priority: 'critical' | 'normal' | 'low' = 'normal'
|
||||
) => {
|
||||
if (nodeId) {
|
||||
const state = nodeState.get(nodeId)
|
||||
if (state) state.dirty = true
|
||||
|
||||
// Priority queuing
|
||||
if (priority === 'critical') {
|
||||
criticalUpdates.add(nodeId)
|
||||
flush() // Immediate flush for critical updates
|
||||
return
|
||||
} else if (priority === 'low') {
|
||||
lowPriorityUpdates.add(nodeId)
|
||||
} else {
|
||||
pendingUpdates.add(nodeId)
|
||||
}
|
||||
}
|
||||
|
||||
if (!updateScheduled) {
|
||||
updateScheduled = true
|
||||
|
||||
// Adaptive batching strategy
|
||||
if (pendingUpdates.size > 10) {
|
||||
// Many updates - batch in nextTick
|
||||
void nextTick(() => flush())
|
||||
} else {
|
||||
// Few updates - small delay for more batching
|
||||
batchTimeoutId = window.setTimeout(() => flush(), 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const flush = () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
if (batchTimeoutId !== null) {
|
||||
clearTimeout(batchTimeoutId)
|
||||
batchTimeoutId = null
|
||||
}
|
||||
|
||||
// Clear all pending updates
|
||||
criticalUpdates.clear()
|
||||
pendingUpdates.clear()
|
||||
lowPriorityUpdates.clear()
|
||||
updateScheduled = false
|
||||
|
||||
// Sync with graph state
|
||||
syncWithGraph()
|
||||
|
||||
const endTime = performance.now()
|
||||
performanceMetrics.updateTime = endTime - startTime
|
||||
}
|
||||
|
||||
const syncWithGraph = () => {
|
||||
if (!graph?._nodes) return
|
||||
|
||||
@@ -255,6 +431,11 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
if (!currentNodes.has(id)) {
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
nodeState.delete(id)
|
||||
nodePositions.delete(id)
|
||||
nodeSizes.delete(id)
|
||||
lastNodesSnapshot.delete(id)
|
||||
spatialIndex.remove(id)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -270,7 +451,163 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
// Extract and store safe data for Vue
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
if (!nodeState.has(id)) {
|
||||
nodeState.set(id, {
|
||||
visible: true,
|
||||
dirty: false,
|
||||
lastUpdate: performance.now(),
|
||||
culled: false
|
||||
})
|
||||
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||
attachMetadata(node)
|
||||
|
||||
// Add to spatial index
|
||||
const bounds: Bounds = {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
spatialIndex.insert(id, bounds, id)
|
||||
}
|
||||
})
|
||||
|
||||
// Update performance metrics
|
||||
performanceMetrics.nodeCount = vueNodeData.size
|
||||
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
|
||||
(s) => s.culled
|
||||
).length
|
||||
}
|
||||
|
||||
// Most performant: Direct position sync without re-setting entire node
|
||||
// Query visible nodes using QuadTree spatial index
|
||||
const getVisibleNodeIds = (viewportBounds: Bounds): Set<string> => {
|
||||
const startTime = performance.now()
|
||||
|
||||
// Use QuadTree for fast spatial query
|
||||
const results: string[] = spatialIndex.query(viewportBounds)
|
||||
const visibleIds = new Set(results)
|
||||
|
||||
lastSpatialQueryTime = performance.now() - startTime
|
||||
spatialMetrics.queryTime = lastSpatialQueryTime
|
||||
|
||||
return visibleIds
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects position changes for a single node and updates reactive state
|
||||
*/
|
||||
const detectPositionChanges = (node: LGraphNode, id: string): boolean => {
|
||||
const currentPos = nodePositions.get(id)
|
||||
|
||||
if (
|
||||
!currentPos ||
|
||||
currentPos.x !== node.pos[0] ||
|
||||
currentPos.y !== node.pos[1]
|
||||
) {
|
||||
nodePositions.set(id, { x: node.pos[0], y: node.pos[1] })
|
||||
|
||||
// Push position change to layout store
|
||||
// Source is already set to 'canvas' in detectChangesInRAF
|
||||
void moveNode(id, { x: node.pos[0], y: node.pos[1] })
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects size changes for a single node and updates reactive state
|
||||
*/
|
||||
const detectSizeChanges = (node: LGraphNode, id: string): boolean => {
|
||||
const currentSize = nodeSizes.get(id)
|
||||
|
||||
if (
|
||||
!currentSize ||
|
||||
currentSize.width !== node.size[0] ||
|
||||
currentSize.height !== node.size[1]
|
||||
) {
|
||||
nodeSizes.set(id, { width: node.size[0], height: node.size[1] })
|
||||
|
||||
// Push size change to layout store
|
||||
// Source is already set to 'canvas' in detectChangesInRAF
|
||||
void resizeNode(id, {
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates spatial index for a node if bounds changed
|
||||
*/
|
||||
const updateSpatialIndex = (node: LGraphNode, id: string): void => {
|
||||
const bounds: Bounds = {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size[0],
|
||||
height: node.size[1]
|
||||
}
|
||||
spatialIndex.update(id, bounds)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates performance metrics after change detection
|
||||
*/
|
||||
const updatePerformanceMetrics = (
|
||||
startTime: number,
|
||||
positionUpdates: number,
|
||||
sizeUpdates: number
|
||||
): void => {
|
||||
const endTime = performance.now()
|
||||
performanceMetrics.updateTime = endTime - startTime
|
||||
performanceMetrics.nodeCount = vueNodeData.size
|
||||
performanceMetrics.culledCount = Array.from(nodeState.values()).filter(
|
||||
(state) => state.culled
|
||||
).length
|
||||
spatialMetrics.nodesInIndex = spatialIndex.size
|
||||
|
||||
if (positionUpdates > 0 || sizeUpdates > 0) {
|
||||
performanceMetrics.rafUpdateCount++
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Main RAF change detection function
|
||||
*/
|
||||
const detectChangesInRAF = () => {
|
||||
const startTime = performance.now()
|
||||
|
||||
if (!graph?._nodes) return
|
||||
|
||||
let positionUpdates = 0
|
||||
let sizeUpdates = 0
|
||||
|
||||
// Set source for all canvas-driven updates
|
||||
setSource(LayoutSource.Canvas)
|
||||
|
||||
// Process each node for changes
|
||||
for (const node of graph._nodes) {
|
||||
const id = String(node.id)
|
||||
|
||||
const posChanged = detectPositionChanges(node, id)
|
||||
const sizeChanged = detectSizeChanges(node, id)
|
||||
|
||||
if (posChanged) positionUpdates++
|
||||
if (sizeChanged) sizeUpdates++
|
||||
|
||||
// Update spatial index if geometry changed
|
||||
if (posChanged || sizeChanged) {
|
||||
updateSpatialIndex(node, id)
|
||||
}
|
||||
}
|
||||
|
||||
updatePerformanceMetrics(startTime, positionUpdates, sizeUpdates)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -292,11 +629,32 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Extract initial data for Vue (may be incomplete during graph configure)
|
||||
vueNodeData.set(id, extractVueNodeData(node))
|
||||
|
||||
// Set up reactive tracking state
|
||||
nodeState.set(id, {
|
||||
visible: true,
|
||||
dirty: false,
|
||||
lastUpdate: performance.now(),
|
||||
culled: false
|
||||
})
|
||||
|
||||
const initializeVueNodeLayout = () => {
|
||||
// Extract actual positions after configure() has potentially updated them
|
||||
const nodePosition = { x: node.pos[0], y: node.pos[1] }
|
||||
const nodeSize = { width: node.size[0], height: node.size[1] }
|
||||
|
||||
nodePositions.set(id, nodePosition)
|
||||
nodeSizes.set(id, nodeSize)
|
||||
attachMetadata(node)
|
||||
|
||||
// Add to spatial index for viewport culling with final positions
|
||||
const nodeBounds: Bounds = {
|
||||
x: nodePosition.x,
|
||||
y: nodePosition.y,
|
||||
width: nodeSize.width,
|
||||
height: nodeSize.height
|
||||
}
|
||||
spatialIndex.insert(id, nodeBounds, id)
|
||||
|
||||
// Add node to layout store with final positions
|
||||
setSource(LayoutSource.Canvas)
|
||||
void createNode(id, {
|
||||
@@ -340,6 +698,9 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
) => {
|
||||
const id = String(node.id)
|
||||
|
||||
// Remove from spatial index
|
||||
spatialIndex.remove(id)
|
||||
|
||||
// Remove node from layout store
|
||||
setSource(LayoutSource.Canvas)
|
||||
void deleteNode(id)
|
||||
@@ -347,6 +708,10 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
// Clean up all tracking references
|
||||
nodeRefs.delete(id)
|
||||
vueNodeData.delete(id)
|
||||
nodeState.delete(id)
|
||||
nodePositions.delete(id)
|
||||
nodeSizes.delete(id)
|
||||
lastNodesSnapshot.delete(id)
|
||||
|
||||
// Call original callback if provided
|
||||
if (originalCallback) {
|
||||
@@ -368,9 +733,23 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
graph.onNodeRemoved = originalOnNodeRemoved || undefined
|
||||
graph.onTrigger = originalOnTrigger || undefined
|
||||
|
||||
// Clear pending updates
|
||||
if (batchTimeoutId !== null) {
|
||||
clearTimeout(batchTimeoutId)
|
||||
batchTimeoutId = null
|
||||
}
|
||||
|
||||
// Clear all state maps
|
||||
nodeRefs.clear()
|
||||
vueNodeData.clear()
|
||||
nodeState.clear()
|
||||
nodePositions.clear()
|
||||
nodeSizes.clear()
|
||||
lastNodesSnapshot.clear()
|
||||
pendingUpdates.clear()
|
||||
criticalUpdates.clear()
|
||||
lowPriorityUpdates.clear()
|
||||
spatialIndex.clear()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -466,7 +845,18 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
|
||||
return {
|
||||
vueNodeData,
|
||||
nodeState,
|
||||
nodePositions,
|
||||
nodeSizes,
|
||||
getNode,
|
||||
cleanup
|
||||
setupEventListeners,
|
||||
cleanup,
|
||||
scheduleUpdate,
|
||||
forceSync: syncWithGraph,
|
||||
detectChangesInRAF,
|
||||
getVisibleNodeIds,
|
||||
performanceMetrics,
|
||||
spatialMetrics,
|
||||
getSpatialIndexDebugInfo: () => spatialIndex.getDebugInfo()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,18 +6,21 @@
|
||||
* 2. Set display none on element to avoid cascade resolution overhead
|
||||
* 3. Only run when transform changes (event driven)
|
||||
*/
|
||||
import { useThrottleFn } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useVueNodeLifecycle } from '@/composables/graph/useVueNodeLifecycle'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { app as comfyApp } from '@/scripts/app'
|
||||
|
||||
export function useViewportCulling() {
|
||||
const canvasStore = useCanvasStore()
|
||||
const { vueNodeData, nodeManager } = useVueNodeLifecycle()
|
||||
const { shouldRenderVueNodes } = useVueFeatureFlags()
|
||||
const { vueNodeData, nodeDataTrigger, nodeManager } = useVueNodeLifecycle()
|
||||
|
||||
const allNodes = computed(() => {
|
||||
if (!shouldRenderVueNodes.value) return []
|
||||
void nodeDataTrigger.value // Force re-evaluation when nodeManager initializes
|
||||
return Array.from(vueNodeData.value.values())
|
||||
})
|
||||
|
||||
@@ -25,7 +28,7 @@ export function useViewportCulling() {
|
||||
* Update visibility of all nodes based on viewport
|
||||
* Queries DOM directly - no cache maintenance needed
|
||||
*/
|
||||
function updateVisibility() {
|
||||
const updateVisibility = () => {
|
||||
if (!nodeManager.value || !canvasStore.canvas || !comfyApp.canvas) return
|
||||
|
||||
const canvas = canvasStore.canvas
|
||||
@@ -67,17 +70,31 @@ export function useViewportCulling() {
|
||||
}
|
||||
}
|
||||
|
||||
const updateVisibilityDebounced = useThrottleFn(updateVisibility, 20)
|
||||
|
||||
// RAF throttling for smooth updates during continuous panning
|
||||
function handleTransformUpdate() {
|
||||
requestAnimationFrame(async () => {
|
||||
await updateVisibilityDebounced()
|
||||
let rafId: number | null = null
|
||||
|
||||
/**
|
||||
* Handle transform update - called by TransformPane event
|
||||
* Uses RAF to batch updates for smooth performance
|
||||
*/
|
||||
const handleTransformUpdate = () => {
|
||||
if (!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 {
|
||||
allNodes,
|
||||
handleTransformUpdate
|
||||
handleTransformUpdate,
|
||||
updateVisibility
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,20 @@
|
||||
/**
|
||||
* 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 { readonly, ref, shallowRef, watch } from 'vue'
|
||||
import { computed, readonly, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
|
||||
import type {
|
||||
GraphNodeManager,
|
||||
NodeState,
|
||||
VueNodeData
|
||||
} from '@/composables/graph/useGraphNodeManager'
|
||||
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
|
||||
@@ -31,10 +42,22 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Vue node data state
|
||||
const vueNodeData = ref<ReadonlyMap<string, VueNodeData>>(new Map())
|
||||
const nodeState = ref<ReadonlyMap<string, NodeState>>(new Map())
|
||||
const nodePositions = ref<ReadonlyMap<string, { x: number; y: number }>>(
|
||||
new Map()
|
||||
)
|
||||
const nodeSizes = ref<ReadonlyMap<string, { width: number; height: number }>>(
|
||||
new Map()
|
||||
)
|
||||
|
||||
// Change detection function
|
||||
const detectChangesInRAF = ref<() => void>(() => {})
|
||||
|
||||
// Trigger for forcing computed re-evaluation
|
||||
const nodeDataTrigger = ref(0)
|
||||
|
||||
const isNodeManagerReady = computed(() => nodeManager.value !== null)
|
||||
|
||||
const initializeNodeManager = () => {
|
||||
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
|
||||
const activeGraph = comfyApp.canvas?.graph || comfyApp.graph
|
||||
@@ -47,6 +70,10 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Use the manager's data maps
|
||||
vueNodeData.value = manager.vueNodeData
|
||||
nodeState.value = manager.nodeState
|
||||
nodePositions.value = manager.nodePositions
|
||||
nodeSizes.value = manager.nodeSizes
|
||||
detectChangesInRAF.value = manager.detectChangesInRAF
|
||||
|
||||
// Initialize layout system with existing nodes from active graph
|
||||
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
|
||||
@@ -109,6 +136,12 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
// Reset reactive maps to clean state
|
||||
vueNodeData.value = new Map()
|
||||
nodeState.value = new Map()
|
||||
nodePositions.value = new Map()
|
||||
nodeSizes.value = new Map()
|
||||
|
||||
// Reset change detection function
|
||||
detectChangesInRAF.value = () => {}
|
||||
}
|
||||
|
||||
// Watch for Vue nodes enabled state changes
|
||||
@@ -202,7 +235,13 @@ function useVueNodeLifecycleIndividual() {
|
||||
|
||||
return {
|
||||
vueNodeData,
|
||||
nodeState,
|
||||
nodePositions,
|
||||
nodeSizes,
|
||||
nodeDataTrigger: readonly(nodeDataTrigger),
|
||||
nodeManager: readonly(nodeManager),
|
||||
detectChangesInRAF: readonly(detectChangesInRAF),
|
||||
isNodeManagerReady,
|
||||
|
||||
// Lifecycle methods
|
||||
initializeNodeManager,
|
||||
|
||||
@@ -4032,18 +4032,6 @@ export class LGraphCanvas
|
||||
|
||||
// TODO: Report failures, i.e. `failedNodes`
|
||||
|
||||
const newPositions = created.map((node) => ({
|
||||
nodeId: String(node.id),
|
||||
bounds: {
|
||||
x: node.pos[0],
|
||||
y: node.pos[1],
|
||||
width: node.size?.[0] ?? 100,
|
||||
height: node.size?.[1] ?? 200
|
||||
}
|
||||
}))
|
||||
|
||||
layoutStore.batchUpdateNodeBounds(newPositions)
|
||||
|
||||
this.selectItems(created)
|
||||
|
||||
graph.afterChange()
|
||||
|
||||
@@ -82,7 +82,6 @@ export interface Positionable extends Parent<Positionable>, HasBoundingRect {
|
||||
* @default 0,0
|
||||
*/
|
||||
readonly pos: Point
|
||||
readonly size?: Size
|
||||
/** true if this object is part of the selection, otherwise false. */
|
||||
selected?: boolean
|
||||
|
||||
|
||||
21
src/main.ts
21
src/main.ts
@@ -2,6 +2,11 @@ import { definePreset } from '@primevue/themes'
|
||||
import Aura from '@primevue/themes/aura'
|
||||
import * as Sentry from '@sentry/vue'
|
||||
import { initializeApp } from 'firebase/app'
|
||||
import {
|
||||
browserLocalPersistence,
|
||||
browserSessionPersistence,
|
||||
indexedDBLocalPersistence
|
||||
} from 'firebase/auth'
|
||||
import { createPinia } from 'pinia'
|
||||
import 'primeicons/primeicons.css'
|
||||
import PrimeVue from 'primevue/config'
|
||||
@@ -9,7 +14,7 @@ import ConfirmationService from 'primevue/confirmationservice'
|
||||
import ToastService from 'primevue/toastservice'
|
||||
import Tooltip from 'primevue/tooltip'
|
||||
import { createApp } from 'vue'
|
||||
import { VueFire, VueFireAuth } from 'vuefire'
|
||||
import { VueFire, VueFireAuthWithDependencies } from 'vuefire'
|
||||
|
||||
import { FIREBASE_CONFIG } from '@/config/firebase'
|
||||
import '@/lib/litegraph/public/css/litegraph.css'
|
||||
@@ -66,6 +71,18 @@ app
|
||||
.use(i18n)
|
||||
.use(VueFire, {
|
||||
firebaseApp,
|
||||
modules: [VueFireAuth()]
|
||||
modules: [
|
||||
// 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')
|
||||
|
||||
@@ -1379,7 +1379,6 @@ class LayoutStoreImpl implements LayoutStore {
|
||||
|
||||
this.spatialIndex.update(nodeId, bounds)
|
||||
ynode.set('bounds', bounds)
|
||||
ynode.set('position', { x: bounds.x, y: bounds.y })
|
||||
ynode.set('size', { width: bounds.width, height: bounds.height })
|
||||
}
|
||||
}, this.currentActor)
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
<template>
|
||||
<div
|
||||
class="absolute inset-0 w-full h-full pointer-events-none"
|
||||
:class="
|
||||
cn(
|
||||
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
|
||||
isLOD ? 'isLOD' : ''
|
||||
)
|
||||
"
|
||||
class="transform-pane"
|
||||
:class="{ 'transform-pane--interacting': isInteracting }"
|
||||
:style="transformStyle"
|
||||
@pointerdown="handlePointerDown"
|
||||
>
|
||||
@@ -23,8 +18,6 @@ import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
import { useCanvasTransformSync } from '@/renderer/core/layout/transform/useCanvasTransformSync'
|
||||
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
|
||||
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
interface TransformPaneProps {
|
||||
canvas?: LGraphCanvas
|
||||
@@ -41,8 +34,6 @@ const {
|
||||
isNodeInViewport
|
||||
} = useTransformState()
|
||||
|
||||
const { isLOD } = useLOD(camera)
|
||||
|
||||
const canvasElement = computed(() => props.canvas?.canvas)
|
||||
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
|
||||
settleDelay: 200,
|
||||
|
||||
@@ -94,20 +94,17 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="relative">
|
||||
<!-- Image Dimensions -->
|
||||
<div class="text-white text-xs text-center mt-2">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-gray-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
<LODFallback />
|
||||
<!-- Image Dimensions -->
|
||||
<div class="text-white text-xs text-center mt-2">
|
||||
<span v-if="imageError" class="text-red-400">
|
||||
{{ $t('g.errorLoadingImage') }}
|
||||
</span>
|
||||
<span v-else-if="isLoading" class="text-gray-400">
|
||||
{{ $t('g.loading') }}...
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ actualDimensions || $t('g.calculatingDimensions') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -122,8 +119,6 @@ import { downloadFile } from '@/base/common/downloadUtil'
|
||||
import { useCommandStore } from '@/stores/commandStore'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
|
||||
interface ImagePreviewProps {
|
||||
/** Array of image URLs to display */
|
||||
readonly imageUrls: readonly string[]
|
||||
|
||||
@@ -10,15 +10,12 @@
|
||||
/>
|
||||
|
||||
<!-- Slot Name -->
|
||||
<div class="relative">
|
||||
<span
|
||||
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>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
|
||||
>
|
||||
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -41,7 +38,6 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface InputSlotProps {
|
||||
|
||||
@@ -2,39 +2,144 @@
|
||||
<div v-if="renderError" class="node-error p-2 text-red-500 text-sm">
|
||||
{{ $t('Node Render Error') }}
|
||||
</div>
|
||||
<NodeBaseTemplate
|
||||
<div
|
||||
v-else
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:container-classes="containerClasses"
|
||||
:container-style="containerStyle"
|
||||
:is-collapsed="isCollapsed"
|
||||
:separator-classes="separatorClasses"
|
||||
:progress-classes="progressClasses"
|
||||
:progress-bar-classes="progressBarClasses"
|
||||
:show-progress="showProgress"
|
||||
:progress-value="progress"
|
||||
:progress-style="progressStyle"
|
||||
:progress-bar-style="progressBarStyle"
|
||||
:has-custom-content="hasCustomContent"
|
||||
:image-urls="nodeImageUrls"
|
||||
:show-preview-image="shouldShowPreviewImg"
|
||||
:preview-image-url="latestPreviewUrl"
|
||||
:event-handlers="{
|
||||
onPointerdown: handlePointerDown,
|
||||
onPointermove: handlePointerMove,
|
||||
onPointerup: handlePointerUp,
|
||||
onWheel: handleWheel
|
||||
}"
|
||||
@collapse="handleCollapse"
|
||||
@update:title="handleHeaderTitleUpdate"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
/>
|
||||
ref="nodeContainerRef"
|
||||
:data-node-id="nodeData.id"
|
||||
:class="
|
||||
cn(
|
||||
'bg-white dark-theme:bg-charcoal-800',
|
||||
'lg-node absolute rounded-2xl',
|
||||
'border border-solid border-sand-100 dark-theme:border-charcoal-600',
|
||||
// hover (only when node should handle events)
|
||||
shouldHandleNodePointerEvents &&
|
||||
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
|
||||
'outline-transparent -outline-offset-2 outline-2',
|
||||
borderClass,
|
||||
outlineClass,
|
||||
{
|
||||
'animate-pulse': executing,
|
||||
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
||||
bypassed,
|
||||
'will-change-transform': isDragging
|
||||
},
|
||||
lodCssClass,
|
||||
shouldHandleNodePointerEvents
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
"
|
||||
:style="[
|
||||
{
|
||||
transform: `translate(${layoutPosition.x ?? position?.x ?? 0}px, ${(layoutPosition.y ?? position?.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
zIndex: zIndex
|
||||
},
|
||||
dragStyle
|
||||
]"
|
||||
@pointerdown="handlePointerDown"
|
||||
@pointermove="handlePointerMove"
|
||||
@pointerup="handlePointerUp"
|
||||
@wheel="handleWheel"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<template v-if="isCollapsed">
|
||||
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
|
||||
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
|
||||
</template>
|
||||
<!-- Header only updates on title/color changes -->
|
||||
<NodeHeader
|
||||
v-memo="[nodeData.title, lodLevel, isCollapsed]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
:collapsed="isCollapsed"
|
||||
@collapse="handleCollapse"
|
||||
@update:title="handleHeaderTitleUpdate"
|
||||
@enter-subgraph="handleEnterSubgraph"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
(isMinimalLOD || isCollapsed) && executing && progress !== undefined
|
||||
"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
|
||||
progressClasses
|
||||
)
|
||||
"
|
||||
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
||||
/>
|
||||
|
||||
<template v-if="!isMinimalLOD && !isCollapsed">
|
||||
<div class="mb-4 relative">
|
||||
<div :class="separatorClasses" />
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
v-if="executing && progress !== undefined"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-0 top-1/2 -translate-y-1/2',
|
||||
!!(progress < 1) && 'rounded-r-full',
|
||||
progressClasses
|
||||
)
|
||||
"
|
||||
:style="{ width: `${Math.min(progress * 100, 100)}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex flex-col gap-4 pb-4"
|
||||
:data-testid="`node-body-${nodeData.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
<NodeSlots
|
||||
v-if="shouldRenderSlots"
|
||||
v-memo="[nodeData.inputs?.length, nodeData.outputs?.length, lodLevel]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
/>
|
||||
|
||||
<!-- Widgets rendered at reduced+ detail -->
|
||||
<NodeWidgets
|
||||
v-if="shouldShowWidgets"
|
||||
v-memo="[nodeData.widgets?.length, lodLevel]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
/>
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<NodeContent
|
||||
v-if="shouldShowContent"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:lod-level="lodLevel"
|
||||
:image-urls="nodeImageUrls"
|
||||
/>
|
||||
<!-- Live preview image -->
|
||||
<div
|
||||
v-if="shouldShowPreviewImg"
|
||||
v-memo="[latestPreviewUrl]"
|
||||
class="px-4"
|
||||
>
|
||||
<img
|
||||
:src="latestPreviewUrl"
|
||||
alt="preview"
|
||||
class="w-full max-h-64 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, inject, onErrorCaptured, onMounted, ref } from 'vue'
|
||||
import { computed, inject, onErrorCaptured, onMounted, provide, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
@@ -47,6 +152,7 @@ import { useNodePointerInteractions } from '@/renderer/extensions/vueNodes/compo
|
||||
import { useVueElementTracking } from '@/renderer/extensions/vueNodes/composables/useVueNodeResizeTracking'
|
||||
import { useNodeExecutionState } from '@/renderer/extensions/vueNodes/execution/useNodeExecutionState'
|
||||
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 { app } from '@/scripts/app'
|
||||
import { useExecutionStore } from '@/stores/executionStore'
|
||||
@@ -57,11 +163,17 @@ import {
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import NodeBaseTemplate from './NodeBaseTemplate.vue'
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
import NodeWidgets from './NodeWidgets.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
// Extended props for main node component
|
||||
interface LGraphNodeProps {
|
||||
nodeData: VueNodeData
|
||||
position?: { x: number; y: number }
|
||||
size?: { width: number; height: number }
|
||||
readonly?: boolean
|
||||
error?: string | null
|
||||
zoomLevel?: number
|
||||
@@ -69,8 +181,11 @@ interface LGraphNodeProps {
|
||||
|
||||
const {
|
||||
nodeData,
|
||||
position = { x: 0, y: 0 },
|
||||
size = { width: 100, height: 50 },
|
||||
error = null,
|
||||
readonly = false
|
||||
readonly = false,
|
||||
zoomLevel = 1
|
||||
} = defineProps<LGraphNodeProps>()
|
||||
|
||||
const { handleNodeCollapse, handleNodeTitleUpdate, handleNodeSelect } =
|
||||
@@ -107,6 +222,18 @@ const bypassed = computed((): boolean => nodeData.mode === 4)
|
||||
// Use canvas interactions for proper wheel event handling and pointer event capture control
|
||||
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
|
||||
const renderError = ref<string | null>(null)
|
||||
const { toastErrorHandler } = useErrorHandling()
|
||||
@@ -118,7 +245,11 @@ onErrorCaptured((error) => {
|
||||
})
|
||||
|
||||
// Use layout system for node position and dragging
|
||||
const { position, size, zIndex, resize } = useNodeLayout(() => nodeData.id)
|
||||
const {
|
||||
position: layoutPosition,
|
||||
zIndex,
|
||||
resize
|
||||
} = useNodeLayout(() => nodeData.id)
|
||||
const {
|
||||
handlePointerDown,
|
||||
handlePointerUp,
|
||||
@@ -128,33 +259,56 @@ const {
|
||||
} = useNodePointerInteractions(() => nodeData, handleNodeSelect)
|
||||
|
||||
onMounted(() => {
|
||||
if (size.value && transformState?.camera) {
|
||||
if (size && transformState?.camera) {
|
||||
const scale = transformState.camera.z
|
||||
const screenSize = {
|
||||
width: size.value.width * scale,
|
||||
height: size.value.height * scale
|
||||
width: size.width * scale,
|
||||
height: size.height * scale
|
||||
}
|
||||
resize(screenSize)
|
||||
}
|
||||
})
|
||||
|
||||
// Collapsed state
|
||||
// Track collapsed state
|
||||
const isCollapsed = computed(() => nodeData.flags?.collapsed ?? false)
|
||||
|
||||
// Show progress when executing with defined progress
|
||||
const showProgress = computed(() => {
|
||||
return !!(executing.value && progress.value !== undefined)
|
||||
// Check if node has custom content (like image outputs)
|
||||
const hasCustomContent = computed(() => {
|
||||
// Show custom content if node has image outputs
|
||||
return nodeImageUrls.value.length > 0
|
||||
})
|
||||
|
||||
// Progress styles
|
||||
const progressStyle = computed(() => {
|
||||
if (!showProgress.value || !progress.value) return undefined
|
||||
return { width: `${Math.min(progress.value * 100, 100)}%` }
|
||||
// Computed classes and conditions for better reusability
|
||||
const separatorClasses = cn(
|
||||
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full'
|
||||
)
|
||||
const progressClasses = cn('h-2 bg-primary-500 transition-all duration-300')
|
||||
|
||||
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
() => nodeData.id,
|
||||
{
|
||||
isMinimalLOD,
|
||||
isCollapsed
|
||||
}
|
||||
)
|
||||
|
||||
// Check if any widget bypasses LOD restrictions
|
||||
const hasLODBypassWidgets = computed(() => {
|
||||
if (!nodeData.widgets?.length) return false
|
||||
return nodeData.widgets.some((w: any) => w.options?.bypassLOD === true)
|
||||
})
|
||||
|
||||
const progressBarStyle = progressStyle
|
||||
// Common condition computations to avoid repetition
|
||||
const shouldShowWidgets = computed(
|
||||
() =>
|
||||
(shouldRenderWidgets.value || hasLODBypassWidgets.value) &&
|
||||
nodeData.widgets?.length
|
||||
)
|
||||
|
||||
const shouldShowContent = computed(
|
||||
() => shouldRenderContent.value && hasCustomContent.value
|
||||
)
|
||||
|
||||
// Border class based on state
|
||||
const borderClass = computed(() => {
|
||||
if (hasAnyError.value) {
|
||||
return 'border-error'
|
||||
@@ -165,7 +319,6 @@ const borderClass = computed(() => {
|
||||
return undefined
|
||||
})
|
||||
|
||||
// Outline class based on selection and state
|
||||
const outlineClass = computed(() => {
|
||||
if (!isSelected.value) {
|
||||
return undefined
|
||||
@@ -179,64 +332,6 @@ const outlineClass = computed(() => {
|
||||
return 'outline-black dark-theme:outline-white'
|
||||
})
|
||||
|
||||
// Container classes
|
||||
const containerClasses = computed(() => {
|
||||
return cn(
|
||||
'bg-white dark-theme:bg-charcoal-800',
|
||||
'lg-node absolute rounded-2xl',
|
||||
'border border-solid border-sand-100 dark-theme:border-charcoal-600',
|
||||
// hover (only when node should handle events)
|
||||
shouldHandleNodePointerEvents.value &&
|
||||
'hover:ring-7 ring-gray-500/50 dark-theme:ring-gray-500/20',
|
||||
'outline-transparent -outline-offset-2 outline-2',
|
||||
borderClass.value,
|
||||
outlineClass.value,
|
||||
{
|
||||
'animate-pulse': executing.value,
|
||||
'opacity-50 before:rounded-2xl before:pointer-events-none before:absolute before:bg-bypass/60 before:inset-0':
|
||||
bypassed.value,
|
||||
'will-change-transform': isDragging.value
|
||||
},
|
||||
shouldHandleNodePointerEvents.value
|
||||
? 'pointer-events-auto'
|
||||
: 'pointer-events-none'
|
||||
)
|
||||
})
|
||||
|
||||
const progressBarClasses = computed(() => {
|
||||
return cn(
|
||||
'absolute inset-x-4 -bottom-[1px] translate-y-1/2 rounded-full',
|
||||
progressClasses
|
||||
)
|
||||
})
|
||||
|
||||
// Static classes
|
||||
const separatorClasses =
|
||||
'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'
|
||||
|
||||
// Container style combining position and drag styles
|
||||
const containerStyle = computed(() => [
|
||||
{
|
||||
transform: `translate(${position.value.x ?? 0}px, ${(position.value.y ?? 0) - LiteGraph.NODE_TITLE_HEIGHT}px)`,
|
||||
zIndex: zIndex.value
|
||||
},
|
||||
dragStyle.value
|
||||
])
|
||||
|
||||
// Check if node has custom content (like image outputs)
|
||||
const hasCustomContent = computed(() => {
|
||||
// Show custom content if node has image outputs
|
||||
return nodeImageUrls.value.length > 0
|
||||
})
|
||||
|
||||
const { latestPreviewUrl, shouldShowPreviewImg } = useNodePreviewState(
|
||||
() => nodeData.id,
|
||||
{
|
||||
isCollapsed
|
||||
}
|
||||
)
|
||||
|
||||
// Event handlers
|
||||
const handleCollapse = () => {
|
||||
handleNodeCollapse(nodeData.id, !isCollapsed.value)
|
||||
@@ -297,4 +392,7 @@ const nodeImageUrls = computed(() => {
|
||||
// Clear URLs if no outputs or no images
|
||||
return []
|
||||
})
|
||||
|
||||
const nodeContainerRef = ref()
|
||||
provide('tooltipContainer', nodeContainerRef)
|
||||
</script>
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
<template>
|
||||
<div class="scale-75">
|
||||
<NodeBaseTemplate
|
||||
:node-data="nodeData"
|
||||
:readonly="true"
|
||||
:container-classes="containerClasses"
|
||||
:is-collapsed="false"
|
||||
:separator-classes="separatorClasses"
|
||||
:has-custom-content="false"
|
||||
:image-urls="[]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
import { useWidgetStore } from '@/stores/widgetStore'
|
||||
|
||||
import NodeBaseTemplate from './NodeBaseTemplate.vue'
|
||||
|
||||
const { nodeDef } = defineProps<{
|
||||
nodeDef: ComfyNodeDefV2
|
||||
}>()
|
||||
|
||||
const widgetStore = useWidgetStore()
|
||||
|
||||
// Convert nodeDef into VueNodeData
|
||||
const nodeData = computed<VueNodeData>(() => {
|
||||
// Convert inputs to widgets (those that have widget constructors)
|
||||
const widgets = Object.entries(nodeDef.inputs || {})
|
||||
.filter(([_, input]) => widgetStore.inputIsWidget(input))
|
||||
.map(([name, input]) => ({
|
||||
name,
|
||||
type: input.widgetType || input.type,
|
||||
value:
|
||||
input.default !== undefined
|
||||
? input.default
|
||||
: input.type === 'COMBO' &&
|
||||
Array.isArray(input.options) &&
|
||||
input.options.length > 0
|
||||
? input.options[0]
|
||||
: undefined,
|
||||
options: {
|
||||
...input,
|
||||
hidden: input.hidden,
|
||||
advanced: input.advanced,
|
||||
values: input.type === 'COMBO' ? input.options : undefined // For combo widgets
|
||||
}
|
||||
}))
|
||||
|
||||
// Filter non-widget inputs for slots
|
||||
const inputs = Object.entries(nodeDef.inputs || {})
|
||||
.filter(([_, input]) => !widgetStore.inputIsWidget(input))
|
||||
.map(([name, input]) => ({
|
||||
name,
|
||||
type: input.type,
|
||||
shape: input.isOptional ? 'HollowCircle' : undefined
|
||||
}))
|
||||
|
||||
return {
|
||||
id: `preview-${nodeDef.name}`,
|
||||
title: nodeDef.display_name || nodeDef.name,
|
||||
type: nodeDef.name,
|
||||
mode: 0, // Normal mode
|
||||
selected: false,
|
||||
executing: false,
|
||||
widgets,
|
||||
inputs,
|
||||
outputs: nodeDef.outputs || [],
|
||||
flags: {
|
||||
collapsed: false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Static classes for preview mode
|
||||
const containerClasses =
|
||||
'bg-white dark-theme:bg-charcoal-800 lg-node absolute rounded-2xl border border-solid border-sand-100 dark-theme:border-charcoal-600 outline-transparent -outline-offset-2 outline-2 pointer-events-none'
|
||||
const separatorClasses =
|
||||
'bg-sand-100 dark-theme:bg-charcoal-600 h-px mx-0 w-full'
|
||||
</script>
|
||||
@@ -1,3 +0,0 @@
|
||||
<template>
|
||||
<div class="lod-fallback absolute inset-0 w-full h-full bg-zinc-800"></div>
|
||||
</template>
|
||||
@@ -1,158 +0,0 @@
|
||||
<template>
|
||||
<div
|
||||
ref="nodeContainerRef"
|
||||
:data-node-id="nodeData?.id"
|
||||
:class="containerClasses"
|
||||
:style="containerStyle"
|
||||
v-bind="eventHandlers"
|
||||
>
|
||||
<div class="flex items-center">
|
||||
<template v-if="isCollapsed">
|
||||
<SlotConnectionDot multi class="absolute left-0 -translate-x-1/2" />
|
||||
<SlotConnectionDot multi class="absolute right-0 translate-x-1/2" />
|
||||
</template>
|
||||
<!-- Header only updates on title/color changes -->
|
||||
<NodeHeader
|
||||
v-memo="[nodeData?.title, isCollapsed]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:collapsed="isCollapsed"
|
||||
@collapse="emit('collapse')"
|
||||
@update:title="emit('update:title', $event)"
|
||||
@enter-subgraph="emit('enter-subgraph')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isCollapsed && showProgress"
|
||||
:class="progressBarClasses"
|
||||
:style="progressBarStyle"
|
||||
/>
|
||||
|
||||
<template v-if="!isCollapsed">
|
||||
<div class="mb-4 relative">
|
||||
<div :class="separatorClasses" />
|
||||
<!-- Progress bar for executing state -->
|
||||
<div
|
||||
v-if="showProgress"
|
||||
:class="
|
||||
cn(
|
||||
'absolute inset-x-0 top-1/2 -translate-y-1/2',
|
||||
!!(progressValue && progressValue < 1) && 'rounded-r-full',
|
||||
progressClasses
|
||||
)
|
||||
"
|
||||
:style="progressStyle"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Node Body - rendered based on LOD level and collapsed state -->
|
||||
<div
|
||||
class="flex flex-col gap-4 pb-4"
|
||||
:data-testid="`node-body-${nodeData?.id}`"
|
||||
>
|
||||
<!-- Slots only rendered at full detail -->
|
||||
<NodeSlots
|
||||
v-memo="[nodeData?.inputs?.length, nodeData?.outputs?.length]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
|
||||
<!-- Widgets rendered at reduced+ detail -->
|
||||
<NodeWidgets
|
||||
v-if="nodeData?.widgets?.length"
|
||||
v-memo="[nodeData?.widgets?.length]"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
/>
|
||||
|
||||
<!-- Custom content at reduced+ detail -->
|
||||
<NodeContent
|
||||
v-if="hasCustomContent"
|
||||
:node-data="nodeData"
|
||||
:readonly="readonly"
|
||||
:image-urls="imageUrls"
|
||||
/>
|
||||
<!-- Live preview image -->
|
||||
<div
|
||||
v-if="showPreviewImage && previewImageUrl"
|
||||
v-memo="[previewImageUrl]"
|
||||
class="px-4"
|
||||
>
|
||||
<img
|
||||
:src="previewImageUrl"
|
||||
alt="preview"
|
||||
class="w-full max-h-64 object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import NodeContent from './NodeContent.vue'
|
||||
import NodeHeader from './NodeHeader.vue'
|
||||
import NodeSlots from './NodeSlots.vue'
|
||||
import NodeWidgets from './NodeWidgets.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface NodeBaseTemplateProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
// Presentation state
|
||||
containerClasses?: string
|
||||
containerStyle?: any
|
||||
isCollapsed?: boolean
|
||||
separatorClasses?: string
|
||||
progressClasses?: string
|
||||
progressBarClasses?: string
|
||||
// Progress state
|
||||
showProgress?: boolean
|
||||
progressValue?: number
|
||||
progressStyle?: any
|
||||
progressBarStyle?: any
|
||||
// Content state
|
||||
hasCustomContent?: boolean
|
||||
imageUrls?: string[]
|
||||
showPreviewImage?: boolean
|
||||
previewImageUrl?: string
|
||||
// Event handlers object for v-bind
|
||||
eventHandlers?: Record<string, any>
|
||||
}
|
||||
|
||||
const {
|
||||
nodeData,
|
||||
readonly = false,
|
||||
containerClasses = '',
|
||||
containerStyle = {},
|
||||
isCollapsed = false,
|
||||
separatorClasses = '',
|
||||
progressClasses = '',
|
||||
progressBarClasses = '',
|
||||
showProgress = false,
|
||||
progressValue,
|
||||
progressStyle,
|
||||
progressBarStyle,
|
||||
hasCustomContent = false,
|
||||
imageUrls = [],
|
||||
showPreviewImage = false,
|
||||
previewImageUrl,
|
||||
eventHandlers = {}
|
||||
} = defineProps<NodeBaseTemplateProps>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
collapse: []
|
||||
'update:title': [newTitle: string]
|
||||
'enter-subgraph': []
|
||||
}>()
|
||||
|
||||
// Provide tooltip container for child components
|
||||
const nodeContainerRef = ref<HTMLElement>()
|
||||
provide('tooltipContainer', nodeContainerRef)
|
||||
</script>
|
||||
@@ -21,6 +21,7 @@ import { computed, onErrorCaptured, ref } from 'vue'
|
||||
import type { VueNodeData } from '@/composables/graph/useGraphNodeManager'
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
import ImagePreview from './ImagePreview.vue'
|
||||
|
||||
@@ -28,6 +29,7 @@ interface NodeContentProps {
|
||||
node?: LGraphNode // For backwards compatibility
|
||||
nodeData?: VueNodeData // New clean data structure
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
imageUrls?: string[]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,44 +4,41 @@
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="lg-node-header p-4 rounded-t-2xl cursor-move"
|
||||
class="lg-node-header flex items-center justify-between p-4 rounded-t-2xl cursor-move w-full"
|
||||
:data-testid="`node-header-${nodeData?.id || ''}`"
|
||||
@dblclick="handleDoubleClick"
|
||||
>
|
||||
<div class="flex items-center justify-between relative">
|
||||
<!-- Collapse/Expand Button -->
|
||||
<button
|
||||
v-show="!readonly"
|
||||
class="bg-transparent border-transparent flex items-center lod-toggle"
|
||||
data-testid="node-collapse-button"
|
||||
@click.stop="handleCollapse"
|
||||
@dblclick.stop
|
||||
>
|
||||
<i
|
||||
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
|
||||
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
|
||||
></i>
|
||||
</button>
|
||||
<!-- Collapse/Expand Button -->
|
||||
<button
|
||||
v-show="!readonly"
|
||||
class="bg-transparent border-transparent flex items-center"
|
||||
data-testid="node-collapse-button"
|
||||
@click.stop="handleCollapse"
|
||||
@dblclick.stop
|
||||
>
|
||||
<i
|
||||
:class="collapsed ? 'pi pi-chevron-right' : 'pi pi-chevron-down'"
|
||||
class="text-xs leading-none relative top-px text-stone-200 dark-theme:text-slate-300"
|
||||
></i>
|
||||
</button>
|
||||
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="text-sm font-bold truncate flex-1 lod-toggle"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
</div>
|
||||
<LODFallback />
|
||||
<!-- Node Title -->
|
||||
<div
|
||||
v-tooltip.top="tooltipConfig"
|
||||
class="text-sm font-bold truncate flex-1"
|
||||
data-testid="node-title"
|
||||
>
|
||||
<EditableText
|
||||
:model-value="displayTitle"
|
||||
:is-editing="isEditing"
|
||||
:input-attrs="{ 'data-testid': 'node-title-input' }"
|
||||
@edit="handleTitleEdit"
|
||||
@cancel="handleTitleCancel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Title Buttons -->
|
||||
<div v-if="!readonly" class="flex items-center lod-toggle">
|
||||
<div v-if="!readonly" class="flex items-center">
|
||||
<IconButton
|
||||
v-if="isSubgraphNode"
|
||||
size="sm"
|
||||
@@ -72,8 +69,6 @@ import {
|
||||
getNodeByLocatorId
|
||||
} from '@/utils/graphTraversalUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
|
||||
interface NodeHeaderProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
|
||||
@@ -19,10 +19,9 @@
|
||||
<div
|
||||
v-for="(widget, index) in processedWidgets"
|
||||
:key="`widget-${index}-${widget.name}`"
|
||||
class="lg-widget-container flex items-center group"
|
||||
class="lg-widget-container relative flex items-center group"
|
||||
>
|
||||
<!-- Widget Input Slot Dot -->
|
||||
|
||||
<div
|
||||
class="opacity-0 group-hover:opacity-100 transition-opacity duration-150"
|
||||
>
|
||||
@@ -62,10 +61,12 @@ import type {
|
||||
import { useErrorHandling } from '@/composables/useErrorHandling'
|
||||
import { useCanvasInteractions } from '@/renderer/core/canvas/useCanvasInteractions'
|
||||
import { useNodeTooltips } from '@/renderer/extensions/vueNodes/composables/useNodeTooltips'
|
||||
import { LODLevel } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
// Import widget components directly
|
||||
import WidgetInputText from '@/renderer/extensions/vueNodes/widgets/components/WidgetInputText.vue'
|
||||
import {
|
||||
getComponent,
|
||||
isEssential,
|
||||
shouldRenderAsVue
|
||||
} from '@/renderer/extensions/vueNodes/widgets/registry/widgetRegistry'
|
||||
import type { SimplifiedWidget, WidgetValue } from '@/types/simplifiedWidget'
|
||||
@@ -76,9 +77,10 @@ import InputSlot from './InputSlot.vue'
|
||||
interface NodeWidgetsProps {
|
||||
nodeData?: VueNodeData
|
||||
readonly?: boolean
|
||||
lodLevel?: LODLevel
|
||||
}
|
||||
|
||||
const { nodeData, readonly } = defineProps<NodeWidgetsProps>()
|
||||
const { nodeData, readonly, lodLevel } = defineProps<NodeWidgetsProps>()
|
||||
|
||||
const { shouldHandleNodePointerEvents, forwardEventToCanvas } =
|
||||
useCanvasInteractions()
|
||||
@@ -129,6 +131,17 @@ const processedWidgets = computed((): ProcessedWidget[] => {
|
||||
if (!widget.type) continue
|
||||
if (!shouldRenderAsVue(widget)) continue
|
||||
|
||||
const bypassLOD = widget.options?.bypassLOD === true
|
||||
|
||||
if (!bypassLOD) {
|
||||
if (
|
||||
lodLevel === LODLevel.MINIMAL ||
|
||||
(lodLevel === LODLevel.REDUCED && !isEssential(widget.type))
|
||||
) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
const vueComponent = getComponent(widget.type) || WidgetInputText
|
||||
|
||||
const simplified: SimplifiedWidget = {
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
<template>
|
||||
<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 class="relative">
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
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>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<!-- Slot Name -->
|
||||
<span
|
||||
v-if="!dotOnly"
|
||||
class="whitespace-nowrap text-sm font-normal dark-theme:text-slate-200 text-stone-200"
|
||||
>
|
||||
{{ slotData.name || `Output ${index}` }}
|
||||
</span>
|
||||
|
||||
<!-- Connection Dot -->
|
||||
<SlotConnectionDot
|
||||
ref="connectionDotRef"
|
||||
@@ -40,7 +38,6 @@ import { useSlotElementTracking } from '@/renderer/extensions/vueNodes/composabl
|
||||
import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composables/useSlotLinkInteraction'
|
||||
import { cn } from '@/utils/tailwindUtil'
|
||||
|
||||
import LODFallback from './LODFallback.vue'
|
||||
import SlotConnectionDot from './SlotConnectionDot.vue'
|
||||
|
||||
interface OutputSlotProps {
|
||||
|
||||
@@ -24,7 +24,6 @@ defineExpose({
|
||||
>
|
||||
<div
|
||||
ref="slot-el"
|
||||
class="slot-dot"
|
||||
:style="{ backgroundColor: color }"
|
||||
:class="
|
||||
cn(
|
||||
|
||||
@@ -1,141 +1,295 @@
|
||||
# ComfyUI Widget LOD System: Architecture and Implementation
|
||||
# Level of Detail (LOD) Implementation Guide for Widgets
|
||||
|
||||
## Executive Summary
|
||||
## What is Level of Detail (LOD)?
|
||||
|
||||
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.
|
||||
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 Two Approaches: Reactive vs. Static LOD
|
||||
For ComfyUI nodes, this means:
|
||||
- **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
|
||||
|
||||
### Approach 1: Reactive LOD (Original Design)
|
||||
## Why LOD Matters
|
||||
|
||||
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.
|
||||
Without LOD optimization:
|
||||
- 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
|
||||
|
||||
**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.
|
||||
With LOD optimization:
|
||||
- Smooth performance even with large node graphs
|
||||
- Battery life improvement on laptops
|
||||
- Better user experience across different zoom levels
|
||||
|
||||
### Approach 2: Static LOD with CSS (Current Implementation)
|
||||
## How to Implement LOD in Your Widget
|
||||
|
||||
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.
|
||||
### Step 1: Get the LOD Context
|
||||
|
||||
**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.
|
||||
Every widget component gets a `zoomLevel` prop. Use this to determine how much detail to show:
|
||||
|
||||
## The GPU Texture Bottleneck
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useLOD } from '@/composables/graph/useLOD'
|
||||
|
||||
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
|
||||
const props = defineProps<{
|
||||
widget: any
|
||||
zoomLevel: number
|
||||
// ... other props
|
||||
}>()
|
||||
|
||||
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:
|
||||
// Get LOD information
|
||||
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
|
||||
</script>
|
||||
```
|
||||
|
||||
### Traditional Assumption
|
||||
**Primary API:** Use `lodScore` (0-1) for granular control and smooth transitions
|
||||
**Convenience API:** Use `lodLevel` ('minimal'|'reduced'|'full') for simple on/off decisions
|
||||
|
||||
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
|
||||
### Step 2: Choose What to Show at Different Zoom Levels
|
||||
|
||||
### Actual Browser Behavior
|
||||
#### Understanding the LOD Score
|
||||
- `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)
|
||||
|
||||
When all nodes are children of a single transformed parent:
|
||||
#### Understanding LOD Levels
|
||||
- `'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)
|
||||
|
||||
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
|
||||
### Step 3: Implement Your Widget's LOD Strategy
|
||||
|
||||
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.
|
||||
Here's a complete example of a slider widget with LOD:
|
||||
|
||||
## Two Distinct Performance Concerns
|
||||
```vue
|
||||
<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>
|
||||
|
||||
The analysis reveals two often-conflated performance considerations that should be understood separately:
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue'
|
||||
import { useLOD } from '@/composables/graph/useLOD'
|
||||
|
||||
### 1. Rendering Performance
|
||||
const props = defineProps<{
|
||||
widget: any
|
||||
zoomLevel: number
|
||||
}>()
|
||||
|
||||
**Question:** How fast can the browser paint and composite the node graph during interactions?
|
||||
const { lodScore, lodLevel } = useLOD(toRef(() => props.zoomLevel))
|
||||
|
||||
**Traditional thinking:** Show less content → render faster
|
||||
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
|
||||
// Define when to show each element
|
||||
const showLabel = computed(() => {
|
||||
// Show label when user can actually read it
|
||||
return lodScore.value > 0.4 // Roughly 12px+ text size
|
||||
})
|
||||
|
||||
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.
|
||||
const showValue = computed(() => {
|
||||
// Show precise value only when zoomed in close
|
||||
return lodScore.value > 0.7 // User is focused on this specific widget
|
||||
})
|
||||
|
||||
### 2. Memory and Lifecycle Management
|
||||
const showDescription = computed(() => {
|
||||
// Description only at full detail
|
||||
return lodLevel.value === 'full' // Maximum zoom level
|
||||
})
|
||||
|
||||
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
|
||||
// You can also use LOD for styling
|
||||
const widgetClasses = computed(() => {
|
||||
const classes = ['number-widget']
|
||||
|
||||
if (lodLevel.value === 'minimal') {
|
||||
classes.push('widget--minimal')
|
||||
}
|
||||
|
||||
return classes
|
||||
})
|
||||
</script>
|
||||
|
||||
This is where unmounting widgets might theoretically help:
|
||||
<style scoped>
|
||||
/* Apply different styles based on LOD */
|
||||
.widget--minimal {
|
||||
/* Simplified appearance when zoomed out */
|
||||
.widget-slider {
|
||||
height: 4px; /* Thinner slider */
|
||||
opacity: 0.9;
|
||||
}
|
||||
}
|
||||
|
||||
- Complex widgets (3D viewers, chart renderers) might hold significant memory
|
||||
- Event listeners and reactive watchers consume resources
|
||||
- Some widgets might run background processes or animations
|
||||
/* Normal styling */
|
||||
.widget-slider {
|
||||
height: 8px;
|
||||
transition: height 0.2s ease;
|
||||
}
|
||||
|
||||
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.
|
||||
.widget-label {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
## Design Philosophy and Trade-offs
|
||||
.widget-value {
|
||||
font-family: monospace;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text-accent);
|
||||
}
|
||||
|
||||
The current CSS-based approach makes several deliberate trade-offs:
|
||||
.widget-description {
|
||||
font-size: 0.6rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### What We Optimize For
|
||||
## Common LOD Patterns
|
||||
|
||||
1. **Consistent, predictable performance** - No reactivity means no sudden performance cliffs
|
||||
2. **Smooth zoom/pan interactions** - CSS transforms are hardware-accelerated
|
||||
3. **Simple widget development** - Widget authors don't need to implement LOD logic
|
||||
4. **Reliable state preservation** - Widgets never lose state from unmounting
|
||||
### Pattern 1: Essential vs. Nice-to-Have
|
||||
```typescript
|
||||
// Always show the main functionality
|
||||
const showMainControl = computed(() => true)
|
||||
|
||||
### What We Accept
|
||||
// Granular control with lodScore
|
||||
const showLabels = computed(() => lodScore.value > 0.4)
|
||||
const labelOpacity = computed(() => Math.max(0.3, lodScore.value))
|
||||
|
||||
1. **Higher baseline memory usage** - All widgets remain mounted
|
||||
2. **Less granular control** - Widgets can't optimize their own LOD behavior
|
||||
3. **Potential waste for exotic widgets** - A 3D renderer widget still runs when hidden
|
||||
// Simple control with lodLevel
|
||||
const showExtras = computed(() => lodLevel.value === 'full')
|
||||
```
|
||||
|
||||
## Open Questions and Future Considerations
|
||||
### Pattern 2: Smooth Opacity Transitions
|
||||
```typescript
|
||||
// Gradually fade elements based on zoom
|
||||
const labelOpacity = computed(() => {
|
||||
// Fade in from zoom 0.3 to 0.6
|
||||
return Math.max(0, Math.min(1, (lodScore.value - 0.3) / 0.3))
|
||||
})
|
||||
```
|
||||
|
||||
### Should widgets have any LOD control?
|
||||
### Pattern 3: Progressive Detail
|
||||
```typescript
|
||||
const detailLevel = computed(() => {
|
||||
if (lodScore.value < 0.3) return 'none'
|
||||
if (lodScore.value < 0.6) return 'basic'
|
||||
if (lodScore.value < 0.8) return 'standard'
|
||||
return 'full'
|
||||
})
|
||||
```
|
||||
|
||||
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
|
||||
## LOD Guidelines by Widget Type
|
||||
|
||||
**Scenario:** A widget renders a complex 3D scene or runs expensive computations
|
||||
**Current behavior:** Hidden via CSS but still mounted
|
||||
**Question:** Should such widgets be able to opt into unmounting at distance?
|
||||
### Text Input Widgets
|
||||
- **Always show**: The input field itself
|
||||
- **Medium zoom**: Show label
|
||||
- **High zoom**: Show placeholder text, validation messages
|
||||
- **Full zoom**: Show character count, format hints
|
||||
|
||||
The challenge is that introducing selective unmounting would require:
|
||||
### Button Widgets
|
||||
- **Always show**: The button
|
||||
- **Medium zoom**: Show button text
|
||||
- **High zoom**: Show button description
|
||||
- **Full zoom**: Show keyboard shortcuts, tooltips
|
||||
|
||||
- Maintaining widget state across mount/unmount cycles
|
||||
- Accepting the performance cost of remounting when zooming in
|
||||
- Adding complexity to the widget API
|
||||
### Selection Widgets (Dropdown, Radio)
|
||||
- **Always show**: The current selection
|
||||
- **Medium zoom**: Show option labels
|
||||
- **High zoom**: Show all options when expanded
|
||||
- **Full zoom**: Show option descriptions, icons
|
||||
|
||||
### Could we reduce GPU texture size?
|
||||
### Complex Widgets (Color Picker, File Browser)
|
||||
- **Always show**: Simplified representation (color swatch, filename)
|
||||
- **Medium zoom**: Show basic controls
|
||||
- **High zoom**: Show full interface
|
||||
- **Full zoom**: Show advanced options, previews
|
||||
|
||||
Since texture dimensions are the limiting factor, could we:
|
||||
## Design Collaboration Guidelines
|
||||
|
||||
- Use multiple compositor layers for different regions (chunk the transformpane)?
|
||||
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
|
||||
### For Designers
|
||||
When designing widgets, consider creating variants for different zoom levels:
|
||||
|
||||
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
|
||||
1. **Minimal Design** (far away view)
|
||||
- Essential elements only
|
||||
- Higher contrast for visibility
|
||||
- Simplified shapes and fewer details
|
||||
|
||||
### Is there a hybrid approach?
|
||||
2. **Standard Design** (normal view)
|
||||
- Balanced detail and simplicity
|
||||
- Clear labels and readable text
|
||||
- Good for most use cases
|
||||
|
||||
Could we identify specific threshold scenarios where reactive LOD makes sense?
|
||||
3. **Full Detail Design** (close-up view)
|
||||
- All labels, descriptions, and help text
|
||||
- Rich visual effects and polish
|
||||
- Maximum information density
|
||||
|
||||
- When node count is low (< 50 nodes)
|
||||
- For specifically registered "expensive" widgets
|
||||
- At extreme zoom levels only
|
||||
### Design Handoff Checklist
|
||||
- [ ] Specify which elements are essential vs. nice-to-have
|
||||
- [ ] Define minimum readable sizes for text elements
|
||||
- [ ] Provide simplified versions for distant viewing
|
||||
- [ ] Consider color contrast at different opacity levels
|
||||
- [ ] Test designs at multiple zoom levels
|
||||
|
||||
## Implementation Guidelines
|
||||
## Testing Your LOD Implementation
|
||||
|
||||
Given the current architecture, here's how to work within the system:
|
||||
### Manual Testing
|
||||
1. Create a workflow with your widget
|
||||
2. Zoom out until nodes are very small
|
||||
3. Verify essential functionality still works
|
||||
4. Zoom in gradually and check that details appear smoothly
|
||||
5. Test performance with 50+ nodes containing your widget
|
||||
|
||||
### For Widget Developers
|
||||
### Performance Considerations
|
||||
- Avoid complex calculations in LOD computed properties
|
||||
- Use `v-if` instead of `v-show` for elements that won't render
|
||||
- Consider using `v-memo` for expensive widget content
|
||||
- Test on lower-end devices
|
||||
|
||||
1. **Build widgets assuming they're always visible** - Don't rely on mount/unmount for cleanup
|
||||
2. **Use CSS classes for zoom-responsive styling** - Let CSS handle visual changes
|
||||
3. **Minimize background processing** - Assume your widget is always running
|
||||
4. **Consider requestAnimationFrame throttling** - For animations that won't be visible when zoomed out
|
||||
### Common Mistakes
|
||||
❌ **Don't**: Hide the main widget functionality at any zoom level
|
||||
❌ **Don't**: Use complex animations that trigger at every zoom change
|
||||
❌ **Don't**: Make LOD thresholds too sensitive (causes flickering)
|
||||
❌ **Don't**: Forget to test with real content and edge cases
|
||||
|
||||
### For System Architects
|
||||
✅ **Do**: Keep essential functionality always visible
|
||||
✅ **Do**: Use smooth transitions between LOD levels
|
||||
✅ **Do**: Test with varying content lengths and types
|
||||
✅ **Do**: Consider accessibility at all zoom levels
|
||||
|
||||
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
|
||||
## Getting Help
|
||||
|
||||
## Conclusion
|
||||
|
||||
The ComfyUI LOD system represents a pragmatic choice: accepting higher memory usage and less granular control in exchange for predictable performance and implementation simplicity. By understanding that GPU texture dimensions—not rasterization complexity—drive performance in a CSS-transform-based architecture, the team has chosen an approach that may seem counterintuitive but actually aligns with browser rendering realities.
|
||||
|
||||
The system works well for the common case of hundreds of relatively simple widgets. Edge cases involving genuinely expensive widgets may need future consideration, but the current approach provides a solid foundation that avoids the performance pitfalls of reactive LOD at scale.
|
||||
|
||||
The key insight—that showing less doesn't necessarily mean rendering faster when everything lives in a single GPU texture—challenges conventional web performance wisdom and demonstrates the importance of understanding the full rendering pipeline when making architectural decisions.
|
||||
- Check existing widgets in `src/components/graph/vueNodes/widgets/` for examples
|
||||
- Ask in the ComfyUI frontend Discord for LOD implementation questions
|
||||
- Test your changes with the LOD debug panel (top-right in GraphCanvas)
|
||||
- Profile performance impact using browser dev tools
|
||||
@@ -82,6 +82,7 @@ function useNodeEventHandlersIndividual() {
|
||||
const currentCollapsed = node.flags?.collapsed ?? false
|
||||
if (currentCollapsed !== collapsed) {
|
||||
node.collapse()
|
||||
nodeManager.value.scheduleUpdate(nodeId, 'critical')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,5 @@
|
||||
import { storeToRefs } from 'pinia'
|
||||
import {
|
||||
type CSSProperties,
|
||||
type MaybeRefOrGetter,
|
||||
computed,
|
||||
inject,
|
||||
toValue
|
||||
} from 'vue'
|
||||
import { type MaybeRefOrGetter, computed, inject, toValue } from 'vue'
|
||||
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import { TransformStateKey } from '@/renderer/core/layout/injectionKeys'
|
||||
@@ -188,16 +182,14 @@ export function useNodeLayout(nodeIdMaybe: MaybeRefOrGetter<string>) {
|
||||
endDrag,
|
||||
|
||||
// Computed styles for Vue templates
|
||||
nodeStyle: computed(
|
||||
(): CSSProperties => ({
|
||||
position: 'absolute' as const,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
width: `${size.value.width}px`,
|
||||
height: `${size.value.height}px`,
|
||||
zIndex: zIndex.value,
|
||||
cursor: isDragging ? 'grabbing' : 'grab'
|
||||
})
|
||||
)
|
||||
nodeStyle: computed(() => ({
|
||||
position: 'absolute' as const,
|
||||
left: `${position.value.x}px`,
|
||||
top: `${position.value.y}px`,
|
||||
width: `${size.value.width}px`,
|
||||
height: `${size.value.height}px`,
|
||||
zIndex: zIndex.value,
|
||||
cursor: isDragging ? 'grabbing' : 'grab'
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,33 +2,186 @@
|
||||
* Level of Detail (LOD) composable for Vue-based node rendering
|
||||
*
|
||||
* Provides dynamic quality adjustment based on zoom level to maintain
|
||||
* performance with large node graphs. Uses zoom threshold based on DPR
|
||||
* to determine how much detail to render for each node component.
|
||||
* Default minFontSize = 8px
|
||||
* Default zoomThreshold = 0.57 (On a DPR = 1 monitor)
|
||||
**/
|
||||
import { useDevicePixelRatio } from '@vueuse/core'
|
||||
import { computed } from 'vue'
|
||||
* performance with large node graphs. Uses zoom thresholds to determine
|
||||
* how much detail to render for each node component.
|
||||
*
|
||||
* ## LOD Levels
|
||||
*
|
||||
* - **FULL** (zoom > 0.8): Complete rendering with all widgets, slots, and content
|
||||
* - **REDUCED** (0.4 < zoom <= 0.8): Essential widgets only, simplified slots
|
||||
* - **MINIMAL** (zoom <= 0.4): Title only, no widgets or slots
|
||||
*
|
||||
* ## Performance Benefits
|
||||
*
|
||||
* - Reduces DOM element count by up to 80% at low zoom levels
|
||||
* - Minimizes layout calculations and paint operations
|
||||
* - Enables smooth performance with 1000+ nodes
|
||||
* - Maintains visual fidelity when detail is actually visible
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const { lodLevel, shouldRenderWidgets, shouldRenderSlots } = useLOD(zoomRef)
|
||||
*
|
||||
* // In template
|
||||
* <NodeWidgets v-if="shouldRenderWidgets" />
|
||||
* <NodeSlots v-if="shouldRenderSlots" />
|
||||
* ```
|
||||
*/
|
||||
import { type MaybeRefOrGetter, computed, readonly, toRef } from 'vue'
|
||||
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
|
||||
interface Camera {
|
||||
z: number // zoom level
|
||||
export enum LODLevel {
|
||||
MINIMAL = 'minimal', // zoom <= 0.4
|
||||
REDUCED = 'reduced', // 0.4 < zoom <= 0.8
|
||||
FULL = 'full' // zoom > 0.8
|
||||
}
|
||||
|
||||
export function useLOD(camera: Camera) {
|
||||
const isLOD = computed(() => {
|
||||
const { pixelRatio } = useDevicePixelRatio()
|
||||
const baseFontSize = 14
|
||||
const dprAdjustment = Math.sqrt(pixelRatio.value)
|
||||
interface LODConfig {
|
||||
renderWidgets: boolean
|
||||
renderSlots: boolean
|
||||
renderContent: boolean
|
||||
renderSlotLabels: boolean
|
||||
renderWidgetLabels: boolean
|
||||
cssClass: string
|
||||
}
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const minFontSize = settingStore.get('LiteGraph.Canvas.MinFontSizeForLOD') //default 8
|
||||
const threshold =
|
||||
Math.round((minFontSize / (baseFontSize * dprAdjustment)) * 100) / 100 //round to 2 decimal places i.e 0.86
|
||||
// LOD configuration for each level
|
||||
const LOD_CONFIGS: Record<LODLevel, LODConfig> = {
|
||||
[LODLevel.FULL]: {
|
||||
renderWidgets: true,
|
||||
renderSlots: true,
|
||||
renderContent: true,
|
||||
renderSlotLabels: true,
|
||||
renderWidgetLabels: true,
|
||||
cssClass: 'lg-node--lod-full'
|
||||
},
|
||||
[LODLevel.REDUCED]: {
|
||||
renderWidgets: true,
|
||||
renderSlots: true,
|
||||
renderContent: false,
|
||||
renderSlotLabels: false,
|
||||
renderWidgetLabels: false,
|
||||
cssClass: 'lg-node--lod-reduced'
|
||||
},
|
||||
[LODLevel.MINIMAL]: {
|
||||
renderWidgets: false,
|
||||
renderSlots: false,
|
||||
renderContent: false,
|
||||
renderSlotLabels: false,
|
||||
renderWidgetLabels: false,
|
||||
cssClass: 'lg-node--lod-minimal'
|
||||
}
|
||||
}
|
||||
|
||||
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))
|
||||
})
|
||||
|
||||
return { isLOD }
|
||||
// Determine current LOD level based on zoom
|
||||
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
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
export const useNodePreviewState = (
|
||||
nodeIdMaybe: MaybeRefOrGetter<string>,
|
||||
options?: {
|
||||
isMinimalLOD?: Ref<boolean>
|
||||
isCollapsed?: Ref<boolean>
|
||||
}
|
||||
) => {
|
||||
@@ -31,10 +32,14 @@ export const useNodePreviewState = (
|
||||
})
|
||||
|
||||
const shouldShowPreviewImg = computed(() => {
|
||||
if (!options?.isCollapsed) {
|
||||
if (!options?.isMinimalLOD || !options?.isCollapsed) {
|
||||
return hasPreview.value
|
||||
}
|
||||
return !options.isCollapsed.value && hasPreview.value
|
||||
return (
|
||||
!options.isMinimalLOD.value &&
|
||||
!options.isCollapsed.value &&
|
||||
hasPreview.value
|
||||
)
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<!-- Display mode: Rendered markdown -->
|
||||
<div
|
||||
v-if="!isEditing"
|
||||
class="comfy-markdown-content text-xs min-h-[60px] rounded-lg px-4 py-2 overflow-y-auto lod-toggle"
|
||||
class="comfy-markdown-content text-xs min-h-[60px] rounded-lg px-4 py-2 overflow-y-auto"
|
||||
v-html="renderedHtml"
|
||||
/>
|
||||
|
||||
@@ -28,7 +28,6 @@
|
||||
@click.stop
|
||||
@keydown.stop
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -40,8 +39,6 @@ import { useStringWidgetValue } from '@/composables/graph/useWidgetValue'
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
|
||||
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
widget: SimplifiedWidget<string>
|
||||
modelValue: string
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Textarea
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs lod-toggle')"
|
||||
:placeholder="placeholder || widget.name || ''"
|
||||
size="small"
|
||||
rows="3"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<Textarea
|
||||
v-model="localValue"
|
||||
v-bind="filteredProps"
|
||||
:disabled="readonly"
|
||||
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"
|
||||
:placeholder="placeholder || widget.name || ''"
|
||||
size="small"
|
||||
rows="3"
|
||||
@update:model-value="onChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
@@ -26,7 +23,6 @@ import {
|
||||
filterWidgetProps
|
||||
} from '@/utils/widgetPropFilter'
|
||||
|
||||
import LODFallback from '../../components/LODFallback.vue'
|
||||
import { WidgetInputBaseClass } from './layout'
|
||||
|
||||
const props = defineProps<{
|
||||
|
||||
@@ -3,8 +3,6 @@ import { noop } from 'es-toolkit'
|
||||
|
||||
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
|
||||
|
||||
import LODFallback from '../../../components/LODFallback.vue'
|
||||
|
||||
defineProps<{
|
||||
widget: Pick<SimplifiedWidget<string | number | undefined>, 'name'>
|
||||
}>()
|
||||
@@ -14,25 +12,19 @@ defineProps<{
|
||||
<div
|
||||
class="flex items-center justify-between gap-2 h-[30px] overscroll-contain"
|
||||
>
|
||||
<div class="relative h-6 flex items-center mr-4">
|
||||
<p
|
||||
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>
|
||||
<LODFallback />
|
||||
</div>
|
||||
<div class="relative">
|
||||
<div
|
||||
class="w-75 cursor-default lod-toggle"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<LODFallback />
|
||||
<p
|
||||
v-if="widget.name"
|
||||
class="text-sm text-stone-200 dark-theme:text-slate-200 font-normal flex-1 truncate w-20"
|
||||
>
|
||||
{{ widget.name }}
|
||||
</p>
|
||||
<div
|
||||
class="w-75 cursor-default"
|
||||
@pointerdown.stop="noop"
|
||||
@pointermove.stop="noop"
|
||||
@pointerup.stop="noop"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -6,12 +6,10 @@ import {
|
||||
GoogleAuthProvider,
|
||||
type User,
|
||||
type UserCredential,
|
||||
browserLocalPersistence,
|
||||
createUserWithEmailAndPassword,
|
||||
deleteUser,
|
||||
onAuthStateChanged,
|
||||
sendPasswordResetEmail,
|
||||
setPersistence,
|
||||
signInWithEmailAndPassword,
|
||||
signInWithPopup,
|
||||
signOut,
|
||||
@@ -82,8 +80,6 @@ export const useFirebaseAuthStore = defineStore('firebaseAuth', () => {
|
||||
// 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()!`.
|
||||
const auth = useFirebaseAuth()!
|
||||
// Set persistence to localStorage (works in both browser and Electron)
|
||||
void setPersistence(auth, browserLocalPersistence)
|
||||
|
||||
onAuthStateChanged(auth, (user) => {
|
||||
currentUser.value = user
|
||||
|
||||
@@ -50,13 +50,23 @@ vi.mock('@/composables/useErrorHandling', () => ({
|
||||
vi.mock('@/renderer/extensions/vueNodes/layout/useNodeLayout', () => ({
|
||||
useNodeLayout: () => ({
|
||||
position: { x: 100, y: 50 },
|
||||
size: { width: 200, height: 100 },
|
||||
startDrag: vi.fn(),
|
||||
handleDrag: 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(
|
||||
'@/renderer/extensions/vueNodes/execution/useNodeExecutionState',
|
||||
() => ({
|
||||
|
||||
@@ -1,69 +1,270 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, reactive } from 'vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
const mockSettingStore = reactive({
|
||||
get: vi.fn(() => 8)
|
||||
})
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => mockSettingStore
|
||||
}))
|
||||
import {
|
||||
LODLevel,
|
||||
LOD_THRESHOLDS,
|
||||
supportsFeatureAtZoom,
|
||||
useLOD
|
||||
} from '@/renderer/extensions/vueNodes/lod/useLOD'
|
||||
|
||||
describe('useLOD', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
describe('LOD level detection', () => {
|
||||
it('should return MINIMAL for zoom <= 0.4', () => {
|
||||
const zoomRef = ref(0.4)
|
||||
const { lodLevel } = useLOD(zoomRef)
|
||||
expect(lodLevel.value).toBe(LODLevel.MINIMAL)
|
||||
|
||||
mockSettingStore.get.mockReturnValue(8)
|
||||
zoomRef.value = 0.2
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
it('should calculate isLOD value based on zoom threshold correctly', async () => {
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
describe('rendering decisions', () => {
|
||||
it('should disable all rendering for MINIMAL LOD', () => {
|
||||
const zoomRef = ref(0.2)
|
||||
const {
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
shouldRenderSlotLabels,
|
||||
shouldRenderWidgetLabels
|
||||
} = useLOD(zoomRef)
|
||||
|
||||
const camera = reactive({ z: 1 })
|
||||
const { isLOD } = useLOD(camera)
|
||||
expect(shouldRenderWidgets.value).toBe(false)
|
||||
expect(shouldRenderSlots.value).toBe(false)
|
||||
expect(shouldRenderContent.value).toBe(false)
|
||||
expect(shouldRenderSlotLabels.value).toBe(false)
|
||||
expect(shouldRenderWidgetLabels.value).toBe(false)
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(false)
|
||||
it('should enable widgets/slots but disable labels for REDUCED LOD', () => {
|
||||
const zoomRef = ref(0.6)
|
||||
const {
|
||||
shouldRenderWidgets,
|
||||
shouldRenderSlots,
|
||||
shouldRenderContent,
|
||||
shouldRenderSlotLabels,
|
||||
shouldRenderWidgetLabels
|
||||
} = useLOD(zoomRef)
|
||||
|
||||
camera.z = 0.55
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(true)
|
||||
expect(shouldRenderWidgets.value).toBe(true)
|
||||
expect(shouldRenderSlots.value).toBe(true)
|
||||
expect(shouldRenderContent.value).toBe(false)
|
||||
expect(shouldRenderSlotLabels.value).toBe(false)
|
||||
expect(shouldRenderWidgetLabels.value).toBe(false)
|
||||
})
|
||||
|
||||
camera.z = 0.87
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(false)
|
||||
it('should enable all rendering for FULL LOD', () => {
|
||||
const zoomRef = ref(1.0)
|
||||
const {
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
it('should handle a different devicePixelRatio value', async () => {
|
||||
vi.stubGlobal('devicePixelRatio', 3) //Threshold with 8px minFontsize = 0.19
|
||||
describe('CSS classes', () => {
|
||||
it('should return correct CSS class for each LOD level', () => {
|
||||
const zoomRef = ref(0.2)
|
||||
const { lodCssClass } = useLOD(zoomRef)
|
||||
|
||||
const camera = reactive({ z: 1 })
|
||||
const { isLOD } = useLOD(camera)
|
||||
expect(lodCssClass.value).toBe('lg-node--lod-minimal')
|
||||
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(false)
|
||||
zoomRef.value = 0.6
|
||||
expect(lodCssClass.value).toBe('lg-node--lod-reduced')
|
||||
|
||||
camera.z = 0.18
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(true)
|
||||
zoomRef.value = 1.0
|
||||
expect(lodCssClass.value).toBe('lg-node--lod-full')
|
||||
})
|
||||
})
|
||||
|
||||
it('should respond to different minFontSize settings', async () => {
|
||||
vi.stubGlobal('devicePixelRatio', 1)
|
||||
describe('essential widgets filtering', () => {
|
||||
it('should return all widgets for FULL LOD', () => {
|
||||
const zoomRef = ref(1.0)
|
||||
const { getEssentialWidgets } = useLOD(zoomRef)
|
||||
|
||||
mockSettingStore.get.mockReturnValue(16) //Now threshold is 1.14
|
||||
const widgets = [
|
||||
{ type: 'combo' },
|
||||
{ type: 'text' },
|
||||
{ type: 'button' },
|
||||
{ type: 'slider' }
|
||||
]
|
||||
|
||||
const camera = reactive({ z: 1 })
|
||||
const { isLOD } = useLOD(camera)
|
||||
expect(getEssentialWidgets(widgets)).toEqual(widgets)
|
||||
})
|
||||
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(true)
|
||||
it('should return empty array for MINIMAL LOD', () => {
|
||||
const zoomRef = ref(0.2)
|
||||
const { getEssentialWidgets } = useLOD(zoomRef)
|
||||
|
||||
camera.z = 1.15
|
||||
await nextTick()
|
||||
expect(isLOD.value).toBe(false)
|
||||
const widgets = [{ type: 'combo' }, { type: 'text' }, { type: 'button' }]
|
||||
|
||||
expect(getEssentialWidgets(widgets)).toEqual([])
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -150,13 +150,6 @@ describe('useFirebaseAuthStore', () => {
|
||||
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 () => {
|
||||
// First, cause an error
|
||||
const mockError = new Error('Invalid password')
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,100 +0,0 @@
|
||||
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"]
|
||||
@@ -1,673 +0,0 @@
|
||||
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",
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
This is a fake model file used for testing.
|
||||
@@ -37,5 +37,7 @@
|
||||
"src/**/*",
|
||||
"src/types/**/*.d.ts",
|
||||
"tests-ui/**/*",
|
||||
"vite.config.mts",
|
||||
"vitest.config.ts",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineConfig, mergeConfig } from 'vite'
|
||||
import type { Plugin } from 'vite'
|
||||
import { Plugin, defineConfig } from 'vite'
|
||||
import { mergeConfig } from 'vite'
|
||||
|
||||
import baseConfig from './vite.config.mts'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user