Compare commits

...

14 Commits

Author SHA1 Message Date
Rizumu Ayaka
c499cf1de0 perf: reuse pendingChangedIds Set to reduce GC pressure
Clearing the existing Set avoids allocating a new one every RAF frame
(~60/s) in the TransformPane hot path.
2026-04-22 19:44:17 +08:00
Rizumu Ayaka
23b54494cb Merge branch 'main' into rizumu/perf/layer-compositing-performance
# Conflicts:
#	browser_tests/tests/vueNodes/interactions/node/imagePreview.spec.ts-snapshots/vue-node-multiple-promoted-previews-chromium-linux.png
2026-04-22 18:10:14 +08:00
Rizumu Ayaka
e4d9b1c214 perf: differential content bounds update via onChange
Instead of iterating all nodes on every version bump, subscribe to
layoutStore.onChange and collect changed node IDs between RAF frames.
Only the changed nodes are checked against the current bounds (O(k)
instead of O(n)). Full scans are reserved for create/delete events
(workflow load, node add/remove).
2026-04-15 20:23:00 +08:00
Rizumu Ayaka
7e5143d2f1 refactor: move version tracking into useContentBounds
Move updateContentBounds logic (version guard, workflow switch
detection, node iteration) from TransformPane into useContentBounds
as an update() method. TransformPane now calls
contentBounds.update(nodes, version) in the RAF loop.
2026-04-15 17:39:21 +08:00
Rizumu Ayaka
bf606a209e fix: address review feedback on TransformPane
- Remove toBeDefined test that only verified mock factory output
- Add test for content bounds offset with negative-coordinate nodes
- Remove unused expandToIncludePoint method (YAGNI)
- Use reactive props destructuring per project convention
2026-04-15 15:29:15 +08:00
GitHub Action
3159921280 [automated] Apply ESLint and Oxfmt fixes 2026-04-10 11:52:55 +00:00
Rizumu Ayaka
20198f1465 fix: reset content bounds on workflow switch and update stale docs
- Detect workflow switches by sampling a node ID: when the sampled
  node disappears from the map, the entire node set was replaced,
  so reset bounds to prevent unbounded growth across workflows.
- Update @example in useTransformState to reflect direct DOM mutation
  pattern used by TransformPane (replaces deprecated :style binding).
2026-04-10 19:49:53 +08:00
Rizumu Ayaka
b0e6942e92 merge: resolve conflicts with main in TransformPane tests 2026-04-10 19:21:37 +08:00
github-actions
bf906c26c6 [automated] Update test expectations 2026-04-02 06:38:13 +00:00
github-actions
d6aabe7d20 [automated] Update test expectations 2026-04-01 14:31:58 +00:00
Rizumu Ayaka
56b0f6b822 test: add pixel ratio tolerance for image preview screenshot
Sub-pixel rendering differences from TransformPane's two-layer
transform decomposition cause ~1% pixel variance across CI runs.
2026-04-01 22:23:09 +08:00
github-actions
5aa18edeb0 [automated] Update test expectations 2026-04-01 13:42:27 +00:00
Rizumu Ayaka
c9bf31e13b Merge branch 'main' into rizumu/perf/layer-compositing-performance 2026-04-01 20:33:12 +08:00
Rizumu Ayaka
e067c4434d perf: layer compositing performance 2026-03-27 20:43:54 +08:00
7 changed files with 457 additions and 61 deletions

View File

@@ -209,7 +209,8 @@ test.describe('Image widget', { tag: ['@screenshot', '@widget'] }, () => {
// Expect the image preview to change automatically
await expect(comfyPage.canvas).toHaveScreenshot(
'image_preview_drag_and_dropped.png'
'image_preview_drag_and_dropped.png',
{ maxDiffPixelRatio: 0.02 }
)
// Expect the filename combo value to be updated

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -1,26 +1,32 @@
import { fireEvent, render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed, nextTick } from 'vue'
import { computed, nextTick, reactive } from 'vue'
import type { NodeLayout } from '@/renderer/core/layout/types'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
import { createMockCanvas } from '@/utils/__tests__/litegraphTestUtils'
import TransformPane from '../transform/TransformPane.vue'
const mockData = vi.hoisted(() => ({
mockTransformStyle: {
transform: 'scale(1) translate(0px, 0px)',
transformOrigin: '0 0'
},
mockCamera: { x: 0, y: 0, z: 1 }
}))
const mockCamera = reactive({ x: 0, y: 0, z: 1 })
const { mockNodes, mockVersion } = vi.hoisted(() => {
const { ref: createRef } = require('vue')
return {
mockNodes: createRef(new Map()),
mockVersion: createRef(0)
}
})
vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
const syncWithCanvas = vi.fn()
return {
useTransformState: () => ({
camera: computed(() => mockData.mockCamera),
transformStyle: computed(() => mockData.mockTransformStyle),
camera: mockCamera,
transformStyle: computed(() => ({
transform: `scale3d(${mockCamera.z}, ${mockCamera.z}, ${mockCamera.z}) translate3d(${mockCamera.x}px, ${mockCamera.y}px, 0)`,
transformOrigin: '0 0'
})),
canvasToScreen: vi.fn(),
screenToCanvas: vi.fn(),
isNodeInViewport: vi.fn(),
@@ -29,6 +35,15 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
}
})
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getAllNodes: () => computed(() => mockNodes.value),
getVersion: () => computed(() => mockVersion.value),
onChange: vi.fn(() => () => {}),
getNodeLayoutRef: (id: string) => computed(() => mockNodes.value.get(id) ?? null)
}
}))
function createMockLGraphCanvas() {
return createMockCanvas({
canvas: {
@@ -60,11 +75,10 @@ describe('TransformPane', () => {
expect(screen.getByTestId('transform-pane')).toBeInTheDocument()
})
it('should apply transform style from composable', async () => {
mockData.mockTransformStyle = {
transform: 'scale(2) translate(100px, 50px)',
transformOrigin: '0 0'
}
it('should apply camera transform via RAF', async () => {
mockCamera.x = 100
mockCamera.y = 50
mockCamera.z = 2
const mockCanvas = createMockLGraphCanvas()
render(TransformPane, {
@@ -72,15 +86,18 @@ describe('TransformPane', () => {
canvas: mockCanvas
}
})
await nextTick()
vi.advanceTimersToNextFrame()
await nextTick()
const transformPane = screen.getByTestId('transform-pane')
expect(transformPane.getAttribute('style')).toContain(
'transform: scale(2) translate(100px, 50px)'
)
const style = transformPane.getAttribute('style')
expect(style).toContain('scale3d(2, 2, 2)')
expect(style).toContain('translate3d(100px, 50px, 0)')
})
it('should render slot content', () => {
it('should render slot content inside offset wrapper', () => {
const mockCanvas = createMockLGraphCanvas()
render(TransformPane, {
props: {
@@ -169,19 +186,42 @@ describe('TransformPane', () => {
})
})
describe('transform state integration', () => {
it('should provide transform utilities to child components', () => {
describe('content bounds offset', () => {
it('should adjust transform to compensate for negative-coordinate nodes', async () => {
mockCamera.x = 10
mockCamera.y = 20
mockCamera.z = 1
const nodeLayout: NodeLayout = {
id: '1',
position: { x: -500, y: -300 },
size: { width: 200, height: 100 },
zIndex: 0,
visible: true,
bounds: { x: -500, y: -300, width: 200, height: 100 }
}
mockNodes.value = new Map([['1', nodeLayout]])
mockVersion.value = 1
const mockCanvas = createMockLGraphCanvas()
render(TransformPane, {
props: {
canvas: mockCanvas
}
props: { canvas: mockCanvas }
})
const transformState = useTransformState()
expect(transformState.syncWithCanvas).toBeDefined()
expect(transformState.canvasToScreen).toBeDefined()
expect(transformState.screenToCanvas).toBeDefined()
await nextTick()
vi.advanceTimersToNextFrame()
await nextTick()
const pane = screen.getByTestId('transform-pane')
const style = pane.getAttribute('style') ?? ''
// Pane should have explicit dimensions (not 100%)
expect(style).toMatch(/width:\s*\d+px/)
expect(style).toMatch(/height:\s*\d+px/)
// Camera translation should be adjusted by offset (camera - offset)
// so it won't match the raw camera values
expect(style).not.toContain('translate3d(10px, 20px, 0)')
})
})

View File

@@ -2,18 +2,29 @@
<div
ref="transformPaneRef"
data-testid="transform-pane"
class="pointer-events-none absolute inset-0 size-full will-change-auto"
class="pointer-events-none absolute top-0 left-0 will-change-auto"
>
<!-- Vue nodes will be rendered here -->
<slot />
<!--
Offset wrapper: applies a single 2D translate to shift all child
nodes into positive coordinate space. This keeps every node within
the TransformPane's CSS box, so Chrome's compositor merges them
into one compositing layer instead of creating one per node.
The 2D translate does NOT promote this div to its own layer.
-->
<div ref="offsetWrapperRef" class="absolute">
<slot />
</div>
</div>
</template>
<script setup lang="ts">
import { useRafFn } from '@vueuse/core'
import { computed, useTemplateRef, watch } from 'vue'
import { computed, onUnmounted, useTemplateRef, watch } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
import type { LayoutChange } from '@/renderer/core/layout/types'
import { layoutStore } from '@/renderer/core/layout/store/layoutStore'
import { useContentBounds } from '@/renderer/core/layout/transform/useContentBounds'
import { useTransformSettling } from '@/renderer/core/layout/transform/useTransformSettling'
import { useTransformState } from '@/renderer/core/layout/transform/useTransformState'
@@ -21,34 +32,72 @@ interface TransformPaneProps {
canvas?: LGraphCanvas
}
const props = defineProps<TransformPaneProps>()
const { canvas } = defineProps<TransformPaneProps>()
const { transformStyle, syncWithCanvas } = useTransformState()
const { camera, syncWithCanvas } = useTransformState()
const canvasElement = computed(() => props.canvas?.canvas)
const canvasElement = computed(() => canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
settleDelay: 256
})
const transformPaneRef = useTemplateRef('transformPaneRef')
const offsetWrapperRef = useTemplateRef('offsetWrapperRef')
/**
* Apply transform style and will-change class via direct DOM mutation
* instead of reactive template bindings (:style / :class).
*
* These values change every animation frame during zoom or pan.
* If they were bound in the template, Vue would diff the entire
* TransformPane vnode—including all child node slots—on every frame,
* causing expensive vdom patch work across the full node list.
* Mutating the DOM directly limits the update to a single element.
*/
const contentBounds = useContentBounds()
watch([transformStyle, transformPaneRef], ([newStyle, el]) => {
if (el) {
Object.assign(el.style, newStyle)
// Collect changed node IDs between RAF frames for differential updates.
const pendingChangedIds = new Set<string>()
let needsFullScan = true
const unsubscribe = layoutStore.onChange((change: LayoutChange) => {
if (change.type === 'create' || change.type === 'delete') {
needsFullScan = true
} else {
for (const id of change.nodeIds) {
pendingChangedIds.add(id)
}
}
})
watch([isInteracting, transformPaneRef], ([interacting, el]) => {
onUnmounted(unsubscribe)
function getNodeLayout(id: string) {
return layoutStore.getNodeLayoutRef(id).value
}
// --- DOM mutation (avoids Vue vdom diffing on every frame) ---
/**
* Apply transform style, pane size, and offset wrapper transform via
* direct DOM mutation instead of reactive template bindings.
*
* These values change every animation frame during zoom or pan.
* Mutating the DOM directly limits the update to two elements,
* avoiding expensive vdom patch work across the full node list.
*/
function applyStyles() {
const pane = transformPaneRef.value
const wrapper = offsetWrapperRef.value
if (!pane) return
const { x: ox, y: oy } = contentBounds.offset
const { width, height } = contentBounds.size
const z = camera.z
// TransformPane: size covers all offset content; transform compensates
pane.style.width = width > 0 ? `${width}px` : '100%'
pane.style.height = height > 0 ? `${height}px` : '100%'
pane.style.transform = `scale3d(${z}, ${z}, ${z}) translate3d(${camera.x - ox}px, ${camera.y - oy}px, 0)`
pane.style.transformOrigin = '0 0'
// Offset wrapper: shift child nodes into positive coordinate space
if (wrapper) {
wrapper.style.transform = `translate(${ox}px, ${oy}px)`
}
}
watch(isInteracting, (interacting) => {
const el = transformPaneRef.value
if (el) {
el.classList.toggle('will-change-transform', interacting)
el.classList.toggle('will-change-auto', !interacting)
@@ -57,10 +106,19 @@ watch([isInteracting, transformPaneRef], ([interacting, el]) => {
useRafFn(
() => {
if (!props.canvas) {
return
if (!canvas) return
syncWithCanvas(canvas)
if (needsFullScan) {
needsFullScan = false
contentBounds.initialize(layoutStore.getAllNodes().value)
} else if (pendingChangedIds.size > 0) {
contentBounds.updateChanged(pendingChangedIds, getNodeLayout)
}
syncWithCanvas(props.canvas)
pendingChangedIds.clear()
contentBounds.flush()
applyStyles()
},
{ immediate: true }
)

View File

@@ -0,0 +1,164 @@
import { describe, expect, it } from 'vitest'
import { useContentBounds } from '@/renderer/core/layout/transform/useContentBounds'
describe('useContentBounds', () => {
describe('initial state', () => {
it('has zero offset and size', () => {
const { offset, size } = useContentBounds()
expect(offset).toEqual({ x: 0, y: 0 })
expect(size).toEqual({ width: 0, height: 0 })
})
})
describe('expandToInclude', () => {
it('expands to cover bounds in positive space', () => {
const cb = useContentBounds()
cb.expandToInclude({ x: 100, y: 200, width: 300, height: 150 })
cb.flush()
// Offset should be 0 because bounds are already positive
// (min stays 0 since initial min is 0 and bounds.x > 0)
expect(cb.offset.x).toBe(0)
expect(cb.offset.y).toBe(0)
// Size covers from min(0) to max(100+300+margin)
expect(cb.size.width).toBeGreaterThan(400)
expect(cb.size.height).toBeGreaterThan(350)
})
it('generates offset for bounds in negative space', () => {
const cb = useContentBounds()
cb.expandToInclude({ x: -500, y: -300, width: 100, height: 50 })
cb.flush()
// offset.x should shift -500 into positive range (plus margin)
expect(cb.offset.x).toBeGreaterThan(500)
expect(cb.offset.y).toBeGreaterThan(300)
})
it('preserves coordinate identity: offset cancels in transform chain', () => {
const cb = useContentBounds()
cb.expandToInclude({ x: -1000, y: -2000, width: 500, height: 300 })
cb.flush()
// Simulate the transform chain for a node at (-1000, -2000)
const nodeX = -1000
const nodeY = -2000
const camX = 50
const camY = 30
const z = 1.5
// With offset wrapper:
// Node DOM position: nodeX + offset.x, nodeY + offset.y
// TransformPane camera: camX - offset.x, camY - offset.y
// Screen = ((nodeX + offset.x) + (camX - offset.x)) * z
// = (nodeX + camX) * z
const screenX = (nodeX + cb.offset.x + (camX - cb.offset.x)) * z
const screenY = (nodeY + cb.offset.y + (camY - cb.offset.y)) * z
expect(screenX).toBeCloseTo((nodeX + camX) * z)
expect(screenY).toBeCloseTo((nodeY + camY) * z)
})
it('uses grow-only strategy', () => {
const cb = useContentBounds()
cb.expandToInclude({ x: -500, y: -500, width: 100, height: 100 })
cb.flush()
const firstOffset = { x: cb.offset.x, y: cb.offset.y }
const firstSize = { width: cb.size.width, height: cb.size.height }
// Expand with bounds inside existing tracked area — no change
cb.expandToInclude({ x: -100, y: -100, width: 50, height: 50 })
cb.flush()
expect(cb.offset.x).toBe(firstOffset.x)
expect(cb.offset.y).toBe(firstOffset.y)
expect(cb.size.width).toBe(firstSize.width)
expect(cb.size.height).toBe(firstSize.height)
// Expand beyond existing area — grows
// First expansion set minX = -500 - margin, so we need x below that
cb.expandToInclude({ x: -5000, y: 0, width: 100, height: 100 })
cb.flush()
expect(cb.offset.x).toBeGreaterThan(firstOffset.x)
})
})
describe('flush', () => {
it('returns false when nothing changed', () => {
const cb = useContentBounds()
expect(cb.flush()).toBe(false)
})
it('returns true when bounds expanded', () => {
const cb = useContentBounds()
cb.expandToInclude({ x: -100, y: 0, width: 50, height: 50 })
expect(cb.flush()).toBe(true)
})
it('returns false on second flush without new expansions', () => {
const cb = useContentBounds()
cb.expandToInclude({ x: -100, y: 0, width: 50, height: 50 })
cb.flush()
expect(cb.flush()).toBe(false)
})
})
describe('reset', () => {
it('clears offset and size to zero', () => {
const cb = useContentBounds()
cb.expandToInclude({ x: -1000, y: -500, width: 200, height: 100 })
cb.flush()
expect(cb.offset.x).toBeGreaterThan(0)
cb.reset()
expect(cb.offset).toEqual({ x: 0, y: 0 })
expect(cb.size).toEqual({ width: 0, height: 0 })
})
it('allows fresh expansion after reset', () => {
const cb = useContentBounds()
cb.expandToInclude({ x: -5000, y: -5000, width: 100, height: 100 })
cb.flush()
const largeOffset = cb.offset.x
cb.reset()
cb.expandToInclude({ x: -100, y: -100, width: 50, height: 50 })
cb.flush()
expect(cb.offset.x).toBeLessThan(largeOffset)
expect(cb.offset.x).toBeGreaterThan(100)
})
})
describe('all node positions remain positive after offset', () => {
it('handles mixed positive and negative coordinates', () => {
const cb = useContentBounds()
const nodes = [
{ x: -3000, y: -2000, width: 200, height: 100 },
{ x: 500, y: 300, width: 150, height: 80 },
{ x: -100, y: 1000, width: 250, height: 120 },
{ x: 2000, y: -500, width: 180, height: 90 }
]
for (const n of nodes) {
cb.expandToInclude(n)
}
cb.flush()
// Every node's offset position should be non-negative
for (const n of nodes) {
expect(n.x + cb.offset.x).toBeGreaterThanOrEqual(0)
expect(n.y + cb.offset.y).toBeGreaterThanOrEqual(0)
}
// Every node's offset right/bottom edge should be within the size
for (const n of nodes) {
expect(n.x + n.width + cb.offset.x).toBeLessThanOrEqual(cb.size.width)
expect(n.y + n.height + cb.offset.y).toBeLessThanOrEqual(cb.size.height)
}
})
})
})

View File

@@ -0,0 +1,135 @@
import { reactive, readonly } from 'vue'
import type { Bounds, NodeLayout, Point } from '@/renderer/core/layout/types'
/**
* Margin added around tracked content bounds to avoid frequent resizing
* when nodes move slightly beyond the current tracked area.
*/
const EXPAND_MARGIN = 2000
interface ContentBoundsState {
/** Offset to add to node positions so all content lands in positive space */
offset: Readonly<Point>
/** Total size of the content area after offset adjustment */
size: Readonly<{ width: number; height: number }>
}
/**
* Tracks the bounding box of all canvas content and computes an offset
* that shifts all positions into positive coordinate space.
*
* This enables the TransformPane's CSS box to fully contain all child
* nodes, preventing Chrome's compositor from creating individual
* compositing layers for overflowing children.
*
* Uses a grow-only strategy: bounds expand when content moves beyond
* the tracked area but never shrink automatically. Call `reset()` to
* recalculate from scratch (e.g., on workflow load).
*/
export function useContentBounds(): ContentBoundsState & {
expandToInclude(bounds: Bounds): void
initialize(nodes: ReadonlyMap<string, NodeLayout>): void
updateChanged(
changedIds: ReadonlySet<string>,
getLayout: (id: string) => NodeLayout | null
): void
flush(): boolean
reset(): void
} {
const offset = reactive<Point>({ x: 0, y: 0 })
const size = reactive({ width: 0, height: 0 })
let minX = 0
let minY = 0
let maxX = 0
let maxY = 0
let dirty = false
function expandToInclude(bounds: Bounds) {
const bRight = bounds.x + bounds.width
const bBottom = bounds.y + bounds.height
// Only expand when content actually exceeds the tracked area.
// When expanding, add EXPAND_MARGIN as headroom to avoid frequent resizing.
if (bounds.x < minX) {
minX = bounds.x - EXPAND_MARGIN
dirty = true
}
if (bounds.y < minY) {
minY = bounds.y - EXPAND_MARGIN
dirty = true
}
if (bRight > maxX) {
maxX = bRight + EXPAND_MARGIN
dirty = true
}
if (bBottom > maxY) {
maxY = bBottom + EXPAND_MARGIN
dirty = true
}
}
/**
* Full scan of all nodes. Used on workflow load or when the entire
* node set is replaced.
*/
function initialize(nodes: ReadonlyMap<string, NodeLayout>) {
reset()
for (const [, layout] of nodes) {
expandToInclude(layout.bounds)
}
}
/**
* Differential update: only process nodes that actually changed.
* O(k) where k is the number of changed nodes, instead of O(n).
*/
function updateChanged(
changedIds: ReadonlySet<string>,
getLayout: (id: string) => NodeLayout | null
) {
for (const id of changedIds) {
const layout = getLayout(id)
if (layout) {
expandToInclude(layout.bounds)
}
}
}
/**
* Applies pending bound changes to the reactive offset and size.
* Returns true if the values actually changed.
*/
function flush(): boolean {
if (!dirty) return false
dirty = false
offset.x = -minX || 0
offset.y = -minY || 0
size.width = maxX - minX || 0
size.height = maxY - minY || 0
return true
}
function reset() {
minX = 0
minY = 0
maxX = 0
maxY = 0
dirty = false
offset.x = 0
offset.y = 0
size.width = 0
size.height = 0
}
return {
offset: readonly(offset),
size: readonly(size),
expandToInclude,
initialize,
updateChanged,
flush,
reset
}
}

View File

@@ -35,15 +35,13 @@
*
* @example
* ```typescript
* const { camera, transformStyle, canvasToScreen } = useTransformState()
* const { camera, syncWithCanvas, canvasToScreen } = useTransformState()
*
* // In template
* <div :style="transformStyle">
* <NodeComponent
* v-for="node in nodes"
* :style="{ left: node.x + 'px', top: node.y + 'px' }"
* />
* </div>
* // Sync camera from LiteGraph each frame (via RAF)
* syncWithCanvas(canvas)
*
* // Apply transform via direct DOM mutation for performance
* el.style.transform = `scale3d(${camera.z}, ${camera.z}, ${camera.z}) translate3d(${camera.x}px, ${camera.y}px, 0)`
*
* // Convert coordinates
* const screenPos = canvasToScreen({ x: nodeX, y: nodeY })