Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions src/extensions/core/imageCompositor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import {
clearCompositorLayers,
getCompositorBBoxes,
getCompositorCanvas,
getCompositorInputsFingerprint,
getCompositorLayers
} from '@/renderer/extensions/compositor/composables/useCompositorLayers'
Expand Down Expand Up @@ -110,6 +111,29 @@ describe('ImageCompositor extension', () => {
expect(getCompositorBBoxes(node)).toBeUndefined()
})

it('caches the document canvas reported by the backend', () => {
const { node } = createdNode()

node.onExecuted?.({
compositor_layers: [{ filename: 'a.png' }],
compositor_inputs: ['hash-a'],
compositor_canvas: [{ w: 1280, h: 1280 }]
})

expect(getCompositorCanvas(node)).toEqual({ w: 1280, h: 1280 })
})

it('leaves the canvas cache empty when the output has none', () => {
const { node } = createdNode()

node.onExecuted?.({
compositor_layers: [{ filename: 'a.png' }],
compositor_inputs: ['hash-a']
})

expect(getCompositorCanvas(node)).toBeUndefined()
})

it('resets the compositor widget when the state is stale', () => {
const { node, compositorWidget } = createdNode()

Expand Down
9 changes: 8 additions & 1 deletion src/extensions/core/imageCompositor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ type ImageCompositorOutput = NodeOutputWith<{
compositor_layers?: Record<string, string>[]
compositor_inputs?: string[]
compositor_bboxes?: (CompositorBBox | null)[]
compositor_canvas?: { w: number; h: number }[]
compositor_state_stale?: boolean[]
}>

Expand Down Expand Up @@ -44,7 +45,13 @@ useExtensionService().registerExtension({
? kept.map(([, index]) => rawBboxes[index] ?? null)
: undefined
if (layers.length)
setCompositorLayers(node, layers, output.compositor_inputs, bboxes)
setCompositorLayers(
node,
layers,
output.compositor_inputs,
bboxes,
output.compositor_canvas?.[0]
)
clearCompositorPreviewOverride(node)

if (output.compositor_state_stale?.[0]) {
Expand Down
1 change: 1 addition & 0 deletions src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -1291,6 +1291,7 @@
"title": "Compositor",
"webglUnavailable": "WebGL is unavailable - the canvas cannot render",
"loadFailed": "Failed to load layers",
"needsTwoImages": "The layer editor needs at least two output images",
"layersFailedToLoad": "One layer failed to load | {count} layers failed to load",
"selectTool": "Select",
"handTool": "Hand",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { render, screen } from '@testing-library/vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'

import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import { toNodeId } from '@/types/nodeId'

import {
clearCompositorLayers,
setCompositorLayers
} from '../composables/useCompositorLayers'
import WidgetCompositor from './WidgetCompositor.vue'

const { getNodeById } = vi.hoisted(() => ({
getNodeById: vi.fn<() => unknown>(() => undefined)
}))

vi.mock('@/scripts/app', () => ({
app: { canvas: { graph: { getNodeById } } }
}))
vi.mock('@/stores/nodeOutputStore', () => ({
useNodeOutputStore: () => ({
getNodeImageUrls: () => undefined,
nodeOutputs: {},
nodePreviewImages: {}
})
}))
vi.mock(
'@/renderer/extensions/compositor/composables/useCompositorEditor',
() => ({
useCompositorEditor: () => ({ openCompositorEditor: vi.fn() })
})
)
vi.mock(
'@/renderer/extensions/compositor/composables/useCompositorPsdDownload',
() => ({
useCompositorPsdDownload: () => ({
exporting: ref(false),
downloadPsd: vi.fn()
})
})
)
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
compositor: {
empty: 'Run the workflow to generate a composite',
open: 'Open Compositor',
runWorkflowFirst: 'Run the workflow once to load input images',
downloadPsd: 'Download PSD'
}
}
}
})

const nodeId = toNodeId(9)
const graphNode = { id: nodeId, graph: null } as unknown as LGraphNode

function renderWidget() {
return render(WidgetCompositor, {
props: { nodeId },
global: {
plugins: [i18n],
stubs: {
Button: { template: '<button v-bind="$attrs"><slot /></button>' }
}
}
})
}

describe('WidgetCompositor', () => {
beforeEach(() => {
vi.clearAllMocks()

Check warning on line 76 in src/renderer/extensions/compositor/components/WidgetCompositor.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

comfy(no-redundant-vitest-cleanup)

src/renderer/extensions/compositor/components/WidgetCompositor.test.ts:76:5: vi.clearAllMocks() is redundant in a Vitest hook because Vitest performs this cleanup automatically.

Check warning on line 76 in src/renderer/extensions/compositor/components/WidgetCompositor.test.ts

View workflow job for this annotation

GitHub Actions / lint-and-format

comfy(no-redundant-vitest-cleanup)

src/renderer/extensions/compositor/components/WidgetCompositor.test.ts:76:5: vi.clearAllMocks() is redundant in a Vitest hook because Vitest performs this cleanup automatically.
clearCompositorLayers(graphNode)
getNodeById.mockReturnValue(undefined)
})

it('renders the empty state when the node is not in the graph (search preview)', () => {
renderWidget()

expect(screen.getByTestId('compositor-empty').textContent).toContain(
'Run the workflow to generate a composite'
)
const open = screen.getByTestId('compositor-open-button')
expect(open.textContent).toContain('Open Compositor')
Comment thread
jtydhr88 marked this conversation as resolved.
expect(open.hasAttribute('disabled')).toBe(true)
})

it('enables opening once the graph node exists with cached layers', () => {
getNodeById.mockReturnValue(graphNode)
setCompositorLayers(graphNode, [
{ filename: 'a.png', subfolder: '', type: 'temp' }
])

renderWidget()

const open = screen.getByTestId('compositor-open-button')
expect(open.hasAttribute('disabled')).toBe(false)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const outputUrl = ref<string | null>(null)

const litegraphNode = computed(() => {
if (!nodeId || !app.canvas.graph) return null
return app.canvas.graph.getNodeById(nodeId)
return app.canvas.graph.getNodeById(nodeId) ?? null
})

function updateOutputUrl(): void {
Expand Down Expand Up @@ -125,7 +125,7 @@ const dimensionsLabel = computed(() =>

const canOpen = computed(() => {
const node = litegraphNode.value
return node !== null && hasCompositorLayers(node)
return !!node && hasCompositorLayers(node)
})

function onPreviewLoad(event: Event): void {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,27 @@ describe('resolveInitialLayerState', () => {
})
})

it('carries the document canvas into the synthesized bbox layout', () => {
const resolved = resolveInitialLayerState(null, ['hash-a'], bboxes, {
w: 1280,
h: 1280
})
expect(resolved?.canvas).toEqual({ w: 1280, h: 1280 })
})

it('omits the canvas when the backend reports none', () => {
const resolved = resolveInitialLayerState(null, ['hash-a'], bboxes)
expect(resolved?.canvas).toBeUndefined()
})

it('ignores a malformed canvas', () => {
const resolved = resolveInitialLayerState(null, ['hash-a'], bboxes, {
w: Number.NaN,
h: 1280
} as { w: number; h: number })
expect(resolved?.canvas).toBeUndefined()
})

it('returns null without a saved state or usable bboxes', () => {
expect(resolveInitialLayerState(null, undefined, undefined)).toBeNull()
expect(resolveInitialLayerState(null, undefined, [])).toBeNull()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -282,10 +282,13 @@ export function layerStateInputsMatch(
}

function bboxLayerState(
bboxes: ReadonlyArray<CompositorBBox | null> | undefined
bboxes: ReadonlyArray<CompositorBBox | null> | undefined,
canvas?: { w: number; h: number }
): CompositorLayerStateInit | null {
if (!bboxes?.some((bbox) => bbox !== null)) return null
const canvasSize = parseCanvasSize(canvas)
return {
...(canvasSize ? { canvas: canvasSize } : {}),
layers: bboxes.map((bbox) =>
bbox
? {
Expand Down Expand Up @@ -315,11 +318,12 @@ function bboxLayerState(
export function resolveInitialLayerState(
savedState: CompositorLayerState | null,
currentInputs: readonly string[] | undefined,
bboxes: ReadonlyArray<CompositorBBox | null> | undefined
bboxes: ReadonlyArray<CompositorBBox | null> | undefined,
canvas?: { w: number; h: number }
): CompositorLayerStateInit | null {
if (savedState && layerStateInputsMatch(savedState.inputs, currentInputs))
return savedState
return bboxLayerState(bboxes)
return bboxLayerState(bboxes, canvas)
}

export function applyLayerState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@ import { toNodeId } from '@/types/nodeId'

import { loadCompositorSession } from './compositorSession'

const { applyLayerState, getCompositorLayers, resolveInitialLayerState } =
vi.hoisted(() => ({
applyLayerState: vi.fn(),
getCompositorLayers: vi.fn<() => unknown>(() => []),
resolveInitialLayerState: vi.fn<() => unknown>(() => null)
}))
const {
applyLayerState,
getCompositorCanvas,
getCompositorLayers,
resolveInitialLayerState
} = vi.hoisted(() => ({
applyLayerState: vi.fn(),
getCompositorCanvas: vi.fn<() => unknown>(() => undefined),
getCompositorLayers: vi.fn<() => unknown>(() => []),
resolveInitialLayerState: vi.fn<() => unknown>(() => null)
}))

vi.mock(
'@/renderer/extensions/compositor/composables/compositorLayerState',
Expand All @@ -25,6 +30,7 @@ vi.mock(
'@/renderer/extensions/compositor/composables/useCompositorLayers',
() => ({
getCompositorBBoxes: () => undefined,
getCompositorCanvas,
getCompositorInputsFingerprint: () => undefined,
getCompositorLayers
})
Expand All @@ -47,7 +53,8 @@ function makeSession() {
loadImages: vi.fn().mockResolvedValue(0),
imageLayers: { value: [{ id: 'a', visible: true }] },
editor: { history: { clear: vi.fn() } },
fitView: vi.fn()
fitView: vi.fn(),
setCanvasSize: vi.fn()
}
}

Expand All @@ -57,6 +64,7 @@ const fallbackName = (i: number) => `Layer ${i + 1}`
describe('loadCompositorSession', () => {
beforeEach(() => {
getCompositorLayers.mockReturnValue([])
getCompositorCanvas.mockReturnValue(undefined)
resolveInitialLayerState.mockReturnValue(null)
})

Expand Down Expand Up @@ -113,5 +121,37 @@ describe('loadCompositorSession', () => {

expect(applyLayerState).not.toHaveBeenCalled()
expect(session.editor.history.clear).not.toHaveBeenCalled()
expect(session.setCanvasSize).not.toHaveBeenCalled()
})

it('sizes the canvas from the backend when there is no per-layer state', async () => {
getCompositorCanvas.mockReturnValue({ w: 1280, h: 1280 })
const session = makeSession()

await loadCompositorSession(
session as unknown as LayerEditorSession,
node,
fallbackName
)

expect(applyLayerState).not.toHaveBeenCalled()
expect(session.setCanvasSize).toHaveBeenCalledWith(1280, 1280)
expect(session.editor.history.clear).toHaveBeenCalled()
expect(session.fitView).toHaveBeenCalled()
})

it('lets the resolved initial state own the canvas over the raw fallback', async () => {
getCompositorCanvas.mockReturnValue({ w: 1280, h: 1280 })
resolveInitialLayerState.mockReturnValue({ layers: [] })
const session = makeSession()

await loadCompositorSession(
session as unknown as LayerEditorSession,
node,
fallbackName
)

expect(applyLayerState).toHaveBeenCalled()
expect(session.setCanvasSize).not.toHaveBeenCalled()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { imageRefViewQuery } from '@/renderer/extensions/compositor/composables/
import { getCompositorWidgetValue } from '@/renderer/extensions/compositor/composables/compositorWidgets'
import {
getCompositorBBoxes,
getCompositorCanvas,
getCompositorInputsFingerprint,
getCompositorLayers
} from '@/renderer/extensions/compositor/composables/useCompositorLayers'
Expand All @@ -31,15 +32,21 @@ export async function loadCompositorSession(
)
const failed = await session.loadImages(urls, names)

const canvas = getCompositorCanvas(node)
const initialState = resolveInitialLayerState(
parseLayerState(getCompositorWidgetValue(node)),
getCompositorInputsFingerprint(node),
getCompositorBBoxes(node)
getCompositorBBoxes(node),
canvas
)
if (initialState) {
applyLayerState(initialState, session.imageLayers.value, session)
session.editor.history.clear()
session.fitView()
} else if (canvas) {
session.setCanvasSize(canvas.w, canvas.h)
session.editor.history.clear()
session.fitView()
}
return failed
}
Loading
Loading