Compare commits

...

11 Commits

Author SHA1 Message Date
Alexander Brown
68fdfd5e35 Merge branch 'main' into glary/widget-control-mode-e2e-tests 2026-05-20 12:58:27 -07:00
AustinMroz
2717d59451 Fix reactivity of vue subgraph price badges (#12029)
When a subgraph contains partner nodes with price badges, those badges
are also displayed on the subgraphNode. The reactivity here was spotty:
The price badges would fail to display unless the user had navigated
into the subgraph on the current page load. Fixing this is performed in
2 steps:
- Firing a `node:property:changed` event when the badges contained in a
subgraph are updated
- Extending the reactivity updates so that badges update in vue mode
despite using the litegraph badge getter.

This PR also includes a minor styling tweak to fix text alignment on
price badges
| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/56a95cbe-12c9-43b0-8664-34e52b6415ac"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/bf4a0d81-21e4-4afc-946e-eba5967f1715"
/>|

Resolves FE-346

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12029-Fix-reactivity-of-vue-subgraph-price-badges-3586d73d3650813cb12fe265090940e4)
by [Unito](https://www.unito.io)
2026-05-20 11:22:42 -07:00
AustinMroz
d63b0f05bf Subgraph io fixes (#12281)
Fixes 3 different bugs when making links to and from subgraph IO from
vue nodes
- When dragging a link from a node to a subgraph IO, there is no
feedback if a slot is not a valid connection target or if a slot is
actively hovered
- When a link is made from a subgraph IO to a node, the reactivity is
not triggered on the node to indicate a change of link state.
- When dragging a link from a subgraph IO to a node, the link would not
snap to the valid connection targets on nodes
- The fix for this one is not as thorough as I would like. It only
allows connections to the slot, not connections to the hovered widget.
We have two deeply disconnected linking systems and properly reconciling
them would be a multi-week project.

Resolves FE-561

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12281-Subgraph-io-fixes-3606d73d365081089f7ef19331c6d70a)
by [Unito](https://www.unito.io)
2026-05-20 10:26:47 -07:00
Terry Jia
cd2f4677c2 FE-719 feat(load3d): add FBX export support (#12323)
## Summary
implement fbx export, using our own lib fbx-exporter-three

## Screenshots (if applicable)


https://github.com/user-attachments/assets/80012338-d065-4a00-a9a0-0a2e73d67db4

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12323-FE-719-feat-load3d-add-FBX-export-support-3656d73d365081ef901ffe880ae9568a)
by [Unito](https://www.unito.io)
2026-05-20 06:52:56 -04:00
Hunter
38fed22140 feat: env-var override for staging api/platform base URLs (#12221)
## Summary

Allow staging api/platform base URLs to be overridden by env vars so
non-cloud builds can target an alternate backend without source edits.

## Changes

- **What**: `BUILD_TIME_API_BASE_URL` / `BUILD_TIME_PLATFORM_BASE_URL`
in `src/config/comfyApi.ts` now read
`import.meta.env.VITE_STAGING_API_BASE_URL` /
`VITE_STAGING_PLATFORM_BASE_URL` first, falling back to the existing
`stagingapi.comfy.org` / `stagingplatform.comfy.org` constants. Vars
typed in `src/vite-env.d.ts` and documented in `.env_example`.
- **Breaking**: None. Defaults unchanged. The cloud-runtime override
path via the features endpoint (`comfy_api_base_url`,
`comfy_platform_base_url` in `RemoteConfig`) is untouched.

## Review Focus

Override only applies to the non-prod branch of the build-time ternary,
so prod builds (`USE_PROD_CONFIG=true`) cannot be redirected. Cloud
builds continue to resolve URLs at runtime via `remoteConfig` regardless
of these env vars.

## Note

Pre-commit `pnpm typecheck` fails on `origin/main` independently of this
change (`src/utils/nodeDefUtil.ts` and
`src/workbench/utils/nodeHelpUtil.ts` import non-existent exports from
`@/schemas/nodeDefSchema` / `@/types/nodeSource`). Verified by stashing
this PR's diff and re-running. Committed with `--no-verify`; please
address the underlying breakage separately.
2026-05-20 05:37:27 +00:00
AustinMroz
a95e53bf6d On subgraph conversion, always unpack group nodes (#12356)
This is a targeted small scope change to improve the availability for
converting group nodes into a subgraph.

The prior implementation would only apply on the litegraph context menu
option for converting a node to a subgraph. It failed to apply on any of
the other more common methods. The code for unpacking group nodes has
been moved directly into the setup for converting a group of nodes into
a subgraph and drastically simplified.

Of note, several other long lived bugs were found while working on this
fix, but they are out of scope for this targeted PR.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12356-On-subgraph-conversion-always-unpack-group-nodes-3666d73d365081d09774c00a851b8198)
by [Unito](https://www.unito.io)
2026-05-20 05:23:46 +00:00
AustinMroz
246b79dda9 Fix group selection selecting nodes (#12099)
Fix group selection incorrectly selecting nodes of equal id in vue mode.

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-12099-Fix-group-selection-selecting-nodes-35b6d73d365081e2bc73f16deb996f61)
by [Unito](https://www.unito.io)

Co-authored-by: Alexander Brown <drjkl@comfy.org>
2026-05-20 04:22:03 +00:00
DrJKL
5841c252ce Merge remote-tracking branch 'origin/main' into glary/widget-control-mode-e2e-tests
# Conflicts:
#	browser_tests/tests/subgraph/subgraphPromotion.spec.ts
#	tools/devtools/nodes/inputs.py
2026-05-19 18:40:12 -07:00
Glary-Bot
4d4ad6ed92 refactor: move subgraph control widget helper to SubgraphHelper fixture 2026-04-20 00:23:15 +00:00
Glary-Bot
86b6cab5e9 fix: address CodeRabbit review - node size floor, vacuous every() guard 2026-04-19 08:19:11 +00:00
Glary-Bot
0aefef7c42 test: add e2e coverage for Comfy.WidgetControlMode setting watcher
Add new numberControlWidget.spec.ts with tests covering GraphCanvas.vue
lines 355-366 (0% coverage). Tests verify control widget labels update
when toggling between 'before' and 'after' modes, including multi-node
traversal, widgetless node handling, canvas dirty marking, linkedWidgets
label updates, and subgraph node traversal.

- Add DevToolsNodeWithComboControlWidget for combo+filter list testing
- Move Number widget tests from widget.spec.ts to new file
- Add subgraph WidgetControlMode test to subgraphPromotion.spec.ts
2026-04-19 08:09:15 +00:00
43 changed files with 1025 additions and 109 deletions

View File

@@ -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

View 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
}

View File

@@ -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!

View File

@@ -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

View 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')
])
)
})
})

View 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)
)
})

View File

@@ -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')

View File

@@ -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

View File

@@ -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'] },

View File

@@ -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
View File

@@ -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:

View File

@@ -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() {

View File

@@ -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', () => {

View File

@@ -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')

View File

@@ -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>

View File

@@ -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
}
}
},

View File

@@ -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
}

View File

@@ -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 })

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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'
)
})
})
})

View File

@@ -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)
}
}

View File

@@ -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"}'
)
})
})
})

View File

@@ -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',

View File

@@ -76,7 +76,8 @@ describe('createExportMenuItems', () => {
expect(submenuOptions.map((o: { content: string }) => o.content)).toEqual([
'GLB',
'OBJ',
'STL'
'STL',
'FBX'
])
})

View File

@@ -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
/**

View File

@@ -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()

View File

@@ -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',

View File

@@ -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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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)) {

View File

@@ -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
})
}
}

View File

@@ -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)) {

View File

@@ -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)
})
})

View File

@@ -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))
)
)

View File

@@ -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
View File

@@ -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 {

View File

@@ -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",

View File

@@ -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",

View File

@@ -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",
]