Compare commits

...

9 Commits

Author SHA1 Message Date
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
8 changed files with 399 additions and 46 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 88 KiB

After

Width:  |  Height:  |  Size: 88 KiB

View File

@@ -213,7 +213,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,23 @@
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 { 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 })
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 +26,14 @@ vi.mock('@/renderer/core/layout/transform/useTransformState', () => {
}
})
vi.mock('@/renderer/core/layout/store/layoutStore', () => ({
layoutStore: {
getAllNodes: () => computed(() => new Map()),
getVersion: () => computed(() => 0),
onChange: vi.fn(() => () => {})
}
}))
function createMockLGraphCanvas() {
return createMockCanvas({
canvas: {
@@ -60,11 +65,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 +76,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: {

View File

@@ -2,10 +2,18 @@
<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>
@@ -14,6 +22,8 @@ import { useRafFn } from '@vueuse/core'
import { computed, useTemplateRef, watch } from 'vue'
import type { LGraphCanvas } from '@/lib/litegraph/src/litegraph'
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'
@@ -23,7 +33,7 @@ interface TransformPaneProps {
const props = defineProps<TransformPaneProps>()
const { transformStyle, syncWithCanvas } = useTransformState()
const { camera, syncWithCanvas } = useTransformState()
const canvasElement = computed(() => props.canvas?.canvas)
const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
@@ -31,24 +41,75 @@ const { isTransforming: isInteracting } = useTransformSettling(canvasElement, {
})
const transformPaneRef = useTemplateRef('transformPaneRef')
const offsetWrapperRef = useTemplateRef('offsetWrapperRef')
const contentBounds = useContentBounds()
const allNodes = layoutStore.getAllNodes()
const storeVersion = layoutStore.getVersion()
let lastTrackedVersion = -1
let sampleNodeId: string | null = null
/**
* Apply transform style and will-change class via direct DOM mutation
* instead of reactive template bindings (:style / :class).
* When the layout store version changes, expand the tracked content
* bounds to include any nodes that moved beyond the current area.
*
* Detects workflow switches by checking whether a previously tracked
* node still exists. When the entire node set is replaced (e.g. on
* workflow load), resets bounds so they don't accumulate across
* unrelated workflows.
*/
function updateContentBounds() {
const currentVersion = storeVersion.value
if (currentVersion === lastTrackedVersion) return
lastTrackedVersion = currentVersion
const nodes = allNodes.value
// Detect workflow switch: if the sampled node is gone, the node set
// was replaced wholesale — reset bounds to avoid unbounded growth.
if (sampleNodeId !== null && nodes.size > 0 && !nodes.has(sampleNodeId)) {
contentBounds.reset()
}
sampleNodeId = nodes.size > 0 ? (nodes.keys().next().value ?? null) : null
for (const [, layout] of nodes) {
contentBounds.expandToInclude(layout.bounds)
}
}
// --- 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.
* 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.
* 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
watch([transformStyle, transformPaneRef], ([newStyle, el]) => {
if (el) {
Object.assign(el.style, newStyle)
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, transformPaneRef], ([interacting, el]) => {
}
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 +118,11 @@ watch([isInteracting, transformPaneRef], ([interacting, el]) => {
useRafFn(
() => {
if (!props.canvas) {
return
}
if (!props.canvas) return
syncWithCanvas(props.canvas)
updateContentBounds()
contentBounds.flush()
applyStyles()
},
{ immediate: true }
)

View File

@@ -0,0 +1,178 @@
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('expandToIncludePoint', () => {
it('expands to include a single point', () => {
const cb = useContentBounds()
cb.expandToIncludePoint({ x: -800, y: 500 })
cb.flush()
expect(cb.offset.x).toBeGreaterThan(800)
// y=500 is positive, doesn't push minY below 0 → offset.y stays 0
// maxY expands to 500 + margin
expect(cb.offset.y).toBe(0)
expect(cb.size.height).toBeGreaterThan(500)
})
})
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,107 @@
import { reactive, readonly } from 'vue'
import type { Bounds, 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
expandToIncludePoint(point: Point): 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
}
}
function expandToIncludePoint(point: Point) {
expandToInclude({ x: point.x, y: point.y, width: 0, height: 0 })
}
/**
* 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,
expandToIncludePoint,
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 })