Skip to content

Commit 31f27d9

Browse files
committed
perf: restore LiteGraph LOD parity for Nodes 2.0
1 parent c3045a0 commit 31f27d9

21 files changed

Lines changed: 1450 additions & 57 deletions

browser_tests/fixtures/VueNodeHelpers.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -199,13 +199,41 @@ export class VueNodeHelpers {
199199
* Wait for Vue nodes to be rendered
200200
*/
201201
async waitForNodes(expectedCount?: number): Promise<void> {
202-
if (expectedCount !== undefined) {
203-
await this.page.waitForFunction(
204-
(count) => document.querySelectorAll('[data-node-id]').length >= count,
205-
expectedCount
206-
)
207-
} else {
208-
await this.page.locator('[data-node-id]').first().waitFor()
202+
try {
203+
if (expectedCount !== undefined) {
204+
await this.page.waitForFunction(
205+
(count) =>
206+
document.querySelectorAll('[data-node-id]').length >= count,
207+
expectedCount
208+
)
209+
} else {
210+
await this.page.locator('[data-node-id]').first().waitFor()
211+
}
212+
} catch (error) {
213+
// Below the level-of-detail threshold no `[data-node-id]` is ever
214+
// created, so this waits out its full timeout and reports only that a
215+
// locator was not found - which points at the node rather than the zoom,
216+
// and costs the timeout on every test in the file. Say so instead.
217+
const belowLod = await this.page.evaluate(() => {
218+
const canvas = window.app?.canvas
219+
const minFontSize = canvas?.min_font_size_for_lod ?? 0
220+
if (!canvas || minFontSize <= 0) return null
221+
222+
const textSize = window.LiteGraph?.NODE_TEXT_SIZE ?? 14
223+
const threshold =
224+
minFontSize / (textSize * Math.sqrt(window.devicePixelRatio || 1))
225+
return canvas.ds.scale < threshold
226+
? { scale: canvas.ds.scale, threshold }
227+
: null
228+
})
229+
230+
if (belowLod) {
231+
throw new Error(
232+
`No Vue nodes are addressable: canvas zoom ${belowLod.scale.toFixed(3)} is below the level-of-detail threshold ${belowLod.threshold.toFixed(3)}, so nodes render as canvas boxes with no [data-node-id] element. Call ensureNodesAddressable(comfyPage) after loading, or set a zoom above the threshold.`,
233+
{ cause: error }
234+
)
235+
}
236+
throw error
209237
}
210238
}
211239

browser_tests/fixtures/utils/fitToView.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { ReadOnlyRect } from '@/lib/litegraph/src/interfaces'
22
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
3+
import { ensureNodesAddressable } from '@e2e/fixtures/utils/lodZoom'
34

45
interface FitToViewOptions {
56
selectionOnly?: boolean
@@ -95,10 +96,14 @@ export async function fitToViewInstant(
9596

9697
const canvas = app.canvas
9798
canvas.ds.fitToBounds(bounds, { zoom })
99+
98100
canvas.setDirty(true, true)
99101
},
100102
{ bounds, zoom }
101103
)
102104

103-
await comfyPage.nextFrame()
105+
// Fitting a large graph can land below the level-of-detail threshold, where
106+
// Vue nodes are drawn as canvas boxes and no `[data-node-id]` exists. Specs
107+
// that want the simplified mode set the zoom themselves.
108+
await ensureNodesAddressable(comfyPage)
104109
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
2+
3+
/**
4+
* Margin above the threshold. The composable applies 1.15 hysteresis on the way
5+
* back up, so sitting exactly at the threshold is not enough to stay there.
6+
*/
7+
const LOD_CLEARANCE = 1.2
8+
9+
/**
10+
* Raises the canvas zoom until Vue nodes exist as DOM elements.
11+
*
12+
* Any spec that queries `[data-node-id]` needs this whenever the zoom is not
13+
* under its own control - a workflow fixture can pin `extra.ds.scale` below the
14+
* threshold, and `large-graph-workflow.json` does exactly that at 0.5 against a
15+
* default threshold of 0.571. The symptom is a bare locator timeout with
16+
* nothing pointing back at the zoom, or a `count() === 0` assertion that passes
17+
* for the wrong reason.
18+
*
19+
* A no-op above the threshold, and when LOD is disabled entirely.
20+
*/
21+
export async function ensureNodesAddressable(comfyPage: ComfyPage) {
22+
await comfyPage.page.evaluate(async (clearance) => {
23+
const canvas = window.app?.canvas
24+
if (!canvas) return
25+
26+
const vueNodesEnabled = await window.app?.extensionManager.setting.get(
27+
'Comfy.VueNodes.Enabled'
28+
)
29+
if (!vueNodesEnabled) return
30+
31+
const minFontSize = canvas.min_font_size_for_lod ?? 0
32+
if (minFontSize <= 0) return
33+
34+
const textSize = window.LiteGraph?.NODE_TEXT_SIZE ?? 14
35+
const dprAdjustment = Math.sqrt(window.devicePixelRatio || 1)
36+
const minimumInteractiveZoom =
37+
(minFontSize / (textSize * dprAdjustment)) * clearance
38+
39+
if (canvas.ds.scale < minimumInteractiveZoom) {
40+
canvas.ds.changeScale(minimumInteractiveZoom)
41+
canvas.setDirty(true, true)
42+
}
43+
}, LOD_CLEARANCE)
44+
45+
await comfyPage.nextFrame()
46+
}

browser_tests/fixtures/utils/runtimeReflow.ts

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,69 @@ interface ReflowNodeUnderTest {
2323
}
2424

2525
/**
26-
* Adds a reflow node, fits it into view, and captures its starting height so a
27-
* test can assert on the height delta after triggering runtime growth.
26+
* Floor for the measurement zoom, used when the LOD threshold cannot be read.
27+
* Above the default threshold at DPR >= 1, but a literal cannot stay safe as
28+
* DPR drops - at DPR 0.5 the threshold is 0.808 - which is why the real zoom
29+
* is derived on the page from the live threshold instead.
30+
*/
31+
const MEASUREMENT_ZOOM_FLOOR = 0.8
32+
33+
/** Centers the viewport on a node at a zoom derived to sit above the LOD threshold. */
34+
async function centerOnNode(comfyPage: ComfyPage, nodeId: string) {
35+
await comfyPage.page.evaluate(
36+
([id, zoomFloor]) => {
37+
const canvas = window.app!.canvas
38+
const graph = window.graph as unknown as TestGraphAccess
39+
const node = graph._nodes_by_id[id]
40+
if (!node) {
41+
// A silent return here surfaces later as an opaque locator timeout
42+
// pointing at the node rather than at this fixture.
43+
throw new Error(
44+
`runtimeReflow: node ${id} not found in the graph; cannot center on it`
45+
)
46+
}
47+
48+
// Derived from the live threshold, reading the same inputs litegraph
49+
// uses, so a DPR or setting change cannot silently put the measurement
50+
// below the LOD cutoff where no [data-node-id] exists.
51+
const minFontSize = canvas.min_font_size_for_lod ?? 0
52+
const textSize = window.LiteGraph?.NODE_TEXT_SIZE ?? 14
53+
const lodThreshold =
54+
minFontSize > 0
55+
? minFontSize / (textSize * Math.sqrt(window.devicePixelRatio || 1))
56+
: 0
57+
const zoom = Math.max(Number(zoomFloor), lodThreshold * 1.4)
58+
59+
const element = canvas.canvas
60+
canvas.ds.scale = Number(zoom)
61+
canvas.ds.offset[0] =
62+
-(node.pos[0] + node.size[0] / 2) +
63+
element.clientWidth / 2 / Number(zoom)
64+
canvas.ds.offset[1] =
65+
-(node.pos[1] + node.size[1] / 2) +
66+
element.clientHeight / 2 / Number(zoom)
67+
canvas.setDirty(true, true)
68+
},
69+
[nodeId, MEASUREMENT_ZOOM_FLOOR] as const
70+
)
71+
await comfyPage.nextFrame()
72+
}
73+
74+
/**
75+
* Adds a reflow node, centers it at a deterministic zoom, and captures its
76+
* starting height so a test can assert on the height delta after triggering
77+
* runtime growth.
78+
*
79+
* Fit-to-view alone can land below the LOD threshold on this workflow, where
80+
* no Vue node elements exist at all, so the node is centered at a fixed zoom
81+
* instead of measured wherever fit happens to leave it.
2882
*/
2983
export async function addReflowNodeAndMeasure(
3084
comfyPage: ComfyPage
3185
): Promise<ReflowNodeUnderTest> {
3286
const nodeId = await addRuntimeReflowNode(comfyPage)
3387
await fitToViewInstant(comfyPage)
88+
await centerOnNode(comfyPage, nodeId)
3489

3590
const node = comfyPage.vueNodes.getNodeLocator(nodeId)
3691
await expect(node).toBeVisible()

browser_tests/tests/performance.spec.ts

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { expect } from '@playwright/test'
22

33
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
4+
import { ensureNodesAddressable } from '@e2e/fixtures/utils/lodZoom'
45
import {
56
logMeasurement,
67
recordMeasurement
@@ -328,6 +329,10 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
328329
test.beforeEach(async ({ comfyPage }) => {
329330
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
330331
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
332+
// The fixture pins extra.ds.scale to 0.5, below the 0.571 LOD threshold,
333+
// so nothing mounts and waitForNodes below would time out on every test
334+
// in this describe.
335+
await ensureNodesAddressable(comfyPage)
331336
await comfyPage.vueNodes.waitForNodes()
332337
})
333338

@@ -370,27 +375,37 @@ test.describe('Performance', { tag: ['@perf'] }, () => {
370375
})
371376

372377
test('zoom out culling', async ({ comfyPage }) => {
378+
const canvasBox = await comfyPage.canvas.boundingBox()
379+
if (!canvasBox) throw new Error('Canvas bounding box not available')
380+
381+
const centerX = canvasBox.x + canvasBox.width / 2
382+
const centerY = canvasBox.y + canvasBox.height / 2
383+
const lodThreshold = await comfyPage.page.evaluate(() => {
384+
const canvas = window.app!.canvas
385+
const textSize = window.LiteGraph!.NODE_TEXT_SIZE
386+
const dprAdjustment = Math.sqrt(window.devicePixelRatio || 1)
387+
return canvas.min_font_size_for_lod / (textSize * dprAdjustment)
388+
})
389+
373390
await comfyPage.perf.startMeasuring()
374391

375-
// Zoom out far enough that nodes become < 4px screen size
376-
// (triggers size-based culling in isNodeInViewport)
392+
await comfyPage.page.mouse.move(centerX, centerY)
377393
for (let i = 0; i < 20; i++) {
378-
await comfyPage.canvasOps.zoom(100)
394+
await comfyPage.page.mouse.wheel(0, 100)
395+
await comfyPage.nextFrame()
379396
}
380397

381-
// Verify we actually entered the culling regime.
382-
// isNodeTooSmall triggers when max(width, height) * scale < 4px.
383-
// Typical nodes are ~200px wide, so scale must be < 0.02.
384-
await expect.poll(() => comfyPage.canvasOps.getScale()).toBeLessThan(0.02)
398+
await expect
399+
.poll(() => comfyPage.canvasOps.getScale())
400+
.toBeLessThan(lodThreshold)
385401

386-
// Idle at extreme zoom-out — most nodes should be culled
387402
for (let i = 0; i < 60; i++) {
388403
await comfyPage.nextFrame()
389404
}
390405

391-
// Zoom back in
392406
for (let i = 0; i < 20; i++) {
393-
await comfyPage.canvasOps.zoom(-100)
407+
await comfyPage.page.mouse.wheel(0, -100)
408+
await comfyPage.nextFrame()
394409
}
395410

396411
const m = await comfyPage.perf.stopMeasuring('vue-zoom-culling')

browser_tests/tests/vueNodes/viewportKeepAlive.spec.ts

Lines changed: 81 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import {
22
comfyExpect as expect,
33
comfyPageFixture as test
44
} from '@e2e/fixtures/ComfyPage'
5+
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
6+
import largeGraphWorkflow from '@e2e/assets/large-graph-workflow.json' with { type: 'json' }
57
import type { Page } from '@playwright/test'
8+
import { zComfyWorkflow } from '@/platform/workflow/validation/schemas/workflowSchema'
9+
10+
const LARGE_GRAPH_WORKFLOW = zComfyWorkflow.parse(largeGraphWorkflow)
611

712
async function setCanvasOffsetX(page: Page, offsetX: number): Promise<void> {
813
await page.evaluate((x) => {
@@ -12,13 +17,28 @@ async function setCanvasOffsetX(page: Page, offsetX: number): Promise<void> {
1217
}, offsetX)
1318
}
1419

20+
async function setCanvasScale(comfyPage: ComfyPage, scale: number) {
21+
await comfyPage.canvasOps.setScale(scale)
22+
await comfyPage.nextFrame()
23+
}
24+
25+
async function getGraphNodeSize(page: Page, nodeId: string) {
26+
return await page.evaluate((id) => {
27+
const node = window.app!.graph!.nodes.find(
28+
(candidate) => String(candidate.id) === id
29+
)
30+
if (!node) throw new Error(`Node ${id} is missing`)
31+
return Array.from(node.size)
32+
}, nodeId)
33+
}
34+
1535
test.describe(
1636
'Vue node viewport KeepAlive',
1737
{ tag: ['@canvas', '@node', '@vue-nodes', '@slow'] },
1838
() => {
1939
test.beforeEach(async ({ comfyPage }) => {
2040
await comfyPage.settings.setSetting('Comfy.VueNodes.Enabled', true)
21-
await comfyPage.workflow.loadWorkflow('large-graph-workflow')
41+
await comfyPage.workflow.loadGraphData(LARGE_GRAPH_WORKFLOW)
2242
await comfyPage.canvasOps.setScale(1)
2343
await comfyPage.page.evaluate(() => {
2444
const canvas = window.app!.canvas
@@ -45,13 +65,7 @@ test.describe(
4565
if (!nodeId || !initialElement) {
4666
throw new Error('Expected an active Vue node with an id')
4767
}
48-
const initialSize = await comfyPage.page.evaluate((id) => {
49-
const node = window.app!.graph!.nodes.find(
50-
(candidate) => String(candidate.id) === id
51-
)
52-
if (!node) throw new Error(`Node ${id} is missing`)
53-
return Array.from(node.size)
54-
}, nodeId)
68+
const initialSize = await getGraphNodeSize(comfyPage.page, nodeId)
5569

5670
await setCanvasOffsetX(comfyPage.page, -100_000)
5771

@@ -75,15 +89,9 @@ test.describe(
7589
returnedElement
7690
)
7791
).toBe(true)
78-
expect(
79-
await comfyPage.page.evaluate((id) => {
80-
const node = window.app!.graph!.nodes.find(
81-
(candidate) => String(candidate.id) === id
82-
)
83-
if (!node) throw new Error(`Node ${id} is missing`)
84-
return Array.from(node.size)
85-
}, nodeId)
86-
).toEqual(initialSize)
92+
expect(await getGraphNodeSize(comfyPage.page, nodeId)).toEqual(
93+
initialSize
94+
)
8795
})
8896

8997
test('keeps the focused node connected while panning away', async ({
@@ -149,5 +157,61 @@ test.describe(
149157
.poll(() => sourceElement.evaluate((element) => element.isConnected))
150158
.toBe(false)
151159
})
160+
161+
test('uses boxes at low quality and restores the same node element', async ({
162+
comfyPage
163+
}) => {
164+
test.slow()
165+
const initialNode = comfyPage.vueNodes.nodes.first()
166+
const nodeId = await initialNode.getAttribute('data-node-id')
167+
const initialElement = await initialNode.elementHandle()
168+
if (!nodeId || !initialElement) {
169+
throw new Error('Expected an active Vue node with an id')
170+
}
171+
const initialSize = await getGraphNodeSize(comfyPage.page, nodeId)
172+
173+
await setCanvasScale(comfyPage, 0.4)
174+
await expect(comfyPage.page.getByTestId('node-box-overlay')).toBeVisible()
175+
await expect
176+
.poll(() => initialElement.evaluate((element) => element.isConnected))
177+
.toBe(false)
178+
179+
await setCanvasScale(comfyPage, 1)
180+
const returnedNode = comfyPage.vueNodes.getNodeLocator(nodeId)
181+
await expect(returnedNode).toBeVisible()
182+
const returnedElement = await returnedNode.elementHandle()
183+
if (!returnedElement) throw new Error('Expected the Vue node to return')
184+
185+
expect(
186+
await initialElement.evaluate(
187+
(element, candidate) => element === candidate,
188+
returnedElement
189+
)
190+
).toBe(true)
191+
expect(await getGraphNodeSize(comfyPage.page, nodeId)).toEqual(
192+
initialSize
193+
)
194+
})
195+
196+
test('keeps the focused node connected in low-quality mode', async ({
197+
comfyPage
198+
}) => {
199+
test.slow()
200+
const focusedNode = comfyPage.vueNodes.nodes.first()
201+
const focusedElement = await focusedNode.elementHandle()
202+
if (!focusedElement) throw new Error('Expected an active Vue node')
203+
await focusedNode.focus()
204+
205+
await setCanvasScale(comfyPage, 0.4)
206+
await expect(comfyPage.page.getByTestId('node-box-overlay')).toBeVisible()
207+
await expect
208+
.poll(() => focusedElement.evaluate((element) => element.isConnected))
209+
.toBe(true)
210+
expect(
211+
await focusedElement.evaluate((element) =>
212+
element.contains(document.activeElement)
213+
)
214+
).toBe(true)
215+
})
152216
}
153217
)

0 commit comments

Comments
 (0)