mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-05-26 07:57:36 +00:00
Compare commits
11 Commits
glary/mar-
...
glary/widg
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68fdfd5e35 | ||
|
|
2717d59451 | ||
|
|
d63b0f05bf | ||
|
|
cd2f4677c2 | ||
|
|
38fed22140 | ||
|
|
a95e53bf6d | ||
|
|
246b79dda9 | ||
|
|
5841c252ce | ||
|
|
4d4ad6ed92 | ||
|
|
86b6cab5e9 | ||
|
|
0aefef7c42 |
@@ -41,6 +41,10 @@ ALGOLIA_API_KEY=684d998c36b67a9a9fce8fc2d8860579
|
||||
# Enable PostHog debug logging in the browser console.
|
||||
# VITE_POSTHOG_DEBUG=true
|
||||
|
||||
# Override staging comfy-api / comfy-platform base URLs.
|
||||
# VITE_STAGING_API_BASE_URL=https://stagingapi.comfy.org
|
||||
# VITE_STAGING_PLATFORM_BASE_URL=https://stagingplatform.comfy.org
|
||||
|
||||
# Sentry ENV vars replace with real ones for debugging
|
||||
# SENTRY_AUTH_TOKEN=private-token # get from sentry
|
||||
# SENTRY_ORG=comfy-org
|
||||
|
||||
31
browser_tests/assets/widgets/combo_control_widget.json
Normal file
31
browser_tests/assets/widgets/combo_control_widget.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"last_node_id": 1,
|
||||
"last_link_id": 0,
|
||||
"nodes": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "DevToolsNodeWithComboControlWidget",
|
||||
"pos": [20, 50],
|
||||
"size": [400, 200],
|
||||
"flags": {},
|
||||
"order": 0,
|
||||
"mode": 0,
|
||||
"inputs": [],
|
||||
"outputs": [],
|
||||
"properties": {
|
||||
"Node name for S&R": "DevToolsNodeWithComboControlWidget"
|
||||
},
|
||||
"widgets_values": ["Option A", "fixed", ""]
|
||||
}
|
||||
],
|
||||
"links": [],
|
||||
"groups": [],
|
||||
"config": {},
|
||||
"extra": {
|
||||
"ds": {
|
||||
"scale": 1,
|
||||
"offset": [0, 0]
|
||||
}
|
||||
},
|
||||
"version": 0.4
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import type { ComfyWorkflowJSON } from '@/platform/workflow/validation/schemas/w
|
||||
import type { ComfyPage } from '@e2e/fixtures/ComfyPage'
|
||||
import { SubgraphEditor } from '@e2e/fixtures/components/SubgraphEditor'
|
||||
import { TestIds } from '@e2e/fixtures/selectors'
|
||||
import type { Position, Size } from '@e2e/fixtures/types'
|
||||
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
import { SubgraphSlotReference } from '@e2e/fixtures/utils/litegraphUtils'
|
||||
|
||||
@@ -241,6 +242,17 @@ export class SubgraphHelper {
|
||||
return new SubgraphSlotReference('output', slotName || '', this.comfyPage)
|
||||
}
|
||||
|
||||
async getInputBounds(): Promise<Position & Size> {
|
||||
return await this.comfyPage.page.evaluate(() => {
|
||||
const graph = app!.canvas.graph as Subgraph
|
||||
const inputNode = graph.inputNode
|
||||
const [x, y] = app!.canvas.ds.convertOffsetToCanvas(inputNode.pos)
|
||||
const width = inputNode.size[0] * app!.canvas.ds.scale
|
||||
const height = inputNode.size[1] * app!.canvas.ds.scale
|
||||
return { x, y, width, height }
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Connect a regular node output to a subgraph input.
|
||||
* This creates a new input slot on the subgraph if targetInputName is not provided.
|
||||
@@ -486,6 +498,25 @@ export class SubgraphHelper {
|
||||
await this.comfyPage.contextMenu.waitForHidden()
|
||||
}
|
||||
|
||||
async getInnerControlWidgetLabels(): Promise<string[]> {
|
||||
return this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
const subgraphNode = graph.nodes.find(
|
||||
(n: { isSubgraphNode?: () => boolean }) =>
|
||||
typeof n.isSubgraphNode === 'function' && n.isSubgraphNode()
|
||||
) as { subgraph?: Subgraph } | undefined
|
||||
if (!subgraphNode?.subgraph) return []
|
||||
const innerNodes = Array.from(subgraphNode.subgraph.nodes.values())
|
||||
return innerNodes.flatMap((n: { widgets?: Array<{ label?: string }> }) =>
|
||||
(n.widgets ?? [])
|
||||
.filter((w: { label?: string }) =>
|
||||
(w.label ?? '').includes('control')
|
||||
)
|
||||
.map((w: { label?: string }) => w.label!)
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
async findSubgraphNodeId(): Promise<string> {
|
||||
const id = await this.page.evaluate(() => {
|
||||
const graph = window.app!.canvas.graph!
|
||||
|
||||
@@ -361,3 +361,15 @@ test.describe('Group Node', { tag: '@node' }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('groupnodes/legacy_group_node')
|
||||
await (await comfyPage.vueNodes.getFixtureByTitle('hello')).title.click()
|
||||
await comfyPage.page.keyboard.press('Control+Shift+e')
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('New Subgraph')).toBeVisible()
|
||||
|
||||
await comfyPage.vueNodes.enterSubgraph()
|
||||
await expect(comfyPage.vueNodes.getNodeByTitle('')).toHaveCount(2)
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 55 KiB |
251
browser_tests/tests/numberControlWidget.spec.ts
Normal file
251
browser_tests/tests/numberControlWidget.spec.ts
Normal file
@@ -0,0 +1,251 @@
|
||||
import { expect } from '@playwright/test'
|
||||
|
||||
import { comfyPageFixture as test } from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Can drag adjust value', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/seed_widget')
|
||||
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
const widget = await node.getWidget(0)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.widgetValue = undefined
|
||||
const widget = window.app!.graph!.nodes[0].widgets![0]
|
||||
widget.callback = (value: number) => {
|
||||
window.widgetValue = value
|
||||
}
|
||||
})
|
||||
await widget.dragHorizontal(50)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
|
||||
.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('WidgetControlMode setting', { tag: '@widget' }, () => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
})
|
||||
|
||||
test('Changing mode to "before" updates control widget labels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
const ksampler = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node?.widgets
|
||||
?.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label)
|
||||
}, ksampler.id)
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node?.widgets
|
||||
?.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label)
|
||||
}, ksampler.id)
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
|
||||
})
|
||||
|
||||
test('Changing mode back to "after" restores labels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
const ksampler = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node?.widgets
|
||||
?.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label)
|
||||
}, ksampler.id)
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
|
||||
})
|
||||
|
||||
test('Mode change updates control widgets across multiple nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('KSampler')
|
||||
node!.pos = [400, 30]
|
||||
window.app!.graph!.add(node!)
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const ksamplers = window.app!.graph!.nodes.filter(
|
||||
(n) => n.type === 'KSampler'
|
||||
)
|
||||
return (
|
||||
ksamplers.length === 2 &&
|
||||
ksamplers.every((n) => {
|
||||
const controlLabels = (n.widgets ?? [])
|
||||
.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label ?? '')
|
||||
return (
|
||||
controlLabels.length > 0 &&
|
||||
controlLabels.every((label) => label.includes('before'))
|
||||
)
|
||||
})
|
||||
)
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Nodes without widgets are skipped without error', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.LiteGraph!.createNode('Reroute')
|
||||
if (node) {
|
||||
node.pos = [400, 30]
|
||||
window.app!.graph!.add(node)
|
||||
}
|
||||
})
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
const ksampler = (await comfyPage.nodeOps.getNodeRefsByType('KSampler'))[0]
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate((id) => {
|
||||
const node = window.app!.graph!.getNodeById(id)
|
||||
return node?.widgets
|
||||
?.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label)
|
||||
}, ksampler.id)
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
|
||||
})
|
||||
|
||||
test('Canvas is marked dirty after mode change', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const w = window as Window & { __canvasDirtied?: boolean }
|
||||
w.__canvasDirtied = false
|
||||
const origSetDirty = window.app!.canvas.setDirty.bind(window.app!.canvas)
|
||||
window.app!.canvas.setDirty = (
|
||||
...args: Parameters<typeof origSetDirty>
|
||||
) => {
|
||||
w.__canvasDirtied = true
|
||||
return origSetDirty(...args)
|
||||
}
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(
|
||||
() =>
|
||||
(window as Window & { __canvasDirtied?: boolean }).__canvasDirtied
|
||||
)
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test('Mode change updates combo control widget labels', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
await comfyPage.workflow.loadWorkflow('widgets/combo_control_widget')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
return (node?.widgets ?? [])
|
||||
.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label!)
|
||||
})
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
return (node?.widgets ?? [])
|
||||
.filter((w) => (w.label ?? '').includes('control'))
|
||||
.map((w) => w.label!)
|
||||
})
|
||||
)
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
|
||||
})
|
||||
|
||||
test('Mode change propagates to linkedWidgets on control widgets', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
// linkedWidgets is only set on main widgets, never on control widgets
|
||||
// themselves. This covers the defensive code path (GraphCanvas.vue:360-362).
|
||||
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')
|
||||
|
||||
await comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
if (!node?.widgets) return
|
||||
const controlWidget = node.widgets.find((w) =>
|
||||
(w.label ?? '').includes('control')
|
||||
)
|
||||
if (!controlWidget) return
|
||||
const mockLinked = Object.create(null)
|
||||
mockLinked.name = 'mock_filter'
|
||||
mockLinked.label = 'control after generate'
|
||||
mockLinked.type = 'string'
|
||||
mockLinked.value = ''
|
||||
controlWidget.linkedWidgets = [mockLinked]
|
||||
})
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() =>
|
||||
comfyPage.page.evaluate(() => {
|
||||
const node = window.app!.graph!.nodes[0]
|
||||
const controlWidget = node?.widgets?.find((w) =>
|
||||
(w.label ?? '').includes('control')
|
||||
)
|
||||
const linked = controlWidget?.linkedWidgets ?? []
|
||||
return [controlWidget?.label, ...linked.map((l) => l.label ?? '')]
|
||||
})
|
||||
)
|
||||
.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.stringContaining('before'),
|
||||
expect.stringContaining('before')
|
||||
])
|
||||
)
|
||||
})
|
||||
})
|
||||
41
browser_tests/tests/priceBadge.spec.ts
Normal file
41
browser_tests/tests/priceBadge.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
comfyPageFixture as test,
|
||||
comfyExpect as expect
|
||||
} from '@e2e/fixtures/ComfyPage'
|
||||
|
||||
test('Price badge displays on subgraphs @vue-nodes', async ({ comfyPage }) => {
|
||||
const apiNodeName = 'Node With Price Badge'
|
||||
await comfyPage.settings.setSetting('Comfy.NodeSearchBoxImpl', 'v1 (legacy)')
|
||||
|
||||
const priceBadge = comfyPage.page.locator('.lg-node-header i + span')
|
||||
const apiNode = comfyPage.vueNodes.getNodeByTitle(apiNodeName)
|
||||
|
||||
await comfyPage.menu.topbar.newWorkflowButton.click()
|
||||
await comfyPage.nextFrame()
|
||||
|
||||
await comfyPage.page.mouse.dblclick(500, 500, { delay: 5 })
|
||||
await comfyPage.searchBox.fillAndSelectFirstNode(apiNodeName)
|
||||
await expect(comfyPage.searchBox.input).toBeHidden()
|
||||
await expect(apiNode, 'Add partner node').toBeVisible()
|
||||
await expect(apiNode.locator(priceBadge), 'Has price badge').toBeVisible()
|
||||
|
||||
await comfyPage.contextMenu
|
||||
.openForVueNode(apiNode)
|
||||
.then((m) => m.clickMenuItemExact('Convert to Subgraph'))
|
||||
const subgraphNode = comfyPage.vueNodes.getNodeByTitle('New Subgraph')
|
||||
await expect(subgraphNode, 'Convert to Subgraph').toBeVisible()
|
||||
|
||||
const nodePrice = subgraphNode.locator(priceBadge)
|
||||
await expect(nodePrice, 'subgraphNode has price badge').toBeVisible()
|
||||
const initialPrice = Number(await nodePrice.innerText())
|
||||
|
||||
await comfyPage.subgraph.editor.togglePromotion(subgraphNode, {
|
||||
nodeName: apiNodeName,
|
||||
widgetName: 'price',
|
||||
toState: true
|
||||
})
|
||||
await comfyPage.vueNodes.selectComboOption('New Subgraph', 'price', '2x')
|
||||
await expect(nodePrice, 'Price is reactive').toHaveText(
|
||||
String(initialPrice * 2)
|
||||
)
|
||||
})
|
||||
@@ -608,6 +608,33 @@ test.describe(
|
||||
}
|
||||
)
|
||||
|
||||
test.describe(
|
||||
'WidgetControlMode in subgraphs',
|
||||
{ tag: ['@subgraph', '@widget'] },
|
||||
() => {
|
||||
test.afterEach(async ({ comfyPage }) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
})
|
||||
|
||||
test('Mode change updates control widget labels inside subgraph nodes', async ({
|
||||
comfyPage
|
||||
}) => {
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'after')
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getInnerControlWidgetLabels())
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('after')]))
|
||||
|
||||
await comfyPage.settings.setSetting('Comfy.WidgetControlMode', 'before')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.subgraph.getInnerControlWidgetLabels())
|
||||
.toEqual(expect.arrayContaining([expect.stringContaining('before')]))
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
test('Promote/Demote by Context Menu @vue-nodes', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
const ksampler = comfyPage.vueNodes.getNodeLocator('1')
|
||||
|
||||
@@ -632,3 +632,72 @@ test.describe('Subgraph Slots', { tag: ['@slow', '@subgraph'] }, () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test(
|
||||
'link interactions',
|
||||
{ tag: ['@vue-nodes', '@subgraph'] },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
await comfyPage.vueNodes.enterSubgraph('2')
|
||||
|
||||
const ksampler = await comfyPage.vueNodes.getFixtureByTitle('KSampler')
|
||||
const seedSlot = ksampler.getSlot('seed')
|
||||
const seedIOSlot = await comfyPage.subgraph.getInputSlot('seed')
|
||||
|
||||
await test.step('Make second INT typed connection', async () => {
|
||||
const toPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await seedSlot.dragTo(comfyPage.canvas, { targetPosition: toPos })
|
||||
const isConnected = () => comfyPage.vueNodes.isSlotConnected(seedSlot)
|
||||
await expect.poll(isConnected).toBe(true)
|
||||
})
|
||||
|
||||
const stepsSlot = ksampler.getSlot('steps')
|
||||
|
||||
await test.step('Node -> I/O hover effect', async () => {
|
||||
await stepsSlot.hover()
|
||||
await stepsSlot.click({ trial: true })
|
||||
await comfyPage.page.mouse.down()
|
||||
await comfyPage.canvas.hover({ position: await seedIOSlot.getPosition() })
|
||||
|
||||
const rawClip = await comfyPage.subgraph.getInputBounds()
|
||||
const absolutePos = await comfyPage.canvasOps.toAbsolute(rawClip)
|
||||
const clip = { ...rawClip, ...absolutePos }
|
||||
await expect(comfyPage.page).toHaveScreenshot('vue-io-highlight.png', {
|
||||
clip
|
||||
})
|
||||
|
||||
//cancel link operation
|
||||
await stepsSlot.hover()
|
||||
await comfyPage.page.mouse.up()
|
||||
})
|
||||
|
||||
await ksampler.title.hover()
|
||||
|
||||
const slotParent = stepsSlot.locator('../..')
|
||||
await expect(slotParent, 'unconnected slot is hidden').toHaveCSS(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
|
||||
await test.step('Connect I/O to node with snap', async () => {
|
||||
const hasSnap = () =>
|
||||
comfyPage.page.evaluate(() => !!app!.canvas._highlight_pos)
|
||||
expect(await hasSnap()).toBe(false)
|
||||
|
||||
const emptySlotPos = await seedIOSlot.getOpenSlotPosition()
|
||||
await comfyPage.canvas.hover({ position: emptySlotPos })
|
||||
await comfyPage.page.mouse.down()
|
||||
await stepsSlot.hover()
|
||||
await expect.poll(hasSnap).toBe(true)
|
||||
await comfyPage.page.mouse.up()
|
||||
|
||||
//move hover off the slot
|
||||
await ksampler.title.hover()
|
||||
})
|
||||
|
||||
await expect(slotParent, 'connected slot is visible').not.toHaveCSS(
|
||||
'opacity',
|
||||
'0'
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.0 KiB |
@@ -137,28 +137,6 @@ test.describe('Slider widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Number widget', { tag: ['@screenshot', '@widget'] }, () => {
|
||||
test('Can drag adjust value', async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('widgets/seed_widget')
|
||||
|
||||
const node = (await comfyPage.nodeOps.getFirstNodeRef())!
|
||||
const widget = await node.getWidget(0)
|
||||
await comfyPage.page.evaluate(() => {
|
||||
window.widgetValue = undefined
|
||||
const widget = window.app!.graph!.nodes[0].widgets![0]
|
||||
widget.callback = (value: number) => {
|
||||
window.widgetValue = value
|
||||
}
|
||||
})
|
||||
await widget.dragHorizontal(50)
|
||||
await expect(comfyPage.canvas).toHaveScreenshot('seed_widget_dragged.png')
|
||||
|
||||
await expect
|
||||
.poll(() => comfyPage.page.evaluate(() => window.widgetValue))
|
||||
.toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
test.describe(
|
||||
'Dynamic widget manipulation',
|
||||
{ tag: ['@screenshot', '@widget'] },
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
"@atlaskit/pragmatic-drag-and-drop": "^1.3.1",
|
||||
"@comfyorg/comfyui-electron-types": "catalog:",
|
||||
"@comfyorg/design-system": "workspace:*",
|
||||
"@comfyorg/fbx-exporter-three": "^1.0.1",
|
||||
"@comfyorg/object-info-parser": "workspace:*",
|
||||
"@comfyorg/registry-types": "workspace:*",
|
||||
"@comfyorg/shared-frontend-utils": "workspace:*",
|
||||
|
||||
45
pnpm-lock.yaml
generated
45
pnpm-lock.yaml
generated
@@ -437,6 +437,9 @@ importers:
|
||||
'@comfyorg/design-system':
|
||||
specifier: workspace:*
|
||||
version: link:packages/design-system
|
||||
'@comfyorg/fbx-exporter-three':
|
||||
specifier: ^1.0.1
|
||||
version: 1.0.1(@types/three@0.170.0)(three@0.170.0)
|
||||
'@comfyorg/object-info-parser':
|
||||
specifier: workspace:*
|
||||
version: link:packages/object-info-parser
|
||||
@@ -1790,6 +1793,16 @@ packages:
|
||||
'@comfyorg/comfyui-electron-types@0.6.2':
|
||||
resolution: {integrity: sha512-r3By5Wbizq8jagUrhtcym79HYUTinsvoBnYkFFWbUmrURBWIaC0HduFVkRkI1PNdI76piW+JSOJJnw00YCVXeg==}
|
||||
|
||||
'@comfyorg/fbx-exporter-three@1.0.1':
|
||||
resolution: {integrity: sha512-fQ1zBsgmmwfio6iEi91hRiFCr946yEgqR2DGh/UMismaLyUohiKGOJL/OnJQnW3+yne/PXxVoYgcortyumsO5w==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@types/three': '>=0.160.0'
|
||||
three: '>=0.160.0'
|
||||
peerDependenciesMeta:
|
||||
'@types/three':
|
||||
optional: true
|
||||
|
||||
'@csstools/color-helpers@5.1.0':
|
||||
resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -4658,6 +4671,7 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
||||
@@ -9870,8 +9884,8 @@ packages:
|
||||
vue-component-type-helpers@3.2.6:
|
||||
resolution: {integrity: sha512-O02tnvIfOQVmnvoWwuSydwRoHjZVt8UEBR+2p4rT35p8GAy5VTlWP8o5qXfJR/GWCN0nVZoYWsVUvx2jwgdBmQ==}
|
||||
|
||||
vue-component-type-helpers@3.2.9:
|
||||
resolution: {integrity: sha512-S3BiWYaLSzHxTpln665ELSrMR9UYmrIDUmhik7nVZxmJjTKL2/a+ew1hvGxksKelivm0ujjWfG1fYOiU/2e8rA==}
|
||||
vue-component-type-helpers@3.3.0:
|
||||
resolution: {integrity: sha512-vwR8DDsBysI9NWXa0okPFpCcW+BUC3sPTuLBNo1faMzw4QWMFd+3/lFYFu29ZN0q+8UReXWJHEYesC9dcXYCLg==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -10481,7 +10495,7 @@ snapshots:
|
||||
|
||||
'@astrojs/yaml2ts@0.2.3':
|
||||
dependencies:
|
||||
yaml: 2.8.2
|
||||
yaml: 2.9.0
|
||||
|
||||
'@atlaskit/pragmatic-drag-and-drop@1.3.1':
|
||||
dependencies:
|
||||
@@ -11228,6 +11242,13 @@ snapshots:
|
||||
|
||||
'@comfyorg/comfyui-electron-types@0.6.2': {}
|
||||
|
||||
'@comfyorg/fbx-exporter-three@1.0.1(@types/three@0.170.0)(three@0.170.0)':
|
||||
dependencies:
|
||||
fflate: 0.8.2
|
||||
three: 0.170.0
|
||||
optionalDependencies:
|
||||
'@types/three': 0.170.0
|
||||
|
||||
'@csstools/color-helpers@5.1.0': {}
|
||||
|
||||
'@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)':
|
||||
@@ -13397,7 +13418,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.13(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.2.9
|
||||
vue-component-type-helpers: 3.3.0
|
||||
|
||||
'@swc/helpers@0.5.17':
|
||||
dependencies:
|
||||
@@ -13951,7 +13972,7 @@ snapshots:
|
||||
'@typescript-eslint/visitor-keys': 8.56.0
|
||||
debug: 4.4.3
|
||||
minimatch: 9.0.5
|
||||
semver: 7.7.4
|
||||
semver: 7.8.0
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
@@ -14840,7 +14861,7 @@ snapshots:
|
||||
picomatch: 4.0.3
|
||||
prompts: 2.4.2
|
||||
rehype: 13.0.2
|
||||
semver: 7.7.4
|
||||
semver: 7.8.0
|
||||
shiki: 3.23.0
|
||||
smol-toml: 1.6.1
|
||||
svgo: 4.0.0
|
||||
@@ -15660,7 +15681,7 @@ snapshots:
|
||||
'@one-ini/wasm': 0.1.1
|
||||
commander: 10.0.1
|
||||
minimatch: 9.0.1
|
||||
semver: 7.7.4
|
||||
semver: 7.8.0
|
||||
|
||||
eight-colors@1.3.3: {}
|
||||
|
||||
@@ -18313,7 +18334,7 @@ snapshots:
|
||||
ky: 1.14.3
|
||||
registry-auth-token: 5.1.1
|
||||
registry-url: 6.0.1
|
||||
semver: 7.7.4
|
||||
semver: 7.8.0
|
||||
|
||||
package-manager-detector@1.6.0: {}
|
||||
|
||||
@@ -19207,7 +19228,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@img/colour': 1.1.0
|
||||
detect-libc: 2.1.2
|
||||
semver: 7.7.4
|
||||
semver: 7.8.0
|
||||
optionalDependencies:
|
||||
'@img/sharp-darwin-arm64': 0.34.5
|
||||
'@img/sharp-darwin-x64': 0.34.5
|
||||
@@ -19808,7 +19829,7 @@ snapshots:
|
||||
|
||||
typescript-auto-import-cache@0.3.6:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
semver: 7.8.0
|
||||
|
||||
typescript-eslint@8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3):
|
||||
dependencies:
|
||||
@@ -20439,7 +20460,7 @@ snapshots:
|
||||
volar-service-typescript@0.0.70(@volar/language-service@2.4.28):
|
||||
dependencies:
|
||||
path-browserify: 1.0.1
|
||||
semver: 7.7.4
|
||||
semver: 7.8.0
|
||||
typescript-auto-import-cache: 0.3.6
|
||||
vscode-languageserver-textdocument: 1.0.12
|
||||
vscode-nls: 5.2.0
|
||||
@@ -20508,7 +20529,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.2.6: {}
|
||||
|
||||
vue-component-type-helpers@3.2.9: {}
|
||||
vue-component-type-helpers@3.3.0: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.13(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -48,7 +48,8 @@ const showExportFormats = ref(false)
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' }
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
function toggleExportFormats() {
|
||||
|
||||
@@ -81,12 +81,12 @@ function renderComponent(onExportModel?: (format: string) => void) {
|
||||
}
|
||||
|
||||
describe('ViewerExportControls', () => {
|
||||
it('renders all three export format options', () => {
|
||||
it('renders all four export format options', () => {
|
||||
renderComponent()
|
||||
const select = screen.getByRole('combobox') as HTMLSelectElement
|
||||
const optionValues = Array.from(select.options).map((o) => o.value)
|
||||
|
||||
expect(optionValues).toEqual(['glb', 'obj', 'stl'])
|
||||
expect(optionValues).toEqual(['glb', 'obj', 'stl', 'fbx'])
|
||||
})
|
||||
|
||||
it('defaults the export format to obj', () => {
|
||||
|
||||
@@ -42,7 +42,8 @@ const emit = defineEmits<{
|
||||
const exportFormats = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' }
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
]
|
||||
|
||||
const exportFormat = ref('obj')
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
</span>
|
||||
<span
|
||||
v-if="rest"
|
||||
class="-ml-2.5 max-w-max min-w-0 grow basis-0 truncate rounded-r-full bg-component-node-widget-background"
|
||||
class="-ml-2.5 flex h-5 max-w-max min-w-0 grow basis-0 items-center truncate rounded-r-full bg-component-node-widget-background text-xs"
|
||||
>
|
||||
<span class="pr-2" v-text="rest" />
|
||||
</span>
|
||||
|
||||
@@ -48,6 +48,8 @@ export interface WidgetSlotMetadata {
|
||||
type: string
|
||||
}
|
||||
|
||||
type Badges = (LGraphBadge | (() => LGraphBadge))[]
|
||||
|
||||
/**
|
||||
* Minimal render-specific widget data extracted from LiteGraph widgets.
|
||||
* Value and metadata (label, hidden, disabled, etc.) are accessed via widgetValueStore.
|
||||
@@ -107,7 +109,7 @@ export interface VueNodeData {
|
||||
title: string
|
||||
type: string
|
||||
apiNode?: boolean
|
||||
badges?: (LGraphBadge | (() => LGraphBadge))[]
|
||||
badges?: Badges
|
||||
bgcolor?: string
|
||||
color?: string
|
||||
flags?: {
|
||||
@@ -786,6 +788,12 @@ export function useGraphNodeManager(graph: LGraph): GraphNodeManager {
|
||||
showAdvanced: Boolean(propertyEvent.newValue)
|
||||
})
|
||||
break
|
||||
case 'badges':
|
||||
vueNodeData.set(nodeId, {
|
||||
...currentData,
|
||||
badges: propertyEvent.newValue as Badges
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -625,9 +625,9 @@ describe('useNodePricing', () => {
|
||||
getNodeDisplayPrice(node)
|
||||
await new Promise((r) => setTimeout(r, 50))
|
||||
|
||||
// VueNodes path bumps per-node ref instead of the global tick.
|
||||
// VueNodes path bumps per-node ref and the global tick.
|
||||
expect(getNodeRevisionRef(node.id).value).toBeGreaterThan(revBefore)
|
||||
expect(pricingRevision.value).toBe(tickBefore)
|
||||
expect(pricingRevision.value).toBeGreaterThan(tickBefore)
|
||||
} finally {
|
||||
LiteGraph.vueNodesMode = false
|
||||
}
|
||||
|
||||
@@ -509,10 +509,8 @@ const scheduleEvaluation = (
|
||||
if (LiteGraph.vueNodesMode) {
|
||||
// VueNodes mode: bump per-node revision (only this node re-renders)
|
||||
getNodeRevisionRef(node.id).value++
|
||||
} else {
|
||||
// Nodes 1.0 mode: bump global tick to trigger setDirtyCanvas
|
||||
pricingTick.value++
|
||||
}
|
||||
pricingTick.value++
|
||||
})
|
||||
|
||||
inflight.set(node, { sig, promise })
|
||||
|
||||
@@ -18,6 +18,15 @@ export const usePriceBadge = () => {
|
||||
} else {
|
||||
node.badges.push(...newBadges)
|
||||
}
|
||||
const graph = node.graph
|
||||
if (!graph) return
|
||||
graph.trigger('node:property:changed', {
|
||||
type: 'node:property:changed',
|
||||
nodeId: node.id,
|
||||
property: 'badges',
|
||||
oldValue: node.badges,
|
||||
newValue: node.badges
|
||||
})
|
||||
}
|
||||
function collectCreditsBadges(
|
||||
graph: LGraph,
|
||||
|
||||
@@ -12,11 +12,12 @@ const STAGING_PLATFORM_BASE_URL = 'https://stagingplatform.comfy.org'
|
||||
|
||||
const BUILD_TIME_API_BASE_URL = __USE_PROD_CONFIG__
|
||||
? PROD_API_BASE_URL
|
||||
: STAGING_API_BASE_URL
|
||||
: (import.meta.env.VITE_STAGING_API_BASE_URL ?? STAGING_API_BASE_URL)
|
||||
|
||||
const BUILD_TIME_PLATFORM_BASE_URL = __USE_PROD_CONFIG__
|
||||
? PROD_PLATFORM_BASE_URL
|
||||
: STAGING_PLATFORM_BASE_URL
|
||||
: (import.meta.env.VITE_STAGING_PLATFORM_BASE_URL ??
|
||||
STAGING_PLATFORM_BASE_URL)
|
||||
|
||||
export function getComfyApiBaseUrl(): string {
|
||||
if (!isCloud) {
|
||||
|
||||
@@ -4,6 +4,33 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import type { GizmoMode } from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const {
|
||||
cloneSkinnedMock,
|
||||
exportGLBMock,
|
||||
exportOBJMock,
|
||||
exportSTLMock,
|
||||
exportFBXMock
|
||||
} = vi.hoisted(() => ({
|
||||
cloneSkinnedMock: vi.fn(),
|
||||
exportGLBMock: vi.fn(),
|
||||
exportOBJMock: vi.fn(),
|
||||
exportSTLMock: vi.fn(),
|
||||
exportFBXMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('three/examples/jsm/utils/SkeletonUtils.js', () => ({
|
||||
clone: cloneSkinnedMock
|
||||
}))
|
||||
|
||||
vi.mock('@/extensions/core/load3d/ModelExporter', () => ({
|
||||
ModelExporter: {
|
||||
exportGLB: exportGLBMock,
|
||||
exportOBJ: exportOBJMock,
|
||||
exportSTL: exportSTLMock,
|
||||
exportFBX: exportFBXMock
|
||||
}
|
||||
}))
|
||||
|
||||
type GizmoStub = {
|
||||
setEnabled: ReturnType<typeof vi.fn>
|
||||
setMode: ReturnType<typeof vi.fn>
|
||||
@@ -849,4 +876,189 @@ describe('Load3d', () => {
|
||||
expect(ctx.forceRender).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportModel', () => {
|
||||
beforeEach(() => {
|
||||
cloneSkinnedMock.mockReset()
|
||||
exportGLBMock.mockReset()
|
||||
exportOBJMock.mockReset()
|
||||
exportSTLMock.mockReset()
|
||||
exportFBXMock.mockReset()
|
||||
})
|
||||
|
||||
function setupForExport(overrides: {
|
||||
currentModel: THREE.Object3D | null
|
||||
originalModel?: THREE.Object3D | null
|
||||
originalFileName?: string | null
|
||||
originalURL?: string | null
|
||||
}) {
|
||||
Object.assign(ctx.load3d, {
|
||||
modelManager: {
|
||||
...ctx.modelManager,
|
||||
currentModel: overrides.currentModel,
|
||||
originalModel: overrides.originalModel ?? null,
|
||||
originalFileName: overrides.originalFileName ?? 'cube',
|
||||
originalURL: overrides.originalURL ?? null
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('throws when no model is loaded', async () => {
|
||||
setupForExport({ currentModel: null })
|
||||
|
||||
await expect(ctx.load3d.exportModel('fbx')).rejects.toThrow(
|
||||
'No model to export'
|
||||
)
|
||||
})
|
||||
|
||||
it('zeroes the source transform during export, then restores it', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(5, 6, 7)
|
||||
model.rotation.set(0.1, 0.2, 0.3)
|
||||
model.scale.set(2, 3, 4)
|
||||
|
||||
let transformDuringExport: {
|
||||
position: THREE.Vector3
|
||||
rotation: THREE.Euler
|
||||
scale: THREE.Vector3
|
||||
} | null = null
|
||||
exportGLBMock.mockImplementation(async () => {
|
||||
transformDuringExport = {
|
||||
position: model.position.clone(),
|
||||
rotation: model.rotation.clone(),
|
||||
scale: model.scale.clone()
|
||||
}
|
||||
})
|
||||
|
||||
setupForExport({ currentModel: model })
|
||||
|
||||
await ctx.load3d.exportModel('glb')
|
||||
|
||||
expect(transformDuringExport!.position.x).toBe(0)
|
||||
expect(transformDuringExport!.position.y).toBe(0)
|
||||
expect(transformDuringExport!.position.z).toBe(0)
|
||||
expect(transformDuringExport!.rotation.x).toBe(0)
|
||||
expect(transformDuringExport!.scale.x).toBe(1)
|
||||
expect(transformDuringExport!.scale.y).toBe(1)
|
||||
expect(transformDuringExport!.scale.z).toBe(1)
|
||||
|
||||
expect(model.position.x).toBe(5)
|
||||
expect(model.position.y).toBe(6)
|
||||
expect(model.position.z).toBe(7)
|
||||
expect(model.rotation.x).toBeCloseTo(0.1)
|
||||
expect(model.scale.x).toBe(2)
|
||||
expect(model.scale.z).toBe(4)
|
||||
})
|
||||
|
||||
it('restores the source transform even when the exporter throws', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
model.position.set(3, 4, 5)
|
||||
model.scale.set(7, 7, 7)
|
||||
exportGLBMock.mockRejectedValueOnce(new Error('boom'))
|
||||
|
||||
setupForExport({ currentModel: model })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await expect(ctx.load3d.exportModel('glb')).rejects.toThrow('boom')
|
||||
|
||||
expect(model.position.x).toBe(3)
|
||||
expect(model.scale.x).toBe(7)
|
||||
})
|
||||
|
||||
it('routes fbx through SkeletonUtils.clone and attaches the source animations', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
const clip = { name: 'walk' } as unknown as THREE.AnimationClip
|
||||
model.animations = [clip]
|
||||
const cloned = new THREE.Object3D()
|
||||
cloneSkinnedMock.mockReturnValueOnce(cloned)
|
||||
|
||||
setupForExport({
|
||||
currentModel: model,
|
||||
originalFileName: 'rig',
|
||||
originalURL: 'http://example.com/api/view?filename=rig.fbx'
|
||||
})
|
||||
|
||||
await ctx.load3d.exportModel('fbx')
|
||||
|
||||
expect(cloneSkinnedMock).toHaveBeenCalledWith(model)
|
||||
expect(exportFBXMock).toHaveBeenCalledOnce()
|
||||
const [exportedModel, filename, originalURL] = exportFBXMock.mock
|
||||
.calls[0] as [
|
||||
THREE.Object3D & { animations: THREE.AnimationClip[] },
|
||||
string,
|
||||
string | null
|
||||
]
|
||||
expect(exportedModel).toBe(cloned)
|
||||
expect(exportedModel.animations).toEqual([clip])
|
||||
expect(filename).toBe('rig.fbx')
|
||||
expect(originalURL).toBe('http://example.com/api/view?filename=rig.fbx')
|
||||
})
|
||||
|
||||
it('falls back to originalModel.animations when the working model has none (fbx)', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
const original = new THREE.Object3D()
|
||||
const clip = { name: 'idle' } as unknown as THREE.AnimationClip
|
||||
original.animations = [clip]
|
||||
const cloned = new THREE.Object3D()
|
||||
cloneSkinnedMock.mockReturnValueOnce(cloned)
|
||||
|
||||
setupForExport({ currentModel: model, originalModel: original })
|
||||
|
||||
await ctx.load3d.exportModel('fbx')
|
||||
|
||||
const [exportedModel] = exportFBXMock.mock.calls[0] as [
|
||||
THREE.Object3D & { animations: THREE.AnimationClip[] }
|
||||
]
|
||||
expect(exportedModel.animations).toEqual([clip])
|
||||
})
|
||||
|
||||
it('uses Object3D.clone (not SkeletonUtils) for non-fbx formats', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
const cloneSpy = vi.spyOn(model, 'clone')
|
||||
|
||||
setupForExport({
|
||||
currentModel: model,
|
||||
originalFileName: 'cube',
|
||||
originalURL: null
|
||||
})
|
||||
|
||||
await ctx.load3d.exportModel('glb')
|
||||
|
||||
expect(cloneSpy).toHaveBeenCalled()
|
||||
expect(cloneSkinnedMock).not.toHaveBeenCalled()
|
||||
expect(exportGLBMock).toHaveBeenCalledOnce()
|
||||
const [, filename] = exportGLBMock.mock.calls[0] as [
|
||||
unknown,
|
||||
string,
|
||||
unknown
|
||||
]
|
||||
expect(filename).toBe('cube.glb')
|
||||
})
|
||||
|
||||
it('emits exportLoadingStart and exportLoadingEnd around the export', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
setupForExport({ currentModel: model })
|
||||
|
||||
await ctx.load3d.exportModel('glb')
|
||||
|
||||
expect(ctx.eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'exportLoadingStart',
|
||||
'Exporting as GLB...'
|
||||
)
|
||||
expect(ctx.eventManager.emitEvent).toHaveBeenCalledWith(
|
||||
'exportLoadingEnd',
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('throws on unsupported format', async () => {
|
||||
const model = new THREE.Object3D()
|
||||
setupForExport({ currentModel: model })
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
|
||||
await expect(ctx.load3d.exportModel('xyz')).rejects.toThrow(
|
||||
'Unsupported export format: xyz'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import * as THREE from 'three'
|
||||
import { clone as cloneSkinned } from 'three/examples/jsm/utils/SkeletonUtils.js'
|
||||
|
||||
import type { AnimationManager } from './AnimationManager'
|
||||
import type { CameraManager } from './CameraManager'
|
||||
@@ -344,8 +345,30 @@ class Load3d {
|
||||
const exportMessage = `Exporting as ${format.toUpperCase()}...`
|
||||
this.eventManager.emitEvent('exportLoadingStart', exportMessage)
|
||||
|
||||
const source = this.modelManager.currentModel
|
||||
const savedPos = source.position.clone()
|
||||
const savedRot = source.rotation.clone()
|
||||
const savedScale = source.scale.clone()
|
||||
source.position.set(0, 0, 0)
|
||||
source.rotation.set(0, 0, 0)
|
||||
source.scale.set(1, 1, 1)
|
||||
source.updateMatrixWorld(true)
|
||||
|
||||
try {
|
||||
const model = this.modelManager.currentModel.clone()
|
||||
const original = this.modelManager.originalModel
|
||||
const clipsFromOriginal =
|
||||
original &&
|
||||
'animations' in original &&
|
||||
Array.isArray(original.animations)
|
||||
? original.animations
|
||||
: []
|
||||
const clips = source.animations?.length
|
||||
? source.animations
|
||||
: clipsFromOriginal
|
||||
const model =
|
||||
format === 'fbx'
|
||||
? Object.assign(cloneSkinned(source), { animations: clips })
|
||||
: source.clone()
|
||||
|
||||
const originalFileName = this.modelManager.originalFileName || 'model'
|
||||
const filename = `${originalFileName}.${format}`
|
||||
@@ -364,6 +387,9 @@ class Load3d {
|
||||
case 'stl':
|
||||
;(await ModelExporter.exportSTL(model, filename), originalURL)
|
||||
break
|
||||
case 'fbx':
|
||||
await ModelExporter.exportFBX(model, filename, originalURL)
|
||||
break
|
||||
default:
|
||||
throw new Error(`Unsupported export format: ${format}`)
|
||||
}
|
||||
@@ -373,6 +399,10 @@ class Load3d {
|
||||
console.error(`Error exporting model as ${format}:`, error)
|
||||
throw error
|
||||
} finally {
|
||||
source.position.copy(savedPos)
|
||||
source.rotation.copy(savedRot)
|
||||
source.scale.copy(savedScale)
|
||||
source.updateMatrixWorld(true)
|
||||
this.eventManager.emitEvent('exportLoadingEnd', null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,13 +8,15 @@ const {
|
||||
addAlertMock,
|
||||
gltfParseMock,
|
||||
objParseMock,
|
||||
stlParseMock
|
||||
stlParseMock,
|
||||
fbxParseAsyncMock
|
||||
} = vi.hoisted(() => ({
|
||||
downloadBlobMock: vi.fn(),
|
||||
addAlertMock: vi.fn(),
|
||||
gltfParseMock: vi.fn(),
|
||||
objParseMock: vi.fn(),
|
||||
stlParseMock: vi.fn()
|
||||
stlParseMock: vi.fn(),
|
||||
fbxParseAsyncMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/base/common/downloadUtil', () => ({
|
||||
@@ -48,6 +50,12 @@ vi.mock('three/examples/jsm/exporters/STLExporter', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@comfyorg/fbx-exporter-three', () => ({
|
||||
FBXExporter: class {
|
||||
parseAsync = fbxParseAsyncMock
|
||||
}
|
||||
}))
|
||||
|
||||
describe('ModelExporter', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
@@ -125,7 +133,9 @@ describe('ModelExporter', () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.downloadFromURL(
|
||||
@@ -149,6 +159,27 @@ describe('ModelExporter', () => {
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('rethrows and shows a toast alert when the response status is not ok', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 404,
|
||||
blob: () => Promise.resolve(new Blob(['x']))
|
||||
})
|
||||
)
|
||||
|
||||
await expect(
|
||||
ModelExporter.downloadFromURL('http://example.com/cube.glb', 'cube.glb')
|
||||
).rejects.toThrow('HTTP 404')
|
||||
expect(downloadBlobMock).not.toHaveBeenCalled()
|
||||
expect(addAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToDownloadFile'
|
||||
)
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportGLB', () => {
|
||||
@@ -156,7 +187,9 @@ describe('ModelExporter', () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
const model = new THREE.Object3D()
|
||||
|
||||
@@ -214,7 +247,9 @@ describe('ModelExporter', () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportOBJ(
|
||||
@@ -260,7 +295,9 @@ describe('ModelExporter', () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({ blob: () => Promise.resolve(blob) })
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportSTL(
|
||||
@@ -300,4 +337,51 @@ describe('ModelExporter', () => {
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportFBX', () => {
|
||||
it('uses the direct-URL fast path for matching .fbx URLs', async () => {
|
||||
const blob = new Blob(['x'])
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, blob: () => Promise.resolve(blob) })
|
||||
)
|
||||
|
||||
await ModelExporter.exportFBX(
|
||||
new THREE.Object3D(),
|
||||
'out.fbx',
|
||||
'http://example.com/api/view?filename=src.fbx'
|
||||
)
|
||||
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.fbx', blob)
|
||||
expect(fbxParseAsyncMock).not.toHaveBeenCalled()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('serializes via FBXExporter and downloads as binary when there is no direct URL', async () => {
|
||||
const bytes = new Uint8Array([0x4b, 0x61, 0x79, 0x64, 0x61, 0x72, 0x61])
|
||||
fbxParseAsyncMock.mockResolvedValue(bytes)
|
||||
|
||||
const promise = ModelExporter.exportFBX(new THREE.Object3D(), 'out.fbx')
|
||||
await vi.runAllTimersAsync()
|
||||
await promise
|
||||
|
||||
expect(fbxParseAsyncMock).toHaveBeenCalled()
|
||||
expect(downloadBlobMock).toHaveBeenCalledWith('out.fbx', expect.any(Blob))
|
||||
})
|
||||
|
||||
it('alerts and rethrows when FBXExporter throws', async () => {
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
fbxParseAsyncMock.mockRejectedValue(new Error('fbx fail'))
|
||||
|
||||
const promise = ModelExporter.exportFBX(new THREE.Object3D(), 'out.fbx')
|
||||
const assertion = expect(promise).rejects.toThrow('fbx fail')
|
||||
await vi.runAllTimersAsync()
|
||||
await assertion
|
||||
expect(addAlertMock).toHaveBeenCalledWith(
|
||||
'toastMessages.failedToExportModel:{"format":"FBX"}'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FBXExporter } from '@comfyorg/fbx-exporter-three'
|
||||
import * as THREE from 'three'
|
||||
import { GLTFExporter } from 'three/examples/jsm/exporters/GLTFExporter'
|
||||
import { OBJExporter } from 'three/examples/jsm/exporters/OBJExporter'
|
||||
@@ -38,6 +39,9 @@ export class ModelExporter {
|
||||
): Promise<void> {
|
||||
try {
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to download file (HTTP ${response.status})`)
|
||||
}
|
||||
const blob = await response.blob()
|
||||
downloadBlob(desiredFilename, blob)
|
||||
} catch (error) {
|
||||
@@ -116,6 +120,41 @@ export class ModelExporter {
|
||||
}
|
||||
}
|
||||
|
||||
static async exportFBX(
|
||||
model: THREE.Object3D,
|
||||
filename: string = 'model.fbx',
|
||||
originalURL?: string | null
|
||||
): Promise<void> {
|
||||
if (originalURL && ModelExporter.canUseDirectURL(originalURL, 'fbx')) {
|
||||
return ModelExporter.downloadFromURL(originalURL, filename)
|
||||
}
|
||||
|
||||
const exporter = new FBXExporter()
|
||||
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
const bytes = await exporter.parseAsync(model)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||
|
||||
// FBXExporter returns Uint8Array — wrap into ArrayBuffer for download.
|
||||
ModelExporter.saveArrayBuffer(
|
||||
bytes.buffer.slice(
|
||||
bytes.byteOffset,
|
||||
bytes.byteOffset + bytes.byteLength
|
||||
) as ArrayBuffer,
|
||||
filename
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Error exporting FBX:', error)
|
||||
useToastStore().addAlert(
|
||||
t('toastMessages.failedToExportModel', { format: 'FBX' })
|
||||
)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
static async exportSTL(
|
||||
model: THREE.Object3D,
|
||||
filename: string = 'model.stl',
|
||||
|
||||
@@ -76,7 +76,8 @@ describe('createExportMenuItems', () => {
|
||||
expect(submenuOptions.map((o: { content: string }) => o.content)).toEqual([
|
||||
'GLB',
|
||||
'OBJ',
|
||||
'STL'
|
||||
'STL',
|
||||
'FBX'
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { LiteGraph } from '@/lib/litegraph/src/litegraph'
|
||||
const EXPORT_FORMATS = [
|
||||
{ label: 'GLB', value: 'glb' },
|
||||
{ label: 'OBJ', value: 'obj' },
|
||||
{ label: 'STL', value: 'stl' }
|
||||
{ label: 'STL', value: 'stl' },
|
||||
{ label: 'FBX', value: 'fbx' }
|
||||
] as const
|
||||
|
||||
/**
|
||||
|
||||
@@ -1672,7 +1672,15 @@ export class LGraph
|
||||
this.beforeChange()
|
||||
|
||||
try {
|
||||
return this._convertToSubgraphImpl(items)
|
||||
function extractNodes(item: Positionable): Positionable[] {
|
||||
if (!(item instanceof LGraphNode) || !item.convertToNodes) return [item]
|
||||
|
||||
const innerNodes = item.convertToNodes()
|
||||
for (const innerNode of innerNodes) innerNode.updateArea()
|
||||
return innerNodes
|
||||
}
|
||||
const processedItems = new Set([...items].flatMap(extractNodes))
|
||||
return this._convertToSubgraphImpl(processedItems)
|
||||
} finally {
|
||||
// Mark state change complete for proper undo support
|
||||
this.afterChange()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { toString } from 'es-toolkit/compat'
|
||||
import { toValue } from 'vue'
|
||||
|
||||
import { PREFIX, SEPARATOR } from '@/constants/groupNodeConstants'
|
||||
import { MovingInputLink } from '@/lib/litegraph/src/canvas/MovingInputLink'
|
||||
import type { RenderLink } from '@/lib/litegraph/src/canvas/RenderLink'
|
||||
import { AutoPanController } from '@/renderer/core/canvas/useAutoPan'
|
||||
import { LitegraphLinkAdapter } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
import type { LinkRenderContext } from '@/renderer/core/canvas/litegraph/litegraphLinkAdapter'
|
||||
@@ -3307,11 +3307,15 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
if (result != null) this.dirty_canvas = result
|
||||
}
|
||||
}
|
||||
const firstLink: RenderLink | undefined = linkConnector.renderLinks.at(0)
|
||||
const isSubgraphIOLink =
|
||||
linkConnector.isConnecting && firstLink?.isIoNodeLink
|
||||
|
||||
// get node over
|
||||
const node = LiteGraph.vueNodesMode
|
||||
? null
|
||||
: graph.getNodeOnPos(x, y, this.visible_nodes)
|
||||
const node =
|
||||
LiteGraph.vueNodesMode && !isSubgraphIOLink
|
||||
? null
|
||||
: graph.getNodeOnPos(x, y, this.visible_nodes)
|
||||
|
||||
const dragRect = this.dragging_rectangle
|
||||
if (dragRect) {
|
||||
@@ -3402,8 +3406,6 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
|
||||
// Check if link is over anything it could connect to - record position of valid target for snap / highlight
|
||||
if (linkConnector.isConnecting) {
|
||||
const firstLink = linkConnector.renderLinks.at(0)
|
||||
|
||||
// Default: nothing highlighted
|
||||
let highlightPos: Point | undefined
|
||||
let highlightInput: INodeInputSlot | undefined
|
||||
@@ -3454,7 +3456,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
highlightInput = node.inputs[inputId]
|
||||
}
|
||||
|
||||
if (highlightInput) {
|
||||
if (highlightInput && !LiteGraph.vueNodesMode) {
|
||||
const widget = node.getWidgetFromSlot(highlightInput)
|
||||
if (widget) linkConnector.overWidget = widget
|
||||
}
|
||||
@@ -8503,40 +8505,7 @@ export class LGraphCanvas implements CustomEventDispatcher<LGraphCanvasEventMap>
|
||||
options = [
|
||||
{
|
||||
content: 'Convert to Subgraph',
|
||||
callback: () => {
|
||||
// find groupnodes, degroup and select children
|
||||
if (this.selectedItems.size) {
|
||||
let hasGroups = false
|
||||
for (const item of this.selectedItems) {
|
||||
const node = item as LGraphNode
|
||||
const isGroup =
|
||||
typeof node.type === 'string' &&
|
||||
node.type.startsWith(`${PREFIX}${SEPARATOR}`)
|
||||
if (isGroup && node.convertToNodes) {
|
||||
hasGroups = true
|
||||
const nodes = node.convertToNodes()
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.selectItems(nodes, true)
|
||||
|
||||
if (!this.selectedItems.size)
|
||||
throw new Error('Convert to Subgraph: Nothing selected.')
|
||||
this._graph.convertToSubgraph(this.selectedItems)
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// If no groups were found, continue normally
|
||||
if (!hasGroups) {
|
||||
if (!this.selectedItems.size)
|
||||
throw new Error('Convert to Subgraph: Nothing selected.')
|
||||
this._graph.convertToSubgraph(this.selectedItems)
|
||||
}
|
||||
} else {
|
||||
throw new Error('Convert to Subgraph: Nothing selected.')
|
||||
}
|
||||
}
|
||||
callback: () => this._graph.convertToSubgraph(this.selectedItems)
|
||||
},
|
||||
{
|
||||
content: 'Properties',
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface RenderLink {
|
||||
/** The reroute that the link is being connected from. */
|
||||
readonly fromReroute?: Reroute
|
||||
|
||||
readonly isIoNodeLink?: boolean
|
||||
|
||||
/**
|
||||
* Capability checks used for hit-testing and validation during drag.
|
||||
* Implementations should return `false` when a connection is not possible
|
||||
|
||||
@@ -24,6 +24,7 @@ export class ToInputFromIoNodeLink implements RenderLink {
|
||||
readonly fromPos: Point
|
||||
fromDirection: LinkDirection = LinkDirection.RIGHT
|
||||
readonly existingLink?: LLink
|
||||
readonly isIoNodeLink = true
|
||||
|
||||
constructor(
|
||||
readonly network: LinkNetwork,
|
||||
|
||||
@@ -23,6 +23,7 @@ export class ToOutputFromIoNodeLink implements RenderLink {
|
||||
readonly fromPos: Point
|
||||
readonly fromSlotIndex: SlotIndex
|
||||
fromDirection: LinkDirection = LinkDirection.LEFT
|
||||
readonly isIoNodeLink = true
|
||||
|
||||
constructor(
|
||||
readonly network: LinkNetwork,
|
||||
|
||||
@@ -136,6 +136,13 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
}
|
||||
subgraph.incrementVersion()
|
||||
|
||||
subgraph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: inputIndex,
|
||||
connected: true,
|
||||
linkId: link.id
|
||||
})
|
||||
node.onConnectionsChange?.(NodeSlotType.INPUT, inputIndex, true, link, slot)
|
||||
|
||||
subgraph.afterChange()
|
||||
@@ -239,11 +246,8 @@ export class SubgraphInput extends SubgraphSlot {
|
||||
override isValidTarget(
|
||||
fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput
|
||||
): boolean {
|
||||
if (isNodeSlot(fromSlot)) {
|
||||
return (
|
||||
'link' in fromSlot &&
|
||||
LiteGraph.isValidConnection(this.type, fromSlot.type)
|
||||
)
|
||||
if (isNodeSlot(fromSlot) && 'link' in fromSlot) {
|
||||
return LiteGraph.isValidConnection(this.type, fromSlot.type)
|
||||
}
|
||||
|
||||
if (isSubgraphOutput(fromSlot)) {
|
||||
|
||||
@@ -226,6 +226,13 @@ export class SubgraphInputNode
|
||||
link,
|
||||
subgraphInput
|
||||
)
|
||||
subgraph.trigger('node:slot-links:changed', {
|
||||
nodeId: node.id,
|
||||
slotType: NodeSlotType.INPUT,
|
||||
slotIndex: slotIndex,
|
||||
connected: false,
|
||||
linkId: link.id
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -140,11 +140,8 @@ export class SubgraphOutput extends SubgraphSlot {
|
||||
override isValidTarget(
|
||||
fromSlot: INodeInputSlot | INodeOutputSlot | SubgraphInput | SubgraphOutput
|
||||
): boolean {
|
||||
if (isNodeSlot(fromSlot)) {
|
||||
return (
|
||||
'links' in fromSlot &&
|
||||
LiteGraph.isValidConnection(fromSlot.type, this.type)
|
||||
)
|
||||
if (isNodeSlot(fromSlot) && 'links' in fromSlot) {
|
||||
return LiteGraph.isValidConnection(fromSlot.type, this.type)
|
||||
}
|
||||
|
||||
if (isSubgraphInput(fromSlot)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createTestingPinia } from '@pinia/testing'
|
||||
import { setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { LGraphGroup } from '@/lib/litegraph/src/LGraphGroup'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
|
||||
vi.mock('@/composables/useAppMode', () => ({
|
||||
@@ -84,4 +85,9 @@ describe('useCanvasStore', () => {
|
||||
expect(originalHandler).toHaveBeenCalledWith(2.0, app.canvas.ds.offset)
|
||||
})
|
||||
})
|
||||
it('Does not include groups in selected nodeIds', async () => {
|
||||
store.selectedItems = [new LGraphGroup()]
|
||||
|
||||
expect(store.selectedNodeIds).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,7 +123,7 @@ export const useCanvasStore = defineStore('canvas', () => {
|
||||
() =>
|
||||
new Set(
|
||||
selectedItems.value
|
||||
.filter((item) => item.id !== undefined)
|
||||
.filter((item) => item.id !== undefined && isLGraphNode(item))
|
||||
.map((item) => String(item.id))
|
||||
)
|
||||
)
|
||||
|
||||
@@ -411,12 +411,20 @@ export function useSlotLinkInteraction({
|
||||
}
|
||||
const raf = createRafBatch(processPointerMoveFrame)
|
||||
|
||||
const canvas = app.canvas
|
||||
const node = canvas.graph?.getNodeById(nodeId)
|
||||
const handlePointerMove = (event: PointerEvent) => {
|
||||
if (!pointerSession.matches(event)) return
|
||||
event.stopPropagation()
|
||||
|
||||
autoPan?.updatePointer(event.clientX, event.clientY)
|
||||
|
||||
if (canvas.subgraph && node) {
|
||||
augmentToCanvasPointerEvent(event, node, canvas)
|
||||
canvas.subgraph.inputNode.onPointerMove(event)
|
||||
canvas.subgraph.outputNode.onPointerMove(event)
|
||||
}
|
||||
|
||||
dragContext.pendingPointerMove = {
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
|
||||
2
src/vite-env.d.ts
vendored
2
src/vite-env.d.ts
vendored
@@ -19,6 +19,8 @@ declare global {
|
||||
|
||||
interface ImportMetaEnv {
|
||||
VITE_APP_VERSION?: string
|
||||
VITE_STAGING_API_BASE_URL?: string
|
||||
VITE_STAGING_PLATFORM_BASE_URL?: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
|
||||
@@ -10,6 +10,7 @@ from .nodes import (
|
||||
LongComboDropdown,
|
||||
MultiSelectNode,
|
||||
NodeWithBooleanInput,
|
||||
NodeWithComboControlWidget,
|
||||
NodeWithDefaultInput,
|
||||
NodeWithForceInput,
|
||||
NodeWithOptionalComboInput,
|
||||
@@ -43,6 +44,7 @@ __all__ = [
|
||||
"LongComboDropdown",
|
||||
"MultiSelectNode",
|
||||
"NodeWithBooleanInput",
|
||||
"NodeWithComboControlWidget",
|
||||
"NodeWithDefaultInput",
|
||||
"NodeWithForceInput",
|
||||
"NodeWithOptionalComboInput",
|
||||
|
||||
@@ -11,6 +11,7 @@ from .errors import (
|
||||
from .inputs import (
|
||||
LongComboDropdown,
|
||||
NodeWithBooleanInput,
|
||||
NodeWithComboControlWidget,
|
||||
NodeWithDefaultInput,
|
||||
NodeWithForceInput,
|
||||
NodeWithOptionalComboInput,
|
||||
@@ -69,6 +70,7 @@ __all__ = [
|
||||
"LongComboDropdown",
|
||||
"MultiSelectNode",
|
||||
"NodeWithBooleanInput",
|
||||
"NodeWithComboControlWidget",
|
||||
"NodeWithDefaultInput",
|
||||
"NodeWithForceInput",
|
||||
"NodeWithOptionalComboInput",
|
||||
|
||||
@@ -2,6 +2,8 @@ from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from comfy_api.v0_0_2 import IO
|
||||
|
||||
|
||||
class LongComboDropdown:
|
||||
@classmethod
|
||||
@@ -317,6 +319,55 @@ class NodeWithLegacyWidget:
|
||||
def node_with_legacy_widget(self):
|
||||
return ()
|
||||
|
||||
class NodeWithPriceBadge(IO.ComfyNode):
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="DevToolsNodeWithPriceBadge",
|
||||
display_name="Node With Price Badge",
|
||||
description="An API node with a price badge",
|
||||
inputs=[IO.Combo.Input("price", options=["1x", "2x", "3x"])],
|
||||
is_api_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["price"]),
|
||||
expr="""
|
||||
(
|
||||
$p := widgets.price;
|
||||
{"type":"usd","usd": $contains($p, "2x") ? 2 : $contains($p, "3x") ? 3 : 1}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(cls, price):
|
||||
return IO.NodeOutput()
|
||||
|
||||
|
||||
class NodeWithComboControlWidget:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"combo_option": (
|
||||
"COMBO",
|
||||
{
|
||||
"options": ["Option A", "Option B", "Option C"],
|
||||
"control_after_generate": True,
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("STRING",)
|
||||
FUNCTION = "execute"
|
||||
CATEGORY = "DevTools"
|
||||
DESCRIPTION = "A node with a combo input that has control_after_generate, producing control widgets with a filter list"
|
||||
OUTPUT_NODE = True
|
||||
|
||||
def execute(self, combo_option: str):
|
||||
return (combo_option,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsLongComboDropdown": LongComboDropdown,
|
||||
@@ -333,7 +384,9 @@ NODE_CLASS_MAPPINGS = {
|
||||
"DevToolsNodeWithSeedInput": NodeWithSeedInput,
|
||||
"DevToolsNodeWithValidation": NodeWithValidation,
|
||||
"DevToolsNodeWithV2ComboInput": NodeWithV2ComboInput,
|
||||
"DevToolsNodeWithComboControlWidget": NodeWithComboControlWidget,
|
||||
"DevToolsNodeWithLegacyWidget": NodeWithLegacyWidget,
|
||||
"DevToolsNodeWithPriceBadge": NodeWithPriceBadge,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
@@ -351,7 +404,9 @@ NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"DevToolsNodeWithSeedInput": "Node With Seed Input",
|
||||
"DevToolsNodeWithValidation": "Node With Validation",
|
||||
"DevToolsNodeWithV2ComboInput": "Node With V2 Combo Input",
|
||||
"DevToolsNodeWithComboControlWidget": "Node With Combo Control Widget",
|
||||
"DevToolsNodeWithLegacyWidget": "Node With Legacy Widget",
|
||||
"DevToolsNodeWithPriceBadge": "Node With Price Badge",
|
||||
}
|
||||
|
||||
__all__ = [
|
||||
@@ -369,6 +424,7 @@ __all__ = [
|
||||
"NodeWithSeedInput",
|
||||
"NodeWithValidation",
|
||||
"NodeWithV2ComboInput",
|
||||
"NodeWithComboControlWidget",
|
||||
"NODE_CLASS_MAPPINGS",
|
||||
"NODE_DISPLAY_NAME_MAPPINGS",
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user