Compare commits

...

12 Commits

Author SHA1 Message Date
Comfy Org PR Bot
792554d6dd 1.33.10 (#7080)
Patch version increment to 1.33.10

**Base branch:** `core/1.33`

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7080-1-33-10-2bc6d73d365081af8d5ac2009068ee43)
by [Unito](https://www.unito.io)

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2025-12-01 14:39:26 -07:00
Comfy Org PR Bot
8df8149bd6 [backport core/1.33] feat(api-nodes-pricing): add prices for Kling O1 video model (#7078)
Backport of #7077 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7078-backport-core-1-33-feat-api-nodes-pricing-add-prices-for-Kling-O1-video-model-2bc6d73d365081429431cbe6c4645fba)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-12-01 14:18:19 -07:00
Comfy Org PR Bot
5e23ae318c [backport core/1.33] [fix] Prevent drag activation during Vue node resize (#7070)
Backport of #7064 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7070-backport-core-1-33-fix-Prevent-drag-activation-during-Vue-node-resize-2bc6d73d365081d29489d1704477bca4)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
Co-authored-by: DrJKL <DrJKL0424@gmail.com>
2025-11-30 20:44:29 -08:00
Christian Byrne
5606c977c8 [backport core/1.33] Simplify Vue node resize to bottom-right corner only (#7063) (#7067)
## Summary
- Backport of #7063 to core/1.33
- Simplifies Vue node resize to bottom-right corner only

Cherry-picked from d76c59cb14

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7067-backport-core-1-33-Simplify-Vue-node-resize-to-bottom-right-corner-only-7063-2bc6d73d36508189b25acf94c8639569)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
2025-11-30 20:20:23 -08:00
Comfy Org PR Bot
5103c8df3f [backport core/1.33] Expose LGraphNode.getSlotPosition (#7058)
Backport of #7042 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7058-backport-core-1-33-Expose-LGraphNode-getSlotPosition-2bb6d73d365081ce9a18f1d4a802276a)
by [Unito](https://www.unito.io)

Co-authored-by: niknah <niknah+github@gmail.com>
2025-11-30 16:42:50 -07:00
Comfy Org PR Bot
0da2d80708 [backport core/1.33] mark vue nodes menu toggle with beta tag (#7051)
Backport of #7047 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7051-backport-core-1-33-mark-vue-nodes-menu-toggle-with-beta-tag-2bb6d73d365081b09428d6f0bb21dc67)
by [Unito](https://www.unito.io)

Co-authored-by: Christian Byrne <cbyrne@comfy.org>
2025-11-29 19:14:17 -07:00
Comfy Org PR Bot
93b06525cc [backport core/1.33] feat(api-nodes-pricing): add prices for ByteDance seedance-1-0-pro-fast model (#7029)
Backport of #7026 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7029-backport-core-1-33-feat-api-nodes-pricing-add-prices-for-ByteDance-seedance-1-0-pro-f-2b96d73d365081e48660fa00f236fae9)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Piskun <13381981+bigcat88@users.noreply.github.com>
2025-11-29 15:09:51 -07:00
Comfy Org PR Bot
6867d84ec1 [backport core/1.33] Remove app.graph usage from widgetInput code (#7010)
Backport of #7008 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7010-backport-core-1-33-Remove-app-graph-usage-from-widgetInput-code-2b86d73d3650811f9da1c5869f352e10)
by [Unito](https://www.unito.io)

Co-authored-by: AustinMroz <austin@comfy.org>
2025-11-27 17:57:00 -07:00
Comfy Org PR Bot
d0bd4c26ca [backport core/1.33] fix: add filter for combo widgets (#7002)
Backport of #6999 to `core/1.33`

Automatically created by backport workflow.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-7002-backport-core-1-33-fix-add-filter-for-combo-widgets-2b86d73d365081d4ab3cc6d04b85bed1)
by [Unito](https://www.unito.io)

Co-authored-by: Johnpaul Chiwetelu <49923152+Myestery@users.noreply.github.com>
2025-11-27 14:20:37 -07:00
Christian Byrne
9f19c8e10e [backport core/1.33] fix: Vue Node <-> Litegraph node height offset normalization (#6979)
## Summary
Backport of #6966 onto core/1.33.

- cherry-picked 29dbfa3f
- resolved the zoomed-in ctrl+shift snapshot conflict by taking upstream
expectations

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6979-backport-core-1-33-fix-Vue-Node-Litegraph-node-height-offset-normalization-2b86d73d365081748ef2d6f47ce7b1d3)
by [Unito](https://www.unito.io)

Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 22:38:09 -07:00
Christian Byrne
36b8972442 [backport core/1.33] fix: remove LOD from vue nodes (#6984)
## Summary
Backport of #6950 onto core/1.33 (clean cherry-pick of 4b87b1fdc).

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6984-backport-core-1-33-fix-remove-LOD-from-vue-nodes-2b86d73d36508151bf1ae4a879016211)
by [Unito](https://www.unito.io)

Co-authored-by: Simula_r <18093452+simula-r@users.noreply.github.com>
Co-authored-by: github-actions <github-actions@github.com>
2025-11-26 21:40:53 -07:00
Christian Byrne
6f77d274a4 [backport core/1.33] fix: don't use registry when only checking for presence of missing nodes (#6974)
## Summary
Backport of #6965 onto core/1.33 (clean cherry-pick of 83f04490b).

## Testing
- pnpm typecheck

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6974-backport-core-1-33-fix-don-t-use-registry-when-only-checking-for-presence-of-missing-n-2b86d73d3650813dac37e224f857f296)
by [Unito](https://www.unito.io)
2025-11-26 17:50:32 -07:00
68 changed files with 563 additions and 852 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 110 KiB

After

Width:  |  Height:  |  Size: 112 KiB

View File

@@ -0,0 +1,54 @@
import {
comfyExpect as expect,
comfyPageFixture as test
} from '../../../../fixtures/ComfyPage'
test.describe('Vue Node Resizing', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.vueNodes.waitForNodes()
})
test('should resize node without position drift after selecting', async ({
comfyPage
}) => {
// Get a Vue node fixture
const node = await comfyPage.vueNodes.getFixtureByTitle('Load Checkpoint')
const initialBox = await node.boundingBox()
if (!initialBox) throw new Error('Node bounding box not found')
// Select the node first (this was causing the bug)
await node.header.click()
await comfyPage.page.waitForTimeout(100) // Brief pause after selection
// Get position after selection
const selectedBox = await node.boundingBox()
if (!selectedBox)
throw new Error('Node bounding box not found after select')
// Verify position unchanged after selection
expect(selectedBox.x).toBeCloseTo(initialBox.x, 1)
expect(selectedBox.y).toBeCloseTo(initialBox.y, 1)
// Now resize from bottom-right corner
const resizeStartX = selectedBox.x + selectedBox.width - 5
const resizeStartY = selectedBox.y + selectedBox.height - 5
await comfyPage.page.mouse.move(resizeStartX, resizeStartY)
await comfyPage.page.mouse.down()
await comfyPage.page.mouse.move(resizeStartX + 50, resizeStartY + 30)
await comfyPage.page.mouse.up()
// Get final position and size
const finalBox = await node.boundingBox()
if (!finalBox) throw new Error('Node bounding box not found after resize')
// Position should NOT have changed (the bug was position drift)
expect(finalBox.x).toBeCloseTo(initialBox.x, 1)
expect(finalBox.y).toBeCloseTo(initialBox.y, 1)
// Size should have increased
expect(finalBox.width).toBeGreaterThan(initialBox.width)
expect(finalBox.height).toBeGreaterThan(initialBox.height)
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 151 KiB

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 143 KiB

After

Width:  |  Height:  |  Size: 142 KiB

View File

@@ -1,49 +0,0 @@
import { expect } from '@playwright/test'
import { comfyPageFixture as test } from '../../../fixtures/ComfyPage'
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.UseNewMenu', 'Disabled')
})
test.describe('Vue Nodes - LOD', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.setSetting('Comfy.VueNodes.Enabled', true)
await comfyPage.setup()
await comfyPage.loadWorkflow('default')
await comfyPage.setSetting('LiteGraph.Canvas.MinFontSizeForLOD', 8)
})
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 comboboxesInNodes = vueNodesContainer.getByRole('combobox')
await expect(textboxesInNodes.first()).toBeVisible()
await expect(comboboxesInNodes.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(comboboxesInNodes.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(comboboxesInNodes.first()).toBeVisible()
})
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 120 KiB

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

View File

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

View File

@@ -1329,57 +1329,6 @@ 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;
mask-image: none;
clip-path: none;
background-image: none;
text-rendering: optimizeSpeed;
border-radius: 0;
contain: layout style;
transition: none;
}
.isLOD .lg-node-header {
border-radius: 0;
pointer-events: none;
}
.isLOD .lg-node-widgets {
pointer-events: none;
}
.lod-toggle {
visibility: visible;
}
.isLOD .lod-toggle {
visibility: hidden;
}
.lod-fallback {
display: none;
}
.isLOD .lod-fallback {
display: block;
}
.isLOD .image-preview img {
image-rendering: pixelated;
}
.isLOD .slot-dot {
border-radius: 0;
}
/* END LOD specific styles */
/* ===================== Mask Editor Styles ===================== */
/* To be migrated to Tailwind later */
#maskEditor_brush {

View File

@@ -44,17 +44,22 @@ import { useI18n } from 'vue-i18n'
import { isCloud } from '@/platform/distribution/types'
import { useTelemetry } from '@/platform/telemetry'
import { app } from '@/scripts/app'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useQueueSettingsStore } from '@/stores/queueStore'
import { useWorkspaceStore } from '@/stores/workspaceStore'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import BatchCountEdit from '../BatchCountEdit.vue'
const workspaceStore = useWorkspaceStore()
const { mode: queueMode, batchCount } = storeToRefs(useQueueSettingsStore())
const { hasMissingNodes } = useMissingNodes()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()
const queueModeMenuItemLookup = computed(() => {

View File

@@ -64,11 +64,13 @@ import {
ComfyWorkflow,
useWorkflowStore
} from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useDialogService } from '@/services/dialogService'
import { useCommandStore } from '@/stores/commandStore'
import { useNodeDefStore } from '@/stores/nodeDefStore'
import { useSubgraphNavigationStore } from '@/stores/subgraphNavigationStore'
import { appendJsonExt } from '@/utils/formatUtil'
import { useMissingNodes } from '@/workbench/extensions/manager/composables/nodePack/useMissingNodes'
import { graphHasMissingNodes } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
interface Props {
item: MenuItem
@@ -79,7 +81,10 @@ const props = withDefaults(defineProps<Props>(), {
isActive: false
})
const { hasMissingNodes } = useMissingNodes()
const nodeDefStore = useNodeDefStore()
const hasMissingNodes = computed(() =>
graphHasMissingNodes(app.graph, nodeDefStore.nodeDefsByName)
)
const { t } = useI18n()
const menu = ref<InstanceType<typeof Menu> & MenuState>()

View File

@@ -73,6 +73,7 @@
@click.stop="handleNodes2ToggleClick"
>
<span class="p-menubar-item-label text-nowrap">{{ item.label }}</span>
<Tag severity="info" class="ml-2 text-xs">{{ $t('g.beta') }}</Tag>
<ToggleSwitch
v-model="nodes2Enabled"
class="ml-4"
@@ -101,6 +102,7 @@
<script setup lang="ts">
import type { MenuItem } from 'primevue/menuitem'
import Tag from 'primevue/tag'
import TieredMenu from 'primevue/tieredmenu'
import type { TieredMenuMethods, TieredMenuState } from 'primevue/tieredmenu'
import ToggleSwitch from 'primevue/toggleswitch'

View File

@@ -3,7 +3,6 @@ import { shallowRef, watch } from 'vue'
import { useGraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import type { GraphNodeManager } from '@/composables/graph/useGraphNodeManager'
import { useRenderModeSetting } from '@/composables/settings/useRenderModeSetting'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
import { useVueNodesMigrationDismissed } from '@/composables/useVueNodesMigrationDismissed'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
@@ -11,6 +10,7 @@ import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useLayoutSync } from '@/renderer/core/layout/sync/useLayoutSync'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ensureCorrectLayoutScale } from '@/renderer/extensions/vueNodes/layout/ensureCorrectLayoutScale'
import { app as comfyApp } from '@/scripts/app'
import { useToastStore } from '@/platform/updates/common/toastStore'
@@ -26,11 +26,6 @@ function useVueNodeLifecycleIndividual() {
let hasShownMigrationToast = false
useRenderModeSetting(
{ setting: 'LiteGraph.Canvas.MinFontSizeForLOD', vue: 0, litegraph: 8 },
shouldRenderVueNodes
)
const initializeNodeManager = () => {
// Use canvas graph if available (handles subgraph contexts), fallback to app graph
const activeGraph = comfyApp.canvas?.graph
@@ -44,7 +39,10 @@ function useVueNodeLifecycleIndividual() {
const nodes = activeGraph._nodes.map((node: LGraphNode) => ({
id: node.id.toString(),
pos: [node.pos[0], node.pos[1]] as [number, number],
size: [node.size[0], node.size[1]] as [number, number]
size: [node.size[0], removeNodeTitleHeight(node.size[1])] as [
number,
number
]
}))
layoutStore.initializeFromLiteGraph(nodes)

View File

@@ -49,6 +49,21 @@ const calculateRunwayDurationPrice = (node: LGraphNode): string => {
return `$${cost}/Run`
}
const makeOmniProDurationCalculator =
(pricePerSecond: number): PricingFunction =>
(node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration'
) as IComboWidget
if (!durationWidget) return `$${pricePerSecond.toFixed(3)}/second`
const seconds = parseFloat(String(durationWidget.value))
if (!Number.isFinite(seconds)) return `$${pricePerSecond.toFixed(3)}/second`
const cost = pricePerSecond * seconds
return `$${cost.toFixed(2)}/Run`
}
const pixversePricingCalculator = (node: LGraphNode): string => {
const durationWidget = node.widgets?.find(
(w) => w.name === 'duration_seconds'
@@ -131,6 +146,11 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
'720p': [0.51, 0.56],
'1080p': [1.18, 1.22]
},
'seedance-1-0-pro-fast': {
'480p': [0.09, 0.1],
'720p': [0.21, 0.23],
'1080p': [0.47, 0.49]
},
'seedance-1-0-lite': {
'480p': [0.17, 0.18],
'720p': [0.37, 0.41],
@@ -138,11 +158,13 @@ const byteDanceVideoPricingCalculator = (node: LGraphNode): string => {
}
}
const modelKey = model.includes('seedance-1-0-pro')
? 'seedance-1-0-pro'
: model.includes('seedance-1-0-lite')
? 'seedance-1-0-lite'
: ''
const modelKey = model.includes('seedance-1-0-pro-fast')
? 'seedance-1-0-pro-fast'
: model.includes('seedance-1-0-pro')
? 'seedance-1-0-pro'
: model.includes('seedance-1-0-lite')
? 'seedance-1-0-lite'
: ''
const resKey = resolution.includes('1080')
? '1080p'
@@ -699,6 +721,21 @@ const apiNodeCosts: Record<string, { displayPrice: string | PricingFunction }> =
KlingVirtualTryOnNode: {
displayPrice: '$0.07/Run'
},
KlingOmniProTextToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.112)
},
KlingOmniProFirstLastFrameNode: {
displayPrice: makeOmniProDurationCalculator(0.112)
},
KlingOmniProImageToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.112)
},
KlingOmniProVideoToVideoNode: {
displayPrice: makeOmniProDurationCalculator(0.168)
},
KlingOmniProEditVideoNode: {
displayPrice: '$0.168/second'
},
LumaImageToVideoNode: {
displayPrice: (node: LGraphNode): string => {
// Same pricing as LumaVideoNode per CSV
@@ -1873,6 +1910,10 @@ export const useNodePricing = () => {
KlingDualCharacterVideoEffectNode: ['mode', 'model_name', 'duration'],
KlingSingleImageVideoEffectNode: ['effect_scene'],
KlingStartEndFrameNode: ['mode', 'model_name', 'duration'],
KlingOmniProTextToVideoNode: ['duration'],
KlingOmniProFirstLastFrameNode: ['duration'],
KlingOmniProImageToVideoNode: ['duration'],
KlingOmniProVideoToVideoNode: ['duration'],
MinimaxHailuoVideoNode: ['resolution', 'duration'],
OpenAIDalle3: ['size', 'quality'],
OpenAIDalle2: ['size', 'n'],

View File

@@ -1,42 +0,0 @@
import type { ComputedRef } from 'vue'
import { ref, watch } from 'vue'
import { useSettingStore } from '@/platform/settings/settingStore'
import type { Settings } from '@/schemas/apiSchema'
interface RenderModeSettingConfig<TSettingKey extends keyof Settings> {
setting: TSettingKey
vue: Settings[TSettingKey]
litegraph: Settings[TSettingKey]
}
export function useRenderModeSetting<TSettingKey extends keyof Settings>(
config: RenderModeSettingConfig<TSettingKey>,
isVueMode: ComputedRef<boolean>
) {
const settingStore = useSettingStore()
const vueValue = ref(config.vue)
const litegraphValue = ref(config.litegraph)
const lastWasVue = ref<boolean | null>(null)
const load = async (vue: boolean) => {
if (lastWasVue.value === vue) return
if (lastWasVue.value !== null) {
const currentValue = settingStore.get(config.setting)
if (lastWasVue.value) {
vueValue.value = currentValue
} else {
litegraphValue.value = currentValue
}
}
await settingStore.set(
config.setting,
vue ? vueValue.value : litegraphValue.value
)
lastWasVue.value = vue
}
watch(isVueMode, load, { immediate: true })
}

View File

@@ -7,9 +7,9 @@ import type {
INodeInputSlot,
INodeOutputSlot,
ISlotType,
LLink,
Point
LLink
} from '@/lib/litegraph/src/litegraph'
import { NodeSlot } from '@/lib/litegraph/src/node/NodeSlot'
import type { CanvasPointerEvent } from '@/lib/litegraph/src/types/events'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import type { InputSpec } from '@/schemas/nodeDefSchema'
@@ -37,15 +37,15 @@ export class PrimitiveNode extends LGraphNode {
}
override applyToGraph(extraLinks: LLink[] = []) {
if (!this.outputs[0].links?.length) return
if (!this.outputs[0].links?.length || !this.graph) return
const links = [
...this.outputs[0].links.map((l) => app.graph.links[l]),
...this.outputs[0].links.map((l) => this.graph!.links[l]),
...extraLinks
]
let v = this.widgets?.[0].value
if (v && this.properties[replacePropertyName]) {
v = applyTextReplacements(app.graph, v as string)
v = applyTextReplacements(this.graph, v as string)
}
// For each output link copy our value over the original widget value
@@ -331,13 +331,13 @@ export class PrimitiveNode extends LGraphNode {
const config1 = (output.widget?.[GET_CONFIG] as () => InputSpec)?.()
if (!config1) return
const isNumber = config1[0] === 'INT' || config1[0] === 'FLOAT'
if (!isNumber) return
if (!isNumber || !this.graph) return
for (const linkId of links) {
const link = app.graph.links[linkId]
const link = this.graph.links[linkId]
if (!link) continue // Can be null when removing a node
const theirNode = app.graph.getNodeById(link.target_id)
const theirNode = this.graph.getNodeById(link.target_id)
if (!theirNode) continue
const theirInput = theirNode.inputs[link.target_slot]
@@ -441,10 +441,7 @@ function getWidgetType(config: InputSpec) {
return { type }
}
export function setWidgetConfig(
slot: INodeInputSlot | INodeOutputSlot,
config?: InputSpec
) {
export function setWidgetConfig(slot: INodeInputSlot, config?: InputSpec) {
if (!slot.widget) return
if (config) {
slot.widget[GET_CONFIG] = () => config
@@ -452,19 +449,18 @@ export function setWidgetConfig(
delete slot.widget
}
if ('link' in slot) {
const link = app.graph.links[slot.link ?? -1]
if (link) {
const originNode = app.graph.getNodeById(link.origin_id)
if (originNode && isPrimitiveNode(originNode)) {
if (config) {
originNode.recreateWidget()
} else if (!app.configuringGraph) {
originNode.disconnectOutput(0)
originNode.onLastDisconnect()
}
}
}
if (!(slot instanceof NodeSlot)) return
const graph = slot.node.graph
if (!graph) return
const link = graph.links[slot.link ?? -1]
if (!link) return
const originNode = graph.getNodeById(link.origin_id)
if (!originNode || !isPrimitiveNode(originNode)) return
if (config) {
originNode.recreateWidget()
} else if (!app.configuringGraph) {
originNode.disconnectOutput(0)
originNode.onLastDisconnect()
}
}
@@ -555,15 +551,6 @@ app.registerExtension({
}
)
function isNodeAtPos(pos: Point) {
for (const n of app.graph.nodes) {
if (n.pos[0] === pos[0] && n.pos[1] === pos[1]) {
return true
}
}
return false
}
// Double click a widget input to automatically attach a primitive
const origOnInputDblClick = nodeType.prototype.onInputDblClick
nodeType.prototype.onInputDblClick = function (
@@ -589,18 +576,18 @@ app.registerExtension({
// Create a primitive node
const node = LiteGraph.createNode('PrimitiveNode')
if (!node) return r
const graph = app.canvas.graph
if (!node || !graph) return r
this.graph?.add(node)
graph?.add(node)
// Calculate a position that won't directly overlap another node
const pos: [number, number] = [
this.pos[0] - node.size[0] - 30,
this.pos[1]
]
while (isNodeAtPos(pos)) {
while (graph.getNodeOnPos(pos[0], pos[1], graph.nodes))
pos[1] += LiteGraph.NODE_TITLE_HEIGHT
}
node.pos = pos
node.connect(0, this, slot)

View File

@@ -7,6 +7,7 @@ import { getSlotPosition } from '@/renderer/core/canvas/litegraph/slotCalculatio
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { LayoutSource } from '@/renderer/core/layout/types'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { CanvasPointer } from './CanvasPointer'
import type { ContextMenu } from './ContextMenu'
@@ -4043,16 +4044,25 @@ 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
}
}))
const newPositions = created
.filter((item): item is LGraphNode => item instanceof LGraphNode)
.map((node) => {
const fullHeight = node.size?.[1] ?? 200
const layoutHeight = LiteGraph.vueNodesMode
? removeNodeTitleHeight(fullHeight)
: fullHeight
return {
nodeId: String(node.id),
bounds: {
x: node.pos[0],
y: node.pos[1],
width: node.size?.[0] ?? 100,
height: layoutHeight
}
}
})
if (newPositions.length) layoutStore.setSource(LayoutSource.Canvas)
layoutStore.batchUpdateNodeBounds(newPositions)
this.selectItems(created)

View File

@@ -2,7 +2,8 @@ import { LGraphNodeProperties } from '@/lib/litegraph/src/LGraphNodeProperties'
import {
calculateInputSlotPos,
calculateInputSlotPosFromSlot,
calculateOutputSlotPos
calculateOutputSlotPos,
getSlotPosition
} from '@/renderer/core/canvas/litegraph/slotCalculations'
import type { SlotPositionContext } from '@/renderer/core/canvas/litegraph/slotCalculations'
import { useLayoutMutations } from '@/renderer/core/layout/operations/layoutMutations'
@@ -3354,6 +3355,16 @@ export class LGraphNode
)
}
/**
* Get slot position using layout tree if available, fallback to node's position * Unified implementation used by both LitegraphLinkAdapter and useLinkLayoutSync
* @param slotIndex The slot index
* @param isInput Whether this is an input slot
* @returns Position of the slot center in graph coordinates
*/
getSlotPosition(slotIndex: number, isInput: boolean): Point {
return getSlotPosition(this, slotIndex, isInput)
}
/** @inheritdoc */
snapToGrid(snapTo: number): boolean {
return this.pinned ? false : snapPoint(this.pos, snapTo)

View File

@@ -1,5 +1,6 @@
{
"g": {
"beta": "Beta",
"user": "User",
"currentUser": "Current user",
"empty": "Empty",

View File

@@ -8,9 +8,11 @@ import {
} from '@/platform/settings/settingStore'
import type { ISettingGroup, SettingParams } from '@/platform/settings/types'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
export function useSettingSearch() {
const settingStore = useSettingStore()
const { shouldRenderVueNodes } = useVueFeatureFlags()
const searchQuery = ref<string>('')
const filteredSettingIds = ref<string[]>([])
@@ -54,7 +56,11 @@ export function useSettingSearch() {
const allSettings = Object.values(settingStore.settingsById)
const filteredSettings = allSettings.filter((setting) => {
// Filter out hidden and deprecated settings, just like in normal settings tree
if (setting.type === 'hidden' || setting.deprecated) {
if (
setting.type === 'hidden' ||
setting.deprecated ||
(shouldRenderVueNodes.value && setting.hideInVueNodes)
) {
return false
}

View File

@@ -10,6 +10,7 @@ import type { SettingParams } from '@/platform/settings/types'
import { isElectron } from '@/utils/envUtil'
import { normalizeI18nKey } from '@/utils/formatUtil'
import { buildTree } from '@/utils/treeUtil'
import { useVueFeatureFlags } from '@/composables/useVueFeatureFlags'
interface SettingPanelItem {
node: SettingTreeNode
@@ -31,10 +32,14 @@ export function useSettingUI(
const settingStore = useSettingStore()
const activeCategory = ref<SettingTreeNode | null>(null)
const { shouldRenderVueNodes } = useVueFeatureFlags()
const settingRoot = computed<SettingTreeNode>(() => {
const root = buildTree(
Object.values(settingStore.settingsById).filter(
(setting: SettingParams) => setting.type !== 'hidden'
(setting: SettingParams) =>
setting.type !== 'hidden' &&
!(shouldRenderVueNodes.value && setting.hideInVueNodes)
),
(setting: SettingParams) => setting.category || setting.id.split('.')
)

View File

@@ -919,7 +919,8 @@ export const CORE_SETTINGS: SettingParams[] = [
step: 1
},
defaultValue: 8,
versionAdded: '1.26.7'
versionAdded: '1.26.7',
hideInVueNodes: true
},
{
id: 'Comfy.Canvas.SelectionToolbox',

View File

@@ -47,6 +47,7 @@ export interface SettingParams<TValue = unknown> extends FormItem {
// sortOrder for sorting settings within a group. Higher values appear first.
// Default is 0 if not specified.
sortOrder?: number
hideInVueNodes?: boolean
}
/**

View File

@@ -29,12 +29,6 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
}
})
vi.mock('@/renderer/extensions/vueNodes/lod/useLOD', () => ({
useLOD: vi.fn(() => ({
isLOD: false
}))
}))
function createMockCanvas(): LGraphCanvas {
return {
canvas: {

View File

@@ -9,6 +9,8 @@ import { computed, customRef, ref } from 'vue'
import type { ComputedRef, Ref } from 'vue'
import * as Y from 'yjs'
import { removeNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
import { ACTOR_CONFIG } from '@/renderer/core/layout/constants'
import { LayoutSource } from '@/renderer/core/layout/types'
import type {
@@ -136,6 +138,8 @@ class LayoutStoreImpl implements LayoutStore {
// Vue dragging state for selection toolbox (public ref for direct mutation)
public isDraggingVueNodes = ref(false)
// Vue resizing state to prevent drag from activating during resize
public isResizingVueNodes = ref(false)
constructor() {
// Initialize Yjs data structures
@@ -1414,8 +1418,8 @@ class LayoutStoreImpl implements LayoutStore {
batchUpdateNodeBounds(updates: NodeBoundsUpdate[]): void {
if (updates.length === 0) return
// Set source to Vue for these DOM-driven updates
const originalSource = this.currentSource
const shouldNormalizeHeights = originalSource === LayoutSource.DOM
this.currentSource = LayoutSource.Vue
const nodeIds: NodeId[] = []
@@ -1426,8 +1430,15 @@ class LayoutStoreImpl implements LayoutStore {
if (!ynode) continue
const currentLayout = yNodeToLayout(ynode)
const normalizedBounds = shouldNormalizeHeights
? {
...bounds,
height: removeNodeTitleHeight(bounds.height)
}
: bounds
boundsRecord[nodeId] = {
bounds,
bounds: normalizedBounds,
previousBounds: currentLayout.bounds
}
nodeIds.push(nodeId)

View File

@@ -8,6 +8,7 @@ import { onUnmounted, ref } from 'vue'
import type { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { addNodeTitleHeight } from '@/renderer/core/layout/utils/nodeSizeUtil'
/**
* Composable for syncing LiteGraph with the Layout system
@@ -43,12 +44,13 @@ export function useLayoutSync() {
liteNode.pos[1] = layout.position.y
}
const targetHeight = addNodeTitleHeight(layout.size.height)
if (
liteNode.size[0] !== layout.size.width ||
liteNode.size[1] !== layout.size.height
liteNode.size[1] !== targetHeight
) {
// Use setSize() to trigger onResize callback
liteNode.setSize([layout.size.width, layout.size.height])
liteNode.setSize([layout.size.width, targetHeight])
}
}

View File

@@ -4,8 +4,7 @@
:class="
cn(
'absolute inset-0 w-full h-full pointer-events-none',
isInteracting ? 'transform-pane--interacting' : 'will-change-auto',
isLOD && 'isLOD'
isInteracting ? 'transform-pane--interacting' : 'will-change-auto'
)
"
:style="transformStyle"
@@ -22,7 +21,6 @@ import { computed } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
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 {
@@ -31,9 +29,7 @@ interface TransformPaneProps {
const props = defineProps<TransformPaneProps>()
const { camera, transformStyle, syncWithCanvas } = useTransformState()
const { isLOD } = useLOD(camera)
const { transformStyle, syncWithCanvas } = useTransformState()
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {

View File

@@ -10,6 +10,7 @@ import type { ComputedRef, Ref } from 'vue'
export enum LayoutSource {
Canvas = 'canvas',
Vue = 'vue',
DOM = 'dom',
External = 'external'
}

View File

@@ -0,0 +1,7 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
export const removeNodeTitleHeight = (height: number) =>
Math.max(0, height - (LiteGraph.NODE_TITLE_HEIGHT || 0))
export const addNodeTitleHeight = (height: number) =>
height + LiteGraph.NODE_TITLE_HEIGHT

View File

@@ -83,20 +83,17 @@
</div>
</div>
<div class="relative">
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
<!-- Video Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="videoError" class="text-red-400">
{{ $t('g.errorLoadingVideo') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
</div>
</template>
@@ -110,8 +107,6 @@ import { useI18n } from 'vue-i18n'
import { downloadFile } from '@/base/common/downloadUtil'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
import LODFallback from './components/LODFallback.vue'
interface VideoPreviewProps {
/** Array of video URLs to display */
readonly imageUrls: readonly string[] // Named imageUrls for consistency with parent components

View File

@@ -93,20 +93,17 @@
</div>
</div>
<div class="relative">
<!-- Image Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
<LODFallback />
<!-- Image Dimensions -->
<div class="mt-2 text-center text-xs text-white">
<span v-if="imageError" class="text-red-400">
{{ $t('g.errorLoadingImage') }}
</span>
<span v-else-if="isLoading" class="text-smoke-400">
{{ $t('g.loading') }}...
</span>
<span v-else>
{{ actualDimensions || $t('g.calculatingDimensions') }}
</span>
</div>
</div>
</template>
@@ -122,8 +119,6 @@ import { app } from '@/scripts/app'
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[]

View File

@@ -10,14 +10,13 @@
/>
<!-- Slot Name -->
<div class="relative h-full flex items-center min-w-0">
<div class="h-full flex items-center min-w-0">
<span
v-if="!dotOnly"
:class="cn('truncate text-xs font-normal lod-toggle', labelClasses)"
:class="cn('truncate text-xs font-normal', labelClasses)"
>
{{ slotData.localized_name || slotData.name || `Input ${index}` }}
</span>
<LODFallback />
</div>
</div>
</template>
@@ -37,7 +36,6 @@ import { useSlotLinkInteraction } from '@/renderer/extensions/vueNodes/composabl
import { useExecutionStore } from '@/stores/executionStore'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import SlotConnectionDot from './SlotConnectionDot.vue'
interface InputSlotProps {

View File

@@ -99,18 +99,14 @@
/>
</div>
<!-- Node Body - rendered based on LOD level and collapsed state -->
<div
class="flex flex-1 flex-col gap-1 pb-2"
:data-testid="`node-body-${nodeData.id}`"
>
<!-- Slots only rendered at full detail -->
<NodeSlots :node-data="nodeData" />
<!-- Widgets rendered at reduced+ detail -->
<NodeWidgets v-if="nodeData.widgets?.length" :node-data="nodeData" />
<!-- Custom content at reduced+ detail -->
<div v-if="hasCustomContent" class="min-h-0 flex-1 flex">
<NodeContent :node-data="nodeData" :media="nodeMedia" />
</div>
@@ -121,17 +117,14 @@
</div>
</template>
<!-- Resize handles -->
<template v-if="!isCollapsed">
<div
v-for="handle in cornerResizeHandles"
:key="handle.id"
role="button"
:aria-label="handle.ariaLabel"
:class="cn(baseResizeHandleClasses, handle.classes)"
@pointerdown.stop="handleResizePointerDown(handle.direction)($event)"
/>
</template>
<!-- Resize handle (bottom-right only) -->
<div
v-if="!isCollapsed"
role="button"
:aria-label="t('g.resizeFromBottomRight')"
:class="cn(baseResizeHandleClasses, 'right-0 bottom-0 cursor-se-resize')"
@pointerdown.stop="handleResizePointerDown"
/>
</div>
</template>
@@ -175,7 +168,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import type { ResizeHandleDirection } from '../interactions/resize/resizeMath'
import { useNodeResize } from '../interactions/resize/useNodeResize'
import LivePreview from './LivePreview.vue'
import NodeContent from './NodeContent.vue'
@@ -267,7 +259,7 @@ onErrorCaptured((error) => {
return false // Prevent error propagation
})
const { position, size, zIndex, moveNodeTo } = useNodeLayout(() => nodeData.id)
const { position, size, zIndex } = useNodeLayout(() => nodeData.id)
const { pointerHandlers } = useNodePointerInteractions(() => nodeData.id)
const { onPointerdown, ...remainingPointerHandlers } = pointerHandlers
const { startDrag } = useNodeDrag()
@@ -318,41 +310,6 @@ onMounted(() => {
const baseResizeHandleClasses =
'absolute h-3 w-3 opacity-0 pointer-events-auto focus-visible:outline focus-visible:outline-2 focus-visible:outline-white/40'
const POSITION_EPSILON = 0.01
type CornerResizeHandle = {
id: string
direction: ResizeHandleDirection
classes: string
ariaLabel: string
}
const cornerResizeHandles: CornerResizeHandle[] = [
{
id: 'se',
direction: { horizontal: 'right', vertical: 'bottom' },
classes: 'right-0 bottom-0 cursor-se-resize',
ariaLabel: t('g.resizeFromBottomRight')
},
{
id: 'ne',
direction: { horizontal: 'right', vertical: 'top' },
classes: 'right-0 top-0 cursor-ne-resize',
ariaLabel: t('g.resizeFromTopRight')
},
{
id: 'sw',
direction: { horizontal: 'left', vertical: 'bottom' },
classes: 'left-0 bottom-0 cursor-sw-resize',
ariaLabel: t('g.resizeFromBottomLeft')
},
{
id: 'nw',
direction: { horizontal: 'left', vertical: 'top' },
classes: 'left-0 top-0 cursor-nw-resize',
ariaLabel: t('g.resizeFromTopLeft')
}
]
const MIN_NODE_WIDTH = 225
@@ -365,22 +322,11 @@ const { startResize } = useNodeResize((result, element) => {
// Apply size directly to DOM element - ResizeObserver will pick this up
element.style.setProperty('--node-width', `${clampedWidth}px`)
element.style.setProperty('--node-height', `${result.size.height}px`)
const currentPosition = position.value
const deltaX = Math.abs(result.position.x - currentPosition.x)
const deltaY = Math.abs(result.position.y - currentPosition.y)
if (deltaX > POSITION_EPSILON || deltaY > POSITION_EPSILON) {
moveNodeTo(result.position)
}
})
const handleResizePointerDown = (direction: ResizeHandleDirection) => {
return (event: PointerEvent) => {
if (nodeData.flags?.pinned) return
startResize(event, direction, { ...position.value })
}
const handleResizePointerDown = (event: PointerEvent) => {
if (nodeData.flags?.pinned) return
startResize(event)
}
watch(isCollapsed, (collapsed) => {

View File

@@ -1,5 +0,0 @@
<template>
<div
class="lod-fallback absolute inset-0 h-full w-full bg-node-component-widget-skeleton-surface"
></div>
</template>

View File

@@ -18,7 +18,7 @@
<div class="flex items-center justify-between gap-2.5 min-w-0">
<!-- Collapse/Expand Button -->
<div class="relative grow-1 flex items-center gap-2.5 min-w-0 flex-1">
<div class="lod-toggle flex shrink-0 items-center px-0.5">
<div class="flex shrink-0 items-center px-0.5">
<IconButton
size="fit-content"
type="transparent"
@@ -44,7 +44,7 @@
<!-- Node Title -->
<div
v-tooltip.top="tooltipConfig"
class="lod-toggle flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
class="flex min-w-0 flex-1 items-center gap-2 text-sm font-bold"
data-testid="node-title"
>
<div class="truncate min-w-0 flex-1">
@@ -57,10 +57,9 @@
/>
</div>
</div>
<LODFallback />
</div>
<div class="lod-toggle flex shrink-0 items-center justify-between gap-2">
<div class="flex shrink-0 items-center justify-between gap-2">
<NodeBadge
v-for="badge of nodeBadges"
:key="badge.text"
@@ -112,7 +111,6 @@ import {
} from '@/utils/graphTraversalUtil'
import { cn } from '@/utils/tailwindUtil'
import LODFallback from './LODFallback.vue'
import type { NodeBadgeProps } from './NodeBadge.vue'
interface NodeHeaderProps {

View File

@@ -5,11 +5,10 @@
<!-- Slot Name -->
<span
v-if="!dotOnly"
class="lod-toggle text-xs font-normal truncate text-node-component-slot-text"
class="text-xs font-normal truncate text-node-component-slot-text"
>
{{ slotData.localized_name || slotData.name || `Output ${index}` }}
</span>
<LODFallback />
</div>
<!-- Connection Dot -->
<SlotConnectionDot
@@ -35,7 +34,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 {

View File

@@ -1,141 +0,0 @@
# ComfyUI Widget LOD System: Architecture and Implementation
## Executive Summary
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.
## The Two Approaches: Reactive vs. Static LOD
### Approach 1: Reactive LOD (Original Design)
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.
**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.
### Approach 2: Static LOD with CSS (Current Implementation)
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.
**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.
## The GPU Texture Bottleneck
The key insight driving the current architecture comes from understanding how browsers handle CSS transforms:
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:
### Traditional Assumption
"If we render less content, we get better performance. Therefore, hiding complex widgets should improve zoom/pan performance."
### Actual Browser Behavior
When all nodes are children of a single transformed parent:
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
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.
## Two Distinct Performance Concerns
The analysis reveals two often-conflated performance considerations that should be understood separately:
### 1. Rendering Performance
**Question:** How fast can the browser paint and composite the node graph during interactions?
**Traditional thinking:** Show less content → render faster
**Reality with CSS transforms:** GPU texture size dominates performance, not content complexity
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.
### 2. Memory and Lifecycle Management
**Question:** How much memory do widget instances consume, and what's the cost of maintaining them?
This is where unmounting widgets might theoretically help:
- 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
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.
## Design Philosophy and Trade-offs
The current CSS-based approach makes several deliberate trade-offs:
### What We Optimize For
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
### What We Accept
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
## Open Questions and Future Considerations
### Should widgets have any LOD control?
The current system provides a uniform gray rectangle fallback with CSS visibility hiding. This works for 99% of widgets, but raises questions:
**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?
The challenge is that introducing selective unmounting would require:
- Maintaining widget state across mount/unmount cycles
- Accepting the performance cost of remounting when zooming in
- Adding complexity to the widget API
### Could we reduce GPU texture size?
Since texture dimensions are the limiting factor, could we:
- Use multiple compositor layers for different regions (chunk the transformpane)?
- Render the nodes using the canvas fallback when 500+ nodes and < 30% zoom.
These approaches would require significant architectural changes and might introduce their own performance trade-offs.
### Is there a hybrid approach?
Could we identify specific threshold scenarios where reactive LOD makes sense?
- When node count is low (< 50 nodes)
- For specifically registered "expensive" widgets
- At extreme zoom levels only
## Implementation Guidelines
Given the current architecture, here's how to work within the system:
### For Widget Developers
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
### For System Architects
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
## 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.

View File

@@ -92,12 +92,14 @@ const mockData = vi.hoisted(() => {
vi.mock('@/renderer/core/layout/store/layoutStore', () => {
const isDraggingVueNodes = ref(false)
const isResizingVueNodes = ref(false)
const fakeNodeLayoutRef = ref(mockData.fakeNodeLayout)
const getNodeLayoutRef = vi.fn(() => fakeNodeLayoutRef)
const setSource = vi.fn()
return {
layoutStore: {
isDraggingVueNodes,
isResizingVueNodes,
getNodeLayoutRef,
setSource
}

View File

@@ -63,6 +63,9 @@ export function useNodePointerInteractions(
function onPointermove(event: PointerEvent) {
if (forwardMiddlePointerIfNeeded(event)) return
// Don't activate drag while resizing
if (layoutStore.isResizingVueNodes.value) return
const nodeId = toValue(nodeIdRef)
if (nodeManager.value?.getNode(nodeId)?.flags?.pinned) {

View File

@@ -107,7 +107,7 @@ const resizeObserver = new ResizeObserver((entries) => {
x: topLeftCanvas.x,
y: topLeftCanvas.y + LiteGraph.NODE_TITLE_HEIGHT,
width: Math.max(0, width),
height: Math.max(0, height - LiteGraph.NODE_TITLE_HEIGHT)
height: Math.max(0, height)
}
let updates = updatesByType.get(elementType)
@@ -123,8 +123,7 @@ const resizeObserver = new ResizeObserver((entries) => {
}
}
// Set source to Vue before processing DOM-driven updates
layoutStore.setSource(LayoutSource.Vue)
layoutStore.setSource(LayoutSource.DOM)
// Flush per-type
for (const [type, updates] of updatesByType) {

View File

@@ -1,104 +0,0 @@
import type { Point, Size } from '@/renderer/core/layout/types'
export type ResizeHandleDirection = {
horizontal: 'left' | 'right'
vertical: 'top' | 'bottom'
}
function applyHandleDelta(
startSize: Size,
delta: Point,
handle: ResizeHandleDirection
): Size {
const horizontalMultiplier = handle.horizontal === 'right' ? 1 : -1
const verticalMultiplier = handle.vertical === 'bottom' ? 1 : -1
return {
width: startSize.width + delta.x * horizontalMultiplier,
height: startSize.height + delta.y * verticalMultiplier
}
}
function computeAdjustedPosition(
startPosition: Point,
startSize: Size,
nextSize: Size,
handle: ResizeHandleDirection
): Point {
const widthDelta = startSize.width - nextSize.width
const heightDelta = startSize.height - nextSize.height
return {
x:
handle.horizontal === 'left'
? startPosition.x + widthDelta
: startPosition.x,
y:
handle.vertical === 'top'
? startPosition.y + heightDelta
: startPosition.y
}
}
/**
* Computes the resulting size and position of a node given pointer movement
* and handle orientation.
*/
export function computeResizeOutcome({
startSize,
startPosition,
delta,
handle,
snapFn
}: {
startSize: Size
startPosition: Point
delta: Point
handle: ResizeHandleDirection
snapFn?: (size: Size) => Size
}): { size: Size; position: Point } {
const resized = applyHandleDelta(startSize, delta, handle)
const snapped = snapFn?.(resized) ?? resized
const position = computeAdjustedPosition(
startPosition,
startSize,
snapped,
handle
)
return {
size: snapped,
position
}
}
export function createResizeSession(config: {
startSize: Size
startPosition: Point
handle: ResizeHandleDirection
}) {
const startSize = { ...config.startSize }
const startPosition = { ...config.startPosition }
const handle = config.handle
return (delta: Point, snapFn?: (size: Size) => Size) =>
computeResizeOutcome({
startSize,
startPosition,
handle,
delta,
snapFn
})
}
export function toCanvasDelta(
startPointer: Point,
currentPointer: Point,
scale: number
): Point {
const safeScale = scale === 0 ? 1 : scale
return {
x: (currentPointer.x - startPointer.x) / safeScale,
y: (currentPointer.y - startPointer.y) / safeScale
}
}

View File

@@ -2,20 +2,17 @@ import { useEventListener } from '@vueuse/core'
import { ref } from 'vue'
import type { Point, Size } from '@/renderer/core/layout/types'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useNodeSnap } from '@/renderer/extensions/vueNodes/composables/useNodeSnap'
import { useShiftKeySync } from '@/renderer/extensions/vueNodes/composables/useShiftKeySync'
import type { ResizeHandleDirection } from './resizeMath'
import { createResizeSession, toCanvasDelta } from './resizeMath'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
interface ResizeCallbackPayload {
size: Size
position: Point
}
/**
* Composable for node resizing functionality
* Composable for node resizing functionality (bottom-right corner only)
*
* Provides resize handle interaction that integrates with the layout system.
* Handles pointer capture, coordinate calculations, and size constraints.
@@ -27,16 +24,7 @@ export function useNodeResize(
const isResizing = ref(false)
const resizeStartPointer = ref<Point | null>(null)
const resizeSession = ref<
| ((
delta: Point,
snapFn?: (size: Size) => Size
) => {
size: Size
position: Point
})
| null
>(null)
const resizeStartSize = ref<Size | null>(null)
// Snap-to-grid functionality
const { shouldSnap, applySnapToSize } = useNodeSnap()
@@ -44,11 +32,7 @@ export function useNodeResize(
// Shift key sync for LiteGraph canvas preview
const { trackShiftKey } = useShiftKeySync()
const startResize = (
event: PointerEvent,
handle: ResizeHandleDirection,
startPosition: Point
) => {
const startResize = (event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
@@ -72,47 +56,49 @@ export function useNodeResize(
// Capture pointer to ensure we get all move/up events
target.setPointerCapture(event.pointerId)
// Mark as resizing to prevent drag from activating
layoutStore.isResizingVueNodes.value = true
isResizing.value = true
resizeStartPointer.value = { x: event.clientX, y: event.clientY }
resizeSession.value = createResizeSession({
startSize,
startPosition: { ...startPosition },
handle
})
resizeStartSize.value = startSize
const handlePointerMove = (moveEvent: PointerEvent) => {
if (
!isResizing.value ||
!resizeStartPointer.value ||
!resizeSession.value
)
!resizeStartSize.value
) {
return
}
const startPointer = resizeStartPointer.value
const session = resizeSession.value
const scale = transformState.camera.z
const deltaX =
(moveEvent.clientX - resizeStartPointer.value.x) / (scale || 1)
const deltaY =
(moveEvent.clientY - resizeStartPointer.value.y) / (scale || 1)
const delta = toCanvasDelta(
startPointer,
{ x: moveEvent.clientX, y: moveEvent.clientY },
transformState.camera.z
)
let newSize: Size = {
width: resizeStartSize.value.width + deltaX,
height: resizeStartSize.value.height + deltaY
}
// Apply snap if shift is held
if (shouldSnap(moveEvent)) {
newSize = applySnapToSize(newSize)
}
const nodeElement = target.closest('[data-node-id]')
if (nodeElement instanceof HTMLElement) {
const outcome = session(
delta,
shouldSnap(moveEvent) ? applySnapToSize : undefined
)
resizeCallback(outcome, nodeElement)
resizeCallback({ size: newSize }, nodeElement)
}
}
const handlePointerUp = (upEvent: PointerEvent) => {
if (isResizing.value) {
isResizing.value = false
layoutStore.isResizingVueNodes.value = false
resizeStartPointer.value = null
resizeSession.value = null
resizeStartSize.value = null
// Stop tracking shift key state
stopShiftSync()

View File

@@ -1,34 +0,0 @@
/**
* 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'
import { useSettingStore } from '@/platform/settings/settingStore'
interface Camera {
z: number // zoom level
}
export function useLOD(camera: Camera) {
const isLOD = computed(() => {
const { pixelRatio } = useDevicePixelRatio()
const baseFontSize = 14
const dprAdjustment = Math.sqrt(pixelRatio.value)
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
return camera.z < threshold
})
return { isLOD }
}

View File

@@ -5,7 +5,7 @@
>
<!-- Display mode: Rendered markdown -->
<div
class="comfy-markdown-content lod-toggle size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
class="comfy-markdown-content size-full min-h-[60px] overflow-y-auto rounded-lg text-sm"
:class="isEditing === false ? 'visible' : 'invisible'"
v-html="renderedHtml"
/>
@@ -27,7 +27,6 @@
@click.stop
@keydown.stop
/>
<LODFallback />
</div>
</template>
@@ -38,8 +37,6 @@ import { computed, nextTick, ref } from 'vue'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
import { renderMarkdownToHtml } from '@/utils/markdownRendererUtil'
import LODFallback from '../../components/LODFallback.vue'
const { widget } = defineProps<{
widget: SimplifiedWidget<string>
}>()

View File

@@ -78,7 +78,6 @@
@ended="playback.onPlaybackEnded"
@loadedmetadata="playback.onMetadataLoaded"
/>
<LODFallback />
</div>
</template>
@@ -91,7 +90,6 @@ import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { app } from '@/scripts/app'
import { useAudioService } from '@/services/audioService'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'

View File

@@ -3,6 +3,7 @@
<Select
v-model="modelValue"
:invalid
:filter="selectOptions.length > 4"
:options="selectOptions"
v-bind="combinedProps"
:class="cn(WidgetInputBaseClass, 'w-full text-xs')"

View File

@@ -3,9 +3,7 @@
<Textarea
v-model="modelValue"
v-bind="filteredProps"
:class="
cn(WidgetInputBaseClass, 'size-full text-xs lod-toggle resize-none')
"
:class="cn(WidgetInputBaseClass, 'size-full text-xs resize-none')"
:placeholder="placeholder || widget.name || ''"
:aria-label="widget.name"
:readonly="widget.options?.read_only"
@@ -17,7 +15,6 @@
@pointerup.capture.stop
@contextmenu.capture.stop
/>
<LODFallback />
</div>
</template>
@@ -32,7 +29,6 @@ import {
filterWidgetProps
} from '@/utils/widgetPropFilter'
import LODFallback from '../../components/LODFallback.vue'
import { WidgetInputBaseClass } from './layout'
const { widget, placeholder = '' } = defineProps<{

View File

@@ -132,7 +132,6 @@
</template>
</TieredMenu>
</div>
<LODFallback />
</div>
</template>
@@ -143,7 +142,6 @@ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import LODFallback from '@/renderer/extensions/vueNodes/components/LODFallback.vue'
import { api } from '@/scripts/api'
import { app } from '@/scripts/app'
import { useNodeOutputStore } from '@/stores/imagePreviewStore'

View File

@@ -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' | 'label'>
}>()
@@ -17,23 +15,21 @@ defineProps<{
<div class="relative flex h-full min-w-0 items-center">
<p
v-if="widget.name"
class="lod-toggle flex-1 truncate text-xs font-normal text-node-component-slot-text"
class="flex-1 truncate text-xs font-normal text-node-component-slot-text"
>
{{ widget.label || widget.name }}
</p>
<LODFallback />
</div>
<!-- basis-full grow -->
<div class="relative min-w-0 flex-1">
<div
class="lod-toggle cursor-default min-w-0"
class="cursor-default min-w-0"
@pointerdown.stop="noop"
@pointermove.stop="noop"
@pointerup.stop="noop"
>
<slot />
</div>
<LODFallback />
</div>
</div>
</template>

View File

@@ -0,0 +1,37 @@
import { unref } from 'vue'
import type { MaybeRef } from 'vue'
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { collectAllNodes } from '@/utils/graphTraversalUtil'
export type NodeDefLookup = Record<string, ComfyNodeDefImpl | undefined>
const isNodeMissingDefinition = (
node: LGraphNode,
nodeDefsByName: NodeDefLookup
) => {
const nodeName = node?.type
if (!nodeName) return false
return !nodeDefsByName[nodeName]
}
export const collectMissingNodes = (
graph: LGraph | Subgraph | null | undefined,
nodeDefsByName: MaybeRef<NodeDefLookup>
): LGraphNode[] => {
if (!graph) return []
const lookup = unref(nodeDefsByName)
return collectAllNodes(graph, (node) => isNodeMissingDefinition(node, lookup))
}
export const graphHasMissingNodes = (
graph: LGraph | Subgraph | null | undefined,
nodeDefsByName: MaybeRef<NodeDefLookup>
) => {
return collectMissingNodes(graph, nodeDefsByName).length > 0
}

View File

@@ -1,11 +1,9 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import {
type LayoutChange,
LayoutSource,
type NodeLayout
} from '@/renderer/core/layout/types'
import { LayoutSource } from '@/renderer/core/layout/types'
import type { LayoutChange, NodeLayout } from '@/renderer/core/layout/types'
describe('layoutStore CRDT operations', () => {
beforeEach(() => {
@@ -304,4 +302,108 @@ describe('layoutStore CRDT operations', () => {
expect(recentOps.length).toBeGreaterThanOrEqual(1)
expect(recentOps[0].type).toBe('moveNode')
})
it('normalizes DOM-sourced heights before storing', () => {
const nodeId = 'dom-node'
const layout = createTestNode(nodeId)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
layoutStore.setSource(LayoutSource.DOM)
layoutStore.batchUpdateNodeBounds([
{
nodeId,
bounds: {
x: layout.bounds.x,
y: layout.bounds.y,
width: layout.size.width,
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
}
}
])
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size.height).toBe(layout.size.height)
expect(nodeRef.value?.size.width).toBe(layout.size.width)
expect(nodeRef.value?.position).toEqual(layout.position)
})
it('normalizes very small DOM-sourced heights safely', () => {
const nodeId = 'small-dom-node'
const layout = createTestNode(nodeId)
layout.size.height = 10
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
layoutStore.setSource(LayoutSource.DOM)
layoutStore.batchUpdateNodeBounds([
{
nodeId,
bounds: {
x: layout.bounds.x,
y: layout.bounds.y,
width: layout.size.width,
height: layout.size.height + LiteGraph.NODE_TITLE_HEIGHT
}
}
])
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size.height).toBeGreaterThanOrEqual(0)
})
it('handles undefined NODE_TITLE_HEIGHT without NaN results', () => {
const nodeId = 'undefined-title-height'
const layout = createTestNode(nodeId)
layoutStore.applyOperation({
type: 'createNode',
entity: 'node',
nodeId,
layout,
timestamp: Date.now(),
source: LayoutSource.External,
actor: 'test'
})
const originalTitleHeight = LiteGraph.NODE_TITLE_HEIGHT
// @ts-expect-error intentionally simulate undefined runtime value
LiteGraph.NODE_TITLE_HEIGHT = undefined
try {
layoutStore.setSource(LayoutSource.DOM)
layoutStore.batchUpdateNodeBounds([
{
nodeId,
bounds: {
x: layout.bounds.x,
y: layout.bounds.y,
width: layout.size.width,
height: layout.size.height
}
}
])
const nodeRef = layoutStore.getNodeLayoutRef(nodeId)
expect(nodeRef.value?.size.height).toBe(layout.size.height)
} finally {
LiteGraph.NODE_TITLE_HEIGHT = originalTitleHeight
}
})
})

View File

@@ -5,7 +5,9 @@ import { LayoutSource } from '@/renderer/core/layout/types'
import { useNodeZIndex } from '@/renderer/extensions/vueNodes/composables/useNodeZIndex'
// Mock the layout mutations module
vi.mock('@/renderer/core/layout/operations/layoutMutations')
vi.mock('@/renderer/core/layout/operations/layoutMutations', () => ({
useLayoutMutations: vi.fn()
}))
const mockedUseLayoutMutations = vi.mocked(useLayoutMutations)

View File

@@ -1,94 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import {
computeResizeOutcome,
createResizeSession,
toCanvasDelta
} from '@/renderer/extensions/vueNodes/interactions/resize/resizeMath'
describe('nodeResizeMath', () => {
const startSize = { width: 200, height: 120 }
const startPosition = { x: 80, y: 160 }
it('computes resize from bottom-right corner without moving position', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 40, y: 20 },
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size).toEqual({ width: 240, height: 140 })
expect(outcome.position).toEqual(startPosition)
})
it('computes resize from top-left corner adjusting position', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: -30, y: -20 },
handle: { horizontal: 'left', vertical: 'top' }
})
expect(outcome.size).toEqual({ width: 230, height: 140 })
expect(outcome.position).toEqual({ x: 50, y: 140 })
})
it('supports reusable resize sessions with snapping', () => {
const session = createResizeSession({
startSize,
startPosition,
handle: { horizontal: 'right', vertical: 'bottom' }
})
const snapFn = vi.fn((size: typeof startSize) => ({
width: Math.round(size.width / 25) * 25,
height: Math.round(size.height / 25) * 25
}))
const applied = session({ x: 13, y: 17 }, snapFn)
expect(applied.size).toEqual({ width: 225, height: 125 })
expect(applied.position).toEqual(startPosition)
expect(snapFn).toHaveBeenCalled()
})
it('converts screen delta to canvas delta using scale', () => {
const delta = toCanvasDelta({ x: 50, y: 75 }, { x: 150, y: 135 }, 2)
expect(delta).toEqual({ x: 50, y: 30 })
})
describe('edge cases', () => {
it('handles zero scale by using fallback scale of 1', () => {
const delta = toCanvasDelta({ x: 50, y: 75 }, { x: 150, y: 135 }, 0)
expect(delta).toEqual({ x: 100, y: 60 })
})
it('handles negative deltas when resizing from right/bottom', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: -50, y: -30 },
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size).toEqual({ width: 150, height: 90 })
expect(outcome.position).toEqual(startPosition)
})
it('handles very large deltas without overflow', () => {
const outcome = computeResizeOutcome({
startSize,
startPosition,
delta: { x: 10000, y: 10000 },
handle: { horizontal: 'right', vertical: 'bottom' }
})
expect(outcome.size.width).toBe(10200)
expect(outcome.size.height).toBe(10120)
expect(outcome.position).toEqual(startPosition)
})
})
})

View File

@@ -1,69 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, reactive } from 'vue'
import { useLOD } from '@/renderer/extensions/vueNodes/lod/useLOD'
const mockSettingStore = reactive({
get: vi.fn(() => 8)
})
vi.mock('@/platform/settings/settingStore', () => ({
useSettingStore: () => mockSettingStore
}))
describe('useLOD', () => {
beforeEach(() => {
vi.restoreAllMocks()
vi.unstubAllGlobals()
mockSettingStore.get.mockReturnValue(8)
})
it('should calculate isLOD value based on zoom threshold correctly', async () => {
vi.stubGlobal('devicePixelRatio', 1)
const camera = reactive({ z: 1 })
const { isLOD } = useLOD(camera)
await nextTick()
expect(isLOD.value).toBe(false)
camera.z = 0.55
await nextTick()
expect(isLOD.value).toBe(true)
camera.z = 0.87
await nextTick()
expect(isLOD.value).toBe(false)
})
it('should handle a different devicePixelRatio value', async () => {
vi.stubGlobal('devicePixelRatio', 3) //Threshold with 8px minFontsize = 0.19
const camera = reactive({ z: 1 })
const { isLOD } = useLOD(camera)
await nextTick()
expect(isLOD.value).toBe(false)
camera.z = 0.18
await nextTick()
expect(isLOD.value).toBe(true)
})
it('should respond to different minFontSize settings', async () => {
vi.stubGlobal('devicePixelRatio', 1)
mockSettingStore.get.mockReturnValue(16) //Now threshold is 1.14
const camera = reactive({ z: 1 })
const { isLOD } = useLOD(camera)
await nextTick()
expect(isLOD.value).toBe(true)
camera.z = 1.15
await nextTick()
expect(isLOD.value).toBe(false)
})
})

View File

@@ -0,0 +1,115 @@
import { describe, expect, it } from 'vitest'
import type {
LGraph,
LGraphNode,
Subgraph
} from '@/lib/litegraph/src/litegraph'
import {
collectMissingNodes,
graphHasMissingNodes
} from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import type { NodeDefLookup } from '@/workbench/extensions/manager/utils/graphHasMissingNodes'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type NodeDefs = NodeDefLookup
let nodeIdCounter = 0
const mockNodeDef = {} as ComfyNodeDefImpl
const createGraph = (nodes: LGraphNode[] = []): LGraph => {
return { nodes } as Partial<LGraph> as LGraph
}
const createSubgraph = (nodes: LGraphNode[]): Subgraph => {
return { nodes } as Partial<Subgraph> as Subgraph
}
const createNode = (
type?: string,
subgraphNodes?: LGraphNode[]
): LGraphNode => {
return {
id: nodeIdCounter++,
type,
isSubgraphNode: subgraphNodes ? () => true : undefined,
subgraph: subgraphNodes ? createSubgraph(subgraphNodes) : undefined
} as unknown as LGraphNode
}
describe('graphHasMissingNodes', () => {
it('returns false when graph is null', () => {
expect(graphHasMissingNodes(null, {})).toBe(false)
})
it('returns false when graph is undefined', () => {
expect(graphHasMissingNodes(undefined, {})).toBe(false)
})
it('returns false when graph has no nodes', () => {
expect(graphHasMissingNodes(createGraph(), {})).toBe(false)
})
it('returns false when every node has a definition', () => {
const graph = createGraph([createNode('FooNode'), createNode('BarNode')])
const nodeDefs: NodeDefs = {
FooNode: mockNodeDef,
BarNode: mockNodeDef
}
expect(graphHasMissingNodes(graph, nodeDefs)).toBe(false)
})
it('returns true when at least one node is missing', () => {
const graph = createGraph([
createNode('FooNode'),
createNode('MissingNode')
])
const nodeDefs: NodeDefs = {
FooNode: mockNodeDef
}
expect(graphHasMissingNodes(graph, nodeDefs)).toBe(true)
})
it('checks nodes nested in subgraphs', () => {
const graph = createGraph([
createNode('ContainerNode', [createNode('InnerMissing')])
])
const nodeDefs: NodeDefs = {
ContainerNode: mockNodeDef
}
const missingNodes = collectMissingNodes(graph, nodeDefs)
expect(missingNodes).toHaveLength(1)
expect(missingNodes[0]?.type).toBe('InnerMissing')
})
it('ignores nodes without a type', () => {
const graph = createGraph([
createNode(undefined),
createNode(null as unknown as string)
])
expect(graphHasMissingNodes(graph, {})).toBe(false)
})
it('traverses deeply nested subgraphs', () => {
const deepGraph = createGraph([
createNode('Layer1', [
createNode('Layer2', [
createNode('Layer3', [createNode('MissingDeep')])
])
])
])
const nodeDefs: NodeDefs = {
Layer1: mockNodeDef,
Layer2: mockNodeDef,
Layer3: mockNodeDef
}
const missingNodes = collectMissingNodes(deepGraph, nodeDefs)
expect(missingNodes).toHaveLength(1)
expect(missingNodes[0]?.type).toBe('MissingDeep')
})
})