Compare commits

..

7 Commits

Author SHA1 Message Date
GitHub Action
bef57d5625 [automated] Apply ESLint and Oxfmt fixes 2026-06-10 22:20:48 +00:00
mattmillerai
4311019ce5 [chore] Update Ingest API types from cloud@e3c52ad 2026-06-10 22:17:08 +00:00
Robin Huang
7c2c78b537 feat: send deploy_environment as Comfy-Env header on /releases requests (#12771)
Reads `system.deploy_environment` from `/system_stats` (added in
Comfy-Org/ComfyUI#14402) and sends it as the `Comfy-Env` header when
fetching `/releases`, matching the header name the backend already uses
for outbound API node requests. The header is omitted when the backend
doesn't report the field, so older backends are unaffected.

Note: api.comfy.org must allow `Comfy-Env` in
`Access-Control-Allow-Headers` for the CORS preflight to pass.
2026-06-10 21:30:08 +00:00
Matt Miller
bd1fd0680e feat(assets): walk getAllAssetsByTag via keyset cursor (#12720)
## ELI-5

When the app needs *all* the assets for a tag (like every input image),
it asks the server for them one page at a time. Today it says "give me
page starting at item #500" (offset paging). If items get added or
removed while it's flipping through, pages shift and it can show the
same thing twice or skip something.

This switches to "give me the page *after this bookmark*" (cursor
paging). The server now hands back a `next_cursor` bookmark with each
page; we pass it to fetch the next one. Bookmarks don't slip when the
list changes underneath, so the walk is stable and drift-free.

## What

Migrates the full-walk asset pager (`getAllAssetsByTag`) from offset to
keyset (`after` / `next_cursor`) pagination, now that the list-assets
endpoint exposes a cursor contract in the generated types.

- `handleAssetRequest` accepts an `after` cursor and sends it instead of
`offset` when present (the server ignores `offset` alongside a cursor)
- `getAllAssetsByTag` resumes each page from the prior response's
`next_cursor`, and terminates when `has_more` is false or `next_cursor`
is omitted
- `next_cursor` is exposed on the asset response schema; `after` is
threaded through `getAssetsByTag` / `getAssetsPageByTag` for
cursor-aware callers
- offset remains supported for random-access callers; only the full-walk
path changes

## Why

Offset pagination double-counts or skips records when the underlying set
changes mid-walk. Keyset cursors are stable under concurrent
inserts/deletes and scale better than deep offsets.

## Stacking

Based on `update-ingest-types` because the `after`/`next_cursor` types
land there first; this targets that branch and will retarget to the
default branch once it merges. Changes here touch only the asset
service/schema, disjoint from the generated types.

## Follow-ups

The asset store's bespoke offset loops (model loader, flat-output
infinite scroll) and the missing-media resolver still walk by offset;
those migrate in separate PRs.

## Tests

`assetService.test.ts` updated to assert the cursor walk, that the first
page carries neither `after` nor `offset`, that subsequent pages resume
from `next_cursor`, and that the walk halts when `next_cursor` is absent
even if `has_more` is true. Full asset/service + missing-media + store
suites pass locally (193 tests).

---------

Co-authored-by: mattmillerai <7741082+mattmillerai@users.noreply.github.com>
Co-authored-by: GitHub Action <action@github.com>
2026-06-10 21:15:19 +00:00
Robin Huang
9617e498c9 feat: track desktop download button clicks on website (#12770)
Adds a `website:download_button_clicked` PostHog event (with `platform`
property) fired when a user clicks the desktop installer download button
on comfy.org. Previously we only had `/download` pageviews as a proxy —
autocapture is not active on the website project, so these clicks were
untracked. Includes unit tests for the new capture helper.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-06-10 20:44:57 +00:00
Terry Jia
25205c0f55 feat: add Load3DAdvanced node (#12723)
## Summary
add Load3DAdvanced node, without FE render, upload BE and HDRI.
BE https://github.com/Comfy-Org/ComfyUI/pull/14316

## Screenshots (if applicable)

https://github.com/user-attachments/assets/e561c919-bb52-4904-97da-fb01885762a7
2026-06-10 13:51:50 -04:00
Dante
598cf33ab7 [bugfix] Truncate long workspace names in workspace switcher (#12762)
## Summary

Long team workspace names wrapped onto multiple lines in the user-menu
workspace switcher, overflowing the fixed 54px rows and breaking the
dropdown layout. Applies the same single-line ellipsis pattern already
used by the current-workspace header
(`CurrentUserPopoverWorkspace.vue`).

## Changes

- **What**: `truncate` on the switcher name span, `max-w-full` on the
name row, `shrink-0` on avatar/tier badge/check icon so only the name
shrinks (`WorkspaceSwitcherPopover.vue`, 5 lines)
- Regression tests: Vitest component test + `@cloud` Playwright e2e
measuring single-line render height

Fixes
[FE-778](https://linear.app/comfyorg/issue/FE-778/bug-team-workspace-names-wrapping-to-multiple-lines-display-poorly-in)

## Red-Green Verification

| Commit | CI | Result |
|---|---|---|
| `30e04e2` test only | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378157)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27278378213)
| 🔴 new unit test + cloud e2e fail (proves tests catch the
bug) |
| `d8f9a5c` fix | [Tests
Unit](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508881)
/ [Tests
E2E](https://github.com/Comfy-Org/ComfyUI_frontend/actions/runs/27279508715)
| 🟢 same tests pass |

## Screenshots

| Before | After |
|---|---|
| <img width="320" alt="before — name wraps to 4 lines, rows collide"
src="https://github.com/user-attachments/assets/90f3286a-5b50-4477-9b5c-9d32d0b026e4"
/> | <img width="320" alt="after — single line with ellipsis, row height
intact"
src="https://github.com/user-attachments/assets/8e47bbb2-b5b1-4945-a008-68491f39dc46"
/> |

## Review Focus

- Truncation chain: the name span is a flex item, so `truncate`
(overflow-hidden) zeroes its automatic min size; `max-w-full` caps the
`items-start` row at the container width. Mirrors the header pattern —
no new component.
- Figma `Team Plan - Workspaces` (Workspaces Menu component, node
2045-14413) specifies compact single-line rows; long-name overflow was
undesigned, truncation preserves the spec'd layout.
2026-06-10 14:45:36 +00:00
56 changed files with 1756 additions and 1055 deletions

View File

@@ -8,6 +8,7 @@ import {
useDownloadUrl
} from '../../../composables/useDownloadUrl'
import { t } from '../../../i18n/translations'
import { captureDownloadClick } from '../../../scripts/posthog'
import BrandButton from '../../common/BrandButton.vue'
const { locale = 'en', class: customClass = '' } = defineProps<{
@@ -69,6 +70,7 @@ const buttons = computed<ButtonSpec[]>(() => {
size="lg"
:class="customClass"
:aria-label="btn.ariaLabel"
@click="captureDownloadClick(btn.key)"
>
<span class="inline-flex items-center gap-2">
<img

View File

@@ -53,3 +53,28 @@ describe('initPostHog', () => {
expect(result.$set_once).toHaveProperty('plan', 'free')
})
})
describe('captureDownloadClick', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.resetModules()
})
it('captures the download event with the platform', async () => {
const { initPostHog, captureDownloadClick } = await import('./posthog')
initPostHog()
captureDownloadClick('mac')
expect(hoisted.mockCapture).toHaveBeenCalledWith(
'website:download_button_clicked',
{ platform: 'mac' }
)
})
it('does not capture before PostHog is initialized', async () => {
const { captureDownloadClick } = await import('./posthog')
captureDownloadClick('windows')
expect(hoisted.mockCapture).not.toHaveBeenCalled()
})
})

View File

@@ -38,3 +38,12 @@ export function capturePageview() {
console.error('PostHog pageview capture failed', error)
}
}
export function captureDownloadClick(platform: string) {
if (!initialized) return
try {
posthog.capture('website:download_button_clicked', { platform })
} catch (error) {
console.error('PostHog download click capture failed', error)
}
}

View File

@@ -1,61 +0,0 @@
import { expect } from '@playwright/test'
import type { Asset } from '@comfyorg/ingest-types'
import { createCloudAssetsFixture } from '@e2e/fixtures/assetApiFixture'
import { STABLE_CHECKPOINT } from '@e2e/fixtures/data/assetFixtures'
const CLOUD_ASSETS: Asset[] = [STABLE_CHECKPOINT]
const test = createCloudAssetsFixture(CLOUD_ASSETS)
test.describe('Browse Model Assets - Use button', { tag: '@cloud' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.Assets.UseAssetAPI', true)
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.nodeOps.clearGraph()
})
test('Use button ghost-places a loader populated with the model', async ({
comfyPage
}) => {
await comfyPage.command.executeCommand('Comfy.BrowseModelAssets')
const modal = comfyPage.page.locator(
'[data-component-id="AssetBrowserModal"]'
)
await expect(modal).toBeVisible()
const card = comfyPage.page.locator(
`[data-component-id="AssetCard"][data-asset-id="${STABLE_CHECKPOINT.id}"]`
)
await expect(card).toBeVisible()
await card.getByRole('button', { name: 'Use' }).click()
// Dialog closes and the ghost is armed; the node is not placed until the
// user clicks the canvas.
await expect(modal).toBeHidden()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe(STABLE_CHECKPOINT.name)
})
})

View File

@@ -233,64 +233,4 @@ test.describe('Model library sidebar - empty state', () => {
await expect(tab.folderNodes).toHaveCount(0)
await expect(tab.leafNodes).toHaveCount(0)
})
test.describe('Model library sidebar - add node', () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.mockFoldersWithFiles(MOCK_FOLDERS)
await comfyPage.setup()
await comfyPage.nodeOps.clearGraph()
})
test.afterEach(async ({ comfyPage }) => {
await comfyPage.modelLibrary.clearMocks()
})
test('Clicking a model defers creation until placed on the canvas', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
await expect
.poll(() => comfyPage.nodeOps.getGraphNodesCount(), { timeout: 1000 })
.toBe(0)
const canvasBox = (await comfyPage.canvas.boundingBox())!
await comfyPage.canvas.click({
position: { x: canvasBox.width / 2, y: canvasBox.height / 2 }
})
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(1)
await expect
.poll(() => comfyPage.nodeOps.getSelectedGraphNodesCount())
.toBe(1)
const [loader] = await comfyPage.nodeOps.getNodeRefsByType(
'CheckpointLoaderSimple'
)
expect(loader).toBeDefined()
const widget = await loader.getWidgetByName('ckpt_name')
expect(await widget.getValue()).toBe('sd_xl_base_1.0.safetensors')
})
test('Ghost preview shows the model in the loader widget before placing', async ({
comfyPage
}) => {
const tab = comfyPage.menu.modelLibraryTab
await tab.open()
await tab.getFolderByLabel('checkpoints').click()
await expect(tab.getLeafByLabel('sd_xl_base_1.0')).toBeVisible()
await tab.getLeafByLabel('sd_xl_base_1.0').click()
const ghost = comfyPage.page.locator(
'[data-node-id="preview-CheckpointLoaderSimple"]'
)
await expect(ghost).toContainText('sd_xl_base_1.0.safetensors')
})
})
})

View File

@@ -0,0 +1,101 @@
import { expect } from '@playwright/test'
import type { RemoteConfig } from '@/platform/remoteConfig/types'
import type { WorkspaceWithRole } from '@/platform/workspace/api/workspaceApi'
import type { WorkspaceTokenResponse } from '@/platform/workspace/stores/workspaceAuthStore'
import { comfyPageFixture } from '@e2e/fixtures/ComfyPage'
const PERSONAL_WORKSPACE_NAME = 'Personal Workspace'
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
// text-sm rows render a single 20px line; a wrapped name is 40px+.
const SINGLE_LINE_MAX_HEIGHT_PX = 28
const mockRemoteConfig: RemoteConfig = { team_workspaces_enabled: true }
const mockListWorkspacesResponse: { workspaces: WorkspaceWithRole[] } = {
workspaces: [
{
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal',
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
role: 'owner'
},
{
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
created_at: '2026-01-02T00:00:00Z',
joined_at: '2026-01-02T00:00:00Z',
role: 'member'
}
]
}
const mockTokenResponse: WorkspaceTokenResponse = {
token: 'mock-workspace-token',
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
workspace: {
id: 'ws-personal',
name: PERSONAL_WORKSPACE_NAME,
type: 'personal'
},
role: 'owner',
permissions: []
}
const test = comfyPageFixture.extend({
page: async ({ page }, use) => {
await page.route('**/api/features', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockRemoteConfig)
})
)
await page.route('**/api/workspaces', async (route) => {
if (route.request().method() !== 'GET') {
await route.fallback()
return
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockListWorkspacesResponse)
})
})
await page.route('**/api/auth/token', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTokenResponse)
})
)
await use(page)
}
})
test.describe('Workspace switcher', { tag: '@cloud' }, () => {
test('renders a long team workspace name on a single line', async ({
comfyPage
}) => {
const page = comfyPage.page
await comfyPage.toast.closeToasts()
await page.getByRole('button', { name: 'Current user' }).click()
await page.getByText(PERSONAL_WORKSPACE_NAME).click()
const longName = page.getByText(LONG_WORKSPACE_NAME)
await expect(longName).toBeVisible()
const box = await longName.boundingBox()
expect(box).not.toBeNull()
expect(box!.height).toBeLessThan(SINGLE_LINE_MAX_HEIGHT_PX)
})
})

View File

@@ -2775,6 +2775,10 @@ export type SystemStatsResponse = {
* ComfyUI version
*/
comfyui_version: string
/**
* How this ComfyUI instance is deployed (e.g. cloud, local-git, local-portable, local-desktop)
*/
deploy_environment?: string
/**
* ComfyUI frontend version (commit hash or tag)
*/

View File

@@ -1624,6 +1624,7 @@ export const zSystemStatsResponse = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
comfyui_frontend_version: z.string().optional(),
workflow_templates_version: z.string().optional(),
cloud_version: z.string().optional(),

View File

@@ -111,6 +111,7 @@ describe('formatUtil', () => {
expect(getMediaTypeFromFilename('scene.fbx')).toBe('3D')
expect(getMediaTypeFromFilename('asset.gltf')).toBe('3D')
expect(getMediaTypeFromFilename('binary.glb')).toBe('3D')
expect(getMediaTypeFromFilename('print.stl')).toBe('3D')
expect(getMediaTypeFromFilename('apple.usdz')).toBe('3D')
expect(getMediaTypeFromFilename('scan.ply')).toBe('3D')
})

View File

@@ -591,7 +591,15 @@ const IMAGE_EXTENSIONS = [
] as const
const VIDEO_EXTENSIONS = ['mp4', 'm4v', 'webm', 'mov', 'avi', 'mkv'] as const
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'flac'] as const
const THREE_D_EXTENSIONS = ['obj', 'fbx', 'gltf', 'glb', 'usdz', 'ply'] as const
const THREE_D_EXTENSIONS = [
'obj',
'fbx',
'gltf',
'glb',
'stl',
'usdz',
'ply'
] as const
const TEXT_EXTENSIONS = [
'txt',
'md',

View File

@@ -355,7 +355,7 @@ describe('TreeExplorerV2Node', () => {
const nodeDiv = getTreeNode(container)
await fireEvent.dragStart(nodeDiv)
expect(mockStartDrag).toHaveBeenCalledWith(mockData, { mode: 'native' })
expect(mockStartDrag).toHaveBeenCalledWith(mockData, 'native')
})
it('does not call startDrag for folder items on dragstart', async () => {

View File

@@ -93,7 +93,6 @@
<NodeTooltip v-if="tooltipEnabled" />
<NodeSearchboxPopover ref="nodeSearchboxPopoverRef" />
<NodeDragPreview />
<VueNodeSwitchPopup />
<!-- Initialize components after comfyApp is ready. useAbsolutePosition requires
@@ -137,7 +136,6 @@ import GraphCanvasMenu from '@/components/graph/GraphCanvasMenu.vue'
import LinkOverlayCanvas from '@/components/graph/LinkOverlayCanvas.vue'
import NodeTooltip from '@/components/graph/NodeTooltip.vue'
import NodeContextMenu from '@/components/graph/NodeContextMenu.vue'
import NodeDragPreview from '@/components/graph/NodeDragPreview.vue'
import SelectionToolbox from '@/components/graph/SelectionToolbox.vue'
import TitleEditor from '@/components/graph/TitleEditor.vue'
import NodePropertiesPanel from '@/components/rightSidePanel/RightSidePanel.vue'

View File

@@ -1,57 +0,0 @@
<template>
<Teleport to="body">
<div
v-if="showGhost && rafPosition"
class="pointer-events-none fixed top-0 left-0 z-10000 will-change-transform"
:style="{
transform: `translate(${rafPosition.x + 12}px, ${rafPosition.y + 12}px)`
}"
>
<div class="origin-top-left scale-50 opacity-80">
<LGraphNodePreview
:node-def="draggedNode!"
:widget-values="pendingWidgetValues"
position="relative"
/>
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { useMouse, useRafFn } from '@vueuse/core'
import { computed, shallowRef, watch } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
const { isDragging, draggedNode, pendingWidgetValues } = useNodeDragToCanvas()
const { x, y, sourceType } = useMouse({ type: 'client' })
const showGhost = computed(() => Boolean(isDragging.value && draggedNode.value))
const rafPosition = shallowRef<{ x: number; y: number }>()
const { pause, resume } = useRafFn(
() => {
if (sourceType.value === null) return
const pos = rafPosition.value
if (pos && pos.x === x.value && pos.y === y.value) return
rafPosition.value = { x: x.value, y: y.value }
},
{ immediate: false }
)
watch(
showGhost,
(show) => {
if (show) {
resume()
} else {
pause()
rafPosition.value = undefined
}
},
{ immediate: true }
)
</script>

View File

@@ -23,6 +23,8 @@
:can-use-gizmo="canUseGizmo"
:can-use-lighting="canUseLighting"
:can-export="canExport"
:can-use-hdri="canUseHdri"
:can-use-background-image="canUseBackgroundImage"
:material-modes="materialModes"
:has-skeleton="hasSkeleton"
@update-background-image="handleBackgroundImageUpdate"
@@ -86,7 +88,7 @@
/>
<RecordingControls
v-if="!isPreview"
v-if="canUseRecording && !isPreview"
v-model:is-recording="isRecording"
v-model:has-recording="hasRecording"
v-model:recording-duration="recordingDuration"
@@ -117,9 +119,18 @@ import { resolveNode } from '@/utils/litegraphUtil'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
const props = defineProps<{
const {
widget,
nodeId,
canUseRecording = true,
canUseHdri = true,
canUseBackgroundImage = true
} = defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
canUseRecording?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
}>()
function isComponentWidget(
@@ -130,11 +141,11 @@ function isComponentWidget(
const node = ref<LGraphNode | null>(null)
if (isComponentWidget(props.widget)) {
node.value = props.widget.node
} else if (props.nodeId) {
if (isComponentWidget(widget)) {
node.value = widget.node
} else if (nodeId) {
onMounted(() => {
node.value = resolveNode(props.nodeId!) ?? null
node.value = resolveNode(nodeId) ?? null
})
}

View File

@@ -0,0 +1,47 @@
import { render } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { defineComponent, h, ref } from 'vue'
const lastProps = ref<Record<string, unknown> | null>(null)
vi.mock('@/components/load3d/Load3D.vue', () => ({
default: defineComponent({
name: 'Load3D',
props: {
widget: { type: null, required: false, default: undefined },
nodeId: { type: null, required: false, default: undefined },
canUseRecording: { type: Boolean, default: true },
canUseHdri: { type: Boolean, default: true },
canUseBackgroundImage: { type: Boolean, default: true }
},
setup(props: Record<string, unknown>) {
lastProps.value = { ...props }
return () => h('div', { 'data-testid': 'load3d-stub' })
}
})
}))
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
describe('Load3DAdvanced', () => {
it('renders the inner Load3D with all expressive features disabled', () => {
const MOCK_NODE = { id: 'node', type: 'Load3DAdvanced' }
render(Load3DAdvanced, {
props: {
widget: { node: MOCK_NODE } as never
}
})
expect(lastProps.value).toMatchObject({
canUseRecording: false,
canUseHdri: false,
canUseBackgroundImage: false
})
})
it('forwards widget and nodeId to the inner Load3D', () => {
const widget = { node: { id: 'a', type: 'Load3DAdvanced' } }
render(Load3DAdvanced, { props: { widget: widget as never, nodeId: 'a' } })
expect(lastProps.value?.widget).toEqual(widget)
expect(lastProps.value?.nodeId).toBe('a')
})
})

View File

@@ -0,0 +1,21 @@
<template>
<Load3D
:widget="widget"
:node-id="nodeId"
:can-use-recording="false"
:can-use-hdri="false"
:can-use-background-image="false"
/>
</template>
<script setup lang="ts">
import Load3D from '@/components/load3d/Load3D.vue'
import type { NodeId } from '@/platform/workflow/validation/schemas/workflowSchema'
import type { ComponentWidget } from '@/scripts/domWidget'
import type { SimplifiedWidget } from '@/types/simplifiedWidget'
defineProps<{
widget: ComponentWidget<string[]> | SimplifiedWidget
nodeId?: NodeId
}>()
</script>

View File

@@ -52,6 +52,7 @@
v-model:background-image="sceneConfig!.backgroundImage"
v-model:background-render-mode="sceneConfig!.backgroundRenderMode"
v-model:fov="cameraConfig!.fov"
:show-background-image="canUseBackgroundImage"
:hdri-active="
!!lightConfig?.hdri?.hdriPath && !!lightConfig?.hdri?.enabled
"
@@ -81,6 +82,7 @@
/>
<HDRIControls
v-if="canUseHdri"
v-model:hdri-config="lightConfig!.hdri"
:has-background-image="!!sceneConfig?.backgroundImage"
@update-hdri-file="handleHDRIFileUpdate"
@@ -129,12 +131,16 @@ const {
canUseGizmo = true,
canUseLighting = true,
canExport = true,
canUseHdri = true,
canUseBackgroundImage = true,
materialModes = ['original', 'normal', 'wireframe'],
hasSkeleton = false
} = defineProps<{
canUseGizmo?: boolean
canUseLighting?: boolean
canExport?: boolean
canUseHdri?: boolean
canUseBackgroundImage?: boolean
materialModes?: readonly MaterialMode[]
hasSkeleton?: boolean
}>()

View File

@@ -37,7 +37,7 @@
</Button>
</div>
<div v-if="!hasBackgroundImage">
<div v-if="showBackgroundImage && !hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.uploadBackgroundImage'),
@@ -61,7 +61,7 @@
</div>
</template>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.panoramaMode'),
@@ -83,12 +83,16 @@
</div>
<PopupSlider
v-if="hasBackgroundImage && backgroundRenderMode === 'panorama'"
v-if="
showBackgroundImage &&
hasBackgroundImage &&
backgroundRenderMode === 'panorama'
"
v-model="fov"
:tooltip-text="$t('load3d.fov')"
/>
<div v-if="hasBackgroundImage">
<div v-if="showBackgroundImage && hasBackgroundImage">
<Button
v-tooltip.right="{
value: $t('load3d.removeBackgroundImage'),
@@ -114,8 +118,9 @@ import Button from '@/components/ui/button/Button.vue'
import type { BackgroundRenderModeType } from '@/extensions/core/load3d/interfaces'
import { cn } from '@comfyorg/tailwind-utils'
const { hdriActive = false } = defineProps<{
const { hdriActive = false, showBackgroundImage = true } = defineProps<{
hdriActive?: boolean
showBackgroundImage?: boolean
}>()
const emit = defineEmits<{

View File

@@ -14,7 +14,7 @@ const {
captureRoot,
getRoot,
resetRoot,
mockStartDrag,
mockAddNodeOnGraph,
mockGetNodeProvider,
mockToggleNodeOnEvent,
mockRefreshModelFolder,
@@ -29,7 +29,7 @@ const {
resetRoot: () => {
capturedRoot = null
},
mockStartDrag: vi.fn(),
mockAddNodeOnGraph: vi.fn(),
mockGetNodeProvider: vi.fn(),
mockToggleNodeOnEvent: vi.fn(),
mockRefreshModelFolder: vi.fn().mockResolvedValue(undefined),
@@ -37,8 +37,8 @@ const {
}
})
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
vi.mock('@/services/litegraphService', () => ({
useLitegraphService: () => ({ addNodeOnGraph: mockAddNodeOnGraph })
}))
vi.mock('@/stores/modelToNodeStore', () => ({
@@ -173,13 +173,16 @@ describe('ModelLibrarySidebarTab', () => {
expect(screen.getByTestId('search-input')).toBeInTheDocument()
})
it('starts a ghost drag carrying the widget value to fill on placement', async () => {
it('handles model click and adds node to graph', async () => {
const mockNodeDef = { name: 'CheckpointLoaderSimple' }
const mockWidget = { name: 'ckpt_name', value: '' }
const mockGraphNode = { widgets: [mockWidget] }
mockGetNodeProvider.mockReturnValue({
nodeDef: mockNodeDef,
key: 'ckpt_name'
})
mockAddNodeOnGraph.mockReturnValue(mockGraphNode)
renderComponent()
await nextTick()
@@ -195,9 +198,8 @@ describe('ModelLibrarySidebarTab', () => {
await modelLeaf?.handleClick?.(mockEvent)
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' }
})
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef)
expect(mockWidget.value).toBe('model.safetensors')
})
it('toggles folder expansion on click', async () => {

View File

@@ -63,9 +63,10 @@ import SidebarTabTemplate from '@/components/sidebar/tabs/SidebarTabTemplate.vue
import ElectronDownloadItems from '@/components/sidebar/tabs/modelLibrary/ElectronDownloadItems.vue'
import ModelTreeLeaf from '@/components/sidebar/tabs/modelLibrary/ModelTreeLeaf.vue'
import Button from '@/components/ui/button/Button.vue'
import { startModelLoaderDrag } from '@/composables/node/startModelNodeDragFromAsset'
import { useTreeExpansion } from '@/composables/useTreeExpansion'
import { useSettingStore } from '@/platform/settings/settingStore'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useLitegraphService } from '@/services/litegraphService'
import { useAssetDownloadStore } from '@/stores/assetDownloadStore'
import type { ComfyModelDef, ModelFolder } from '@/stores/modelStore'
import { ResourceState, useModelStore } from '@/stores/modelStore'
@@ -155,7 +156,15 @@ const renderedRoot = computed<TreeExplorerNode<ModelOrFolder>>(() => {
if (this.leaf && model) {
const provider = modelToNodeStore.getNodeProvider(model.directory)
if (provider) {
startModelLoaderDrag(provider, model.file_name)
const graphNode = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(provider.nodeDef)
)
const widget = graphNode?.widgets?.find(
(widget) => widget.name === provider.key
)
if (widget) {
widget.value = model.file_name
}
}
} else {
toggleNodeOnEvent(e, node)

View File

@@ -31,8 +31,11 @@ vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({
isDragging: { value: false },
draggedNode: { value: null },
cursorPosition: { value: { x: 0, y: 0 } },
startDrag: vi.fn(),
cancelDrag: vi.fn()
cancelDrag: vi.fn(),
setupGlobalListeners: vi.fn(),
cleanupGlobalListeners: vi.fn()
})
}))

View File

@@ -115,6 +115,7 @@
</div>
</template>
<template #body>
<NodeDragPreview />
<div class="flex h-full flex-col">
<div
v-if="hasNoMatches"
@@ -214,6 +215,7 @@ import type {
import AllNodesPanel from './nodeLibrary/AllNodesPanel.vue'
import BlueprintsPanel from './nodeLibrary/BlueprintsPanel.vue'
import EssentialNodesPanel from './nodeLibrary/EssentialNodesPanel.vue'
import NodeDragPreview from './nodeLibrary/NodeDragPreview.vue'
import SidebarTabTemplate from './SidebarTabTemplate.vue'
const { flags } = useFeatureFlags()

View File

@@ -0,0 +1,69 @@
<template>
<Teleport to="body">
<div
v-if="isDragging && draggedNode && showPreview"
class="pointer-events-none fixed z-10000"
:style="{
left: `${previewPosition.x + 12}px`,
top: `${previewPosition.y + 12}px`
}"
>
<div class="origin-top-left scale-50 opacity-80">
<LGraphNodePreview :node-def="draggedNode" position="relative" />
</div>
</div>
</Teleport>
</template>
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
const {
isDragging,
draggedNode,
cursorPosition,
dragMode,
setupGlobalListeners,
cleanupGlobalListeners
} = useNodeDragToCanvas()
const nativeDragPosition = ref({ x: 0, y: 0 })
const previewPosition = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value
}
return cursorPosition.value
})
const showPreview = computed(() => {
if (dragMode.value === 'native') {
return nativeDragPosition.value.x > 0 || nativeDragPosition.value.y > 0
}
return true
})
function handleDrag(e: DragEvent) {
if (e.clientX === 0 && e.clientY === 0) return
nativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function handleDragEnd() {
nativeDragPosition.value = { x: 0, y: 0 }
}
onMounted(() => {
setupGlobalListeners()
document.addEventListener('drag', handleDrag)
document.addEventListener('dragend', handleDragEnd)
})
onUnmounted(() => {
cleanupGlobalListeners()
document.removeEventListener('drag', handleDrag)
document.removeEventListener('dragend', handleDragEnd)
})
</script>

View File

@@ -1,73 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
const { mockStartDrag, mockGetNodeProvider } = vi.hoisted(() => ({
mockStartDrag: vi.fn(),
mockGetNodeProvider: vi.fn()
}))
vi.mock('@/composables/node/useNodeDragToCanvas', () => ({
useNodeDragToCanvas: () => ({ startDrag: mockStartDrag })
}))
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
}))
function createAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
name: 'sd_xl_base_1.0.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: { filename: 'sd_xl_base_1.0.safetensors' },
...overrides
}
}
describe('startModelNodeDragFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('starts a ghost drag for the resolved node carrying the widget value', () => {
const nodeDef = { name: 'CheckpointLoaderSimple' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: 'ckpt_name' })
const error = startModelNodeDragFromAsset(createAsset())
expect(error).toBeUndefined()
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' }
})
})
it('carries no widget value when the provider has no key', () => {
const nodeDef = { name: 'FL_ChatterboxVC' }
mockGetNodeProvider.mockReturnValue({ nodeDef, key: '' })
startModelNodeDragFromAsset(
createAsset({
tags: ['models', 'chatterbox/chatterbox_vc'],
user_metadata: { filename: 'chatterbox_vc_model.pt' }
})
)
expect(mockStartDrag).toHaveBeenCalledWith(nodeDef, {
widgetValues: undefined
})
})
it('returns the resolution error and does not start a drag for an invalid asset', () => {
mockGetNodeProvider.mockReturnValue(null)
const error = startModelNodeDragFromAsset(createAsset())
expect(error?.code).toBe('NO_PROVIDER')
expect(mockStartDrag).not.toHaveBeenCalled()
})
})

View File

@@ -1,35 +0,0 @@
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import type { ResolveModelNodeError } from '@/platform/assets/utils/resolveModelNodeFromAsset'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
/**
* Arms a ghost drag for a model loader node. Providers with no widget key
* (auto-load nodes) start the drag without widget values.
*/
export function startModelLoaderDrag(
provider: ModelNodeProvider,
filename: string
) {
const widgetValues = provider.key ? { [provider.key]: filename } : undefined
useNodeDragToCanvas().startDrag(provider.nodeDef, { widgetValues })
}
/**
* Starts a ghost drag for the model loader node described by an asset. The
* node is created where the user next clicks the canvas, with the asset's
* filename written into the loader widget.
*
* @returns the resolution error when the asset cannot be mapped to a node,
* otherwise `undefined`.
*/
export function startModelNodeDragFromAsset(
asset: AssetItem
): ResolveModelNodeError | undefined {
const resolved = resolveModelNodeFromAsset(asset)
if (!resolved.success) return resolved.error
const { provider, filename } = resolved.value
startModelLoaderDrag(provider, filename)
}

View File

@@ -7,8 +7,7 @@ const {
mockAddNodeOnGraph,
mockConvertEventToCanvasOffset,
mockSelectItems,
mockCanvas,
mockToastAdd
mockCanvas
} = vi.hoisted(() => {
const mockConvertEventToCanvasOffset = vi.fn()
const mockSelectItems = vi.fn()
@@ -16,7 +15,6 @@ const {
mockAddNodeOnGraph: vi.fn(),
mockConvertEventToCanvasOffset,
mockSelectItems,
mockToastAdd: vi.fn(),
mockCanvas: {
canvas: {
getBoundingClientRect: vi.fn()
@@ -39,12 +37,6 @@ vi.mock('@/services/litegraphService', () => ({
}))
}))
vi.mock('@/platform/updates/common/toastStore', () => ({
useToastStore: vi.fn(() => ({ add: mockToastAdd }))
}))
vi.mock('@/i18n', () => ({ t: (key: string) => key }))
describe('useNodeDragToCanvas', () => {
let useNodeDragToCanvas: typeof UseNodeDragToCanvasType
@@ -62,8 +54,8 @@ describe('useNodeDragToCanvas', () => {
})
afterEach(() => {
const { cancelDrag } = useNodeDragToCanvas()
cancelDrag()
const { cleanupGlobalListeners } = useNodeDragToCanvas()
cleanupGlobalListeners()
vi.restoreAllMocks()
})
@@ -79,6 +71,22 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(true)
expect(draggedNode.value).toBe(mockNodeDef)
})
it('should set dragMode to click by default', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
expect(dragMode.value).toBe('click')
})
it('should set dragMode to native when specified', () => {
const { dragMode, startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
})
})
describe('cancelDrag', () => {
@@ -94,78 +102,75 @@ describe('useNodeDragToCanvas', () => {
expect(isDragging.value).toBe(false)
expect(draggedNode.value).toBeNull()
})
})
describe('drag listener lifecycle', () => {
it('should attach document listeners on startDrag', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { startDrag } = useNodeDragToCanvas()
it('should reset dragMode to click', () => {
const { dragMode, startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
startDrag(mockNodeDef, 'native')
expect(dragMode.value).toBe('native')
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
true
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('should not attach drag listeners until a drag starts', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
useNodeDragToCanvas()
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
expect(addEventListenerSpy).not.toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('should detach document listeners on cancelDrag', () => {
const removeEventListenerSpy = vi.spyOn(document, 'removeEventListener')
const { startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
cancelDrag()
expect(removeEventListenerSpy).toHaveBeenCalledWith(
expect(dragMode.value).toBe('click')
})
})
describe('setupGlobalListeners', () => {
it('should add event listeners to document', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointermove',
expect.any(Function)
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerdown',
expect.any(Function),
true
)
expect(removeEventListenerSpy).toHaveBeenCalledWith(
expect(addEventListenerSpy).toHaveBeenCalledWith(
'pointerup',
expect.any(Function),
true
)
expect(addEventListenerSpy).toHaveBeenCalledWith(
'keydown',
expect.any(Function)
)
})
it('should only attach listeners once across re-arms', () => {
it('should only setup listeners once', () => {
const addEventListenerSpy = vi.spyOn(document, 'addEventListener')
const { startDrag } = useNodeDragToCanvas()
const { setupGlobalListeners } = useNodeDragToCanvas()
startDrag(mockNodeDef)
setupGlobalListeners()
const callCount = addEventListenerSpy.mock.calls.length
startDrag(mockNodeDef)
setupGlobalListeners()
expect(addEventListenerSpy.mock.calls.length).toBe(callCount)
})
})
describe('cursorPosition', () => {
it('should update on pointermove', () => {
const { cursorPosition, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
const pointerEvent = new PointerEvent('pointermove', {
clientX: 100,
clientY: 200
})
document.dispatchEvent(pointerEvent)
expect(cursorPosition.value).toEqual({ x: 100, y: 200 })
})
})
describe('endDrag behavior', () => {
it('should add node when pointer is over canvas', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
@@ -176,7 +181,9 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag } = useNodeDragToCanvas()
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
@@ -199,7 +206,10 @@ describe('useNodeDragToCanvas', () => {
bottom: 500
})
const { startDrag, isDragging } = useNodeDragToCanvas()
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
const pointerEvent = new PointerEvent('pointerup', {
@@ -214,7 +224,10 @@ describe('useNodeDragToCanvas', () => {
})
it('should cancel drag on Escape key', () => {
const { startDrag, isDragging } = useNodeDragToCanvas()
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(isDragging.value).toBe(true)
@@ -226,7 +239,10 @@ describe('useNodeDragToCanvas', () => {
})
it('should not cancel drag on other keys', () => {
const { startDrag, isDragging } = useNodeDragToCanvas()
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
const keyEvent = new KeyboardEvent('keydown', { key: 'Enter' })
@@ -246,7 +262,8 @@ describe('useNodeDragToCanvas', () => {
const placedNode = { id: 1 }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const { startDrag } = useNodeDragToCanvas()
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
document.dispatchEvent(
@@ -260,97 +277,6 @@ describe('useNodeDragToCanvas', () => {
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
})
it('should apply the requested widget values to the placed node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const widget = { name: 'ckpt_name', value: '' }
mockAddNodeOnGraph.mockReturnValue({ id: 1, widgets: [widget] })
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(widget.value).toBe('model.safetensors')
})
it('should still place the node when a requested widget is missing', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const placedNode = { id: 1, widgets: [] }
mockAddNodeOnGraph.mockReturnValue(placedNode)
const consoleErrorSpy = vi
.spyOn(console, 'error')
.mockImplementation(() => {})
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, {
widgetValues: { ckpt_name: 'model.safetensors' }
})
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockSelectItems).toHaveBeenCalledWith([placedNode])
expect(mockToastAdd).not.toHaveBeenCalled()
expect(consoleErrorSpy).toHaveBeenCalledWith(
expect.stringContaining('ckpt_name')
)
})
it('should show an error toast when the graph fails to add the node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
right: 500,
top: 0,
bottom: 500
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
document.dispatchEvent(
new PointerEvent('pointerup', {
clientX: 250,
clientY: 250,
bubbles: true
})
)
expect(mockToastAdd).toHaveBeenCalledWith(
expect.objectContaining({
severity: 'error',
detail: 'assetBrowser.failedToCreateNode'
})
)
})
it('should not call selectItems when graph returns no node', () => {
mockCanvas.canvas.getBoundingClientRect.mockReturnValue({
left: 0,
@@ -360,9 +286,9 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
mockAddNodeOnGraph.mockReturnValue(null)
vi.spyOn(console, 'error').mockImplementation(() => {})
const { startDrag } = useNodeDragToCanvas()
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
document.dispatchEvent(
@@ -385,8 +311,11 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([150, 150])
const { startDrag, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const { startDrag, setupGlobalListeners, isDragging } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
const pointerEvent = new PointerEvent('pointerup', {
clientX: 250,
@@ -412,7 +341,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
startDrag(mockNodeDef, 'native')
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).toHaveBeenCalledWith(mockNodeDef, {
@@ -430,7 +359,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
startDrag(mockNodeDef, 'native')
handleNativeDrop(600, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
@@ -448,7 +377,7 @@ describe('useNodeDragToCanvas', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef)
startDrag(mockNodeDef, 'click')
handleNativeDrop(250, 250)
expect(mockAddNodeOnGraph).not.toHaveBeenCalled()
@@ -463,12 +392,14 @@ describe('useNodeDragToCanvas', () => {
})
mockConvertEventToCanvasOffset.mockReturnValue([200, 200])
const { startDrag, handleNativeDrop, isDragging } = useNodeDragToCanvas()
const { startDrag, handleNativeDrop, isDragging, dragMode } =
useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
startDrag(mockNodeDef, 'native')
handleNativeDrop(250, 250)
expect(isDragging.value).toBe(false)
expect(dragMode.value).toBe('click')
})
})
@@ -495,29 +426,31 @@ describe('useNodeDragToCanvas', () => {
})
it('should stop propagation when in click-drag mode over canvas', () => {
const { startDrag } = useNodeDragToCanvas()
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(dispatchPointerDown(250, 250)).toHaveBeenCalled()
})
it('should not stop propagation once the drag is cancelled', () => {
const { startDrag, cancelDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef)
cancelDrag()
it('should not stop propagation when not dragging', () => {
const { setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation in native drag mode', () => {
const { startDrag } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
expect(dispatchPointerDown(250, 250)).not.toHaveBeenCalled()
})
it('should not stop propagation when pointer is outside canvas', () => {
const { startDrag } = useNodeDragToCanvas()
const { startDrag, setupGlobalListeners } = useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef)
expect(dispatchPointerDown(600, 250)).not.toHaveBeenCalled()
@@ -544,8 +477,10 @@ describe('useNodeDragToCanvas', () => {
}
it('should prefer tracked drag position over dragend coordinates', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
fireDrag(250, 250)
// dragend supplies a bad position (the Firefox bug); the tracked one
@@ -559,8 +494,10 @@ describe('useNodeDragToCanvas', () => {
})
it('should ignore drag events with (0, 0)', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
fireDrag(250, 250)
fireDrag(0, 0)
@@ -573,8 +510,10 @@ describe('useNodeDragToCanvas', () => {
})
it('should fall back to dragend coordinates when no drag fired', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
handleNativeDrop(250, 250)
@@ -584,14 +523,32 @@ describe('useNodeDragToCanvas', () => {
})
})
it('should ignore dragover events fired before startDrag', () => {
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
fireDrag(250, 250)
startDrag(mockNodeDef, 'native')
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenCalledWith({
clientX: 300,
clientY: 300
})
})
it('should clear tracked position between drags', () => {
const { startDrag, handleNativeDrop } = useNodeDragToCanvas()
startDrag(mockNodeDef, { mode: 'native' })
const { startDrag, setupGlobalListeners, handleNativeDrop } =
useNodeDragToCanvas()
setupGlobalListeners()
startDrag(mockNodeDef, 'native')
fireDrag(250, 250)
handleNativeDrop(1505, 102)
// Second drag - no drag events, so we should fall back to args.
startDrag(mockNodeDef, { mode: 'native' })
startDrag(mockNodeDef, 'native')
handleNativeDrop(300, 300)
expect(mockConvertEventToCanvasOffset).toHaveBeenLastCalledWith({

View File

@@ -1,29 +1,23 @@
import { ref, shallowRef } from 'vue'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/litegraph'
import { withNodeAddSource } from '@/platform/telemetry/nodeAdded/nodeAddSource'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
import { useLitegraphService } from '@/services/litegraphService'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
type DragMode = 'click' | 'native'
type WidgetValues = Record<string, string>
type Position = { x: number; y: number }
interface StartDragOptions {
mode?: DragMode
widgetValues?: WidgetValues
}
const isDragging = ref(false)
const draggedNode = shallowRef<ComfyNodeDefImpl | null>(null)
const cursorPosition = ref({ x: 0, y: 0 })
const dragMode = ref<DragMode>('click')
const lastNativeDragPosition = shallowRef<Position>()
const pendingWidgetValues = shallowRef<WidgetValues>()
const lastNativeDragPosition = shallowRef<{ x: number; y: number }>()
let listenersSetup = false
function updatePosition(e: PointerEvent) {
cursorPosition.value = { x: e.clientX, y: e.clientY }
}
// Firefox dragend can report stale clientX/Y and `drag` can fire with
// (0, 0). dragover on the target reliably reports real client coords.
// https://bugzilla.mozilla.org/show_bug.cgi?id=1773886
@@ -33,15 +27,11 @@ function trackNativeDragPosition(e: DragEvent) {
lastNativeDragPosition.value = { x: e.clientX, y: e.clientY }
}
function applyWidgetValues(node: LGraphNode, values: WidgetValues) {
for (const [name, value] of Object.entries(values)) {
const widget = node.widgets?.find((w) => w.name === name)
if (!widget) {
console.error(`Widget ${name} not found on node ${node.type}`)
continue
}
widget.value = value
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
lastNativeDragPosition.value = undefined
}
function isOverCanvas(clientX: number, clientY: number): boolean {
@@ -72,19 +62,7 @@ function addNodeAtPosition(clientX: number, clientY: number): boolean {
const node = withNodeAddSource('sidebar_drag', () =>
useLitegraphService().addNodeOnGraph(nodeDef, { pos })
)
if (!node) {
console.error(`Failed to add node to graph: ${nodeDef.name}`)
useToastStore().add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode')
})
return true
}
if (pendingWidgetValues.value)
applyWidgetValues(node, pendingWidgetValues.value)
canvas.selectItems([node])
if (node) canvas.selectItems([node])
return true
}
@@ -114,6 +92,7 @@ function setupGlobalListeners() {
if (listenersSetup) return
listenersSetup = true
document.addEventListener('pointermove', updatePosition)
document.addEventListener('pointerdown', blockCommitPointerDown, true)
document.addEventListener('pointerup', endDrag, true)
document.addEventListener('keydown', handleKeydown)
@@ -124,31 +103,22 @@ function cleanupGlobalListeners() {
if (!listenersSetup) return
listenersSetup = false
document.removeEventListener('pointermove', updatePosition)
document.removeEventListener('pointerdown', blockCommitPointerDown, true)
document.removeEventListener('pointerup', endDrag, true)
document.removeEventListener('keydown', handleKeydown)
document.removeEventListener('dragover', trackNativeDragPosition)
}
function cancelDrag() {
isDragging.value = false
draggedNode.value = null
dragMode.value = 'click'
lastNativeDragPosition.value = undefined
pendingWidgetValues.value = undefined
cleanupGlobalListeners()
if (isDragging.value && dragMode.value === 'click') {
cancelDrag()
}
}
export function useNodeDragToCanvas() {
function startDrag(
nodeDef: ComfyNodeDefImpl,
{ mode = 'click', widgetValues }: StartDragOptions = {}
) {
function startDrag(nodeDef: ComfyNodeDefImpl, mode: DragMode = 'click') {
isDragging.value = true
draggedNode.value = nodeDef
dragMode.value = mode
pendingWidgetValues.value = widgetValues
setupGlobalListeners()
}
function handleNativeDrop(clientX: number, clientY: number) {
@@ -164,9 +134,12 @@ export function useNodeDragToCanvas() {
return {
isDragging,
draggedNode,
pendingWidgetValues,
cursorPosition,
dragMode,
startDrag,
cancelDrag,
handleNativeDrop
handleNativeDrop,
setupGlobalListeners,
cleanupGlobalListeners
}
}

View File

@@ -124,9 +124,7 @@ describe('useNodePreviewAndDrag', () => {
expect(result.isDragging.value).toBe(true)
expect(result.isHovered.value).toBe(false)
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, {
mode: 'native'
})
expect(mockStartDrag).toHaveBeenCalledWith(mockNodeDef, 'native')
expect(mockDataTransfer.effectAllowed).toBe('copy')
expect(mockDataTransfer.setData).toHaveBeenCalledWith(
'application/x-comfy-node',

View File

@@ -112,7 +112,7 @@ export function useNodePreviewAndDrag(
isDragging.value = true
isHovered.value = false
startDrag(nodeDef.value, { mode: 'native' })
startDrag(nodeDef.value, 'native')
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'copy'

View File

@@ -2,7 +2,6 @@ import { useCurrentUser } from '@/composables/auth/useCurrentUser'
import { useAuthActions } from '@/composables/auth/useAuthActions'
import { useSelectedLiteGraphItems } from '@/composables/canvas/useSelectedLiteGraphItems'
import { useSubgraphOperations } from '@/composables/graph/useSubgraphOperations'
import { startModelNodeDragFromAsset } from '@/composables/node/startModelNodeDragFromAsset'
import { useExternalLink } from '@/composables/useExternalLink'
import { useModelSelectorDialog } from '@/composables/useModelSelectorDialog'
import {
@@ -21,6 +20,7 @@ import {
import type { Point } from '@/lib/litegraph/src/litegraph'
import { useBillingContext } from '@/composables/billing/useBillingContext'
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useSettingStore } from '@/platform/settings/settingStore'
import { buildSupportUrl } from '@/platform/support/config'
import { useTelemetry } from '@/platform/telemetry'
@@ -1305,14 +1305,14 @@ export function useCoreCommands(): ComfyCommand[] {
assetType: 'models',
title: t('sideToolbar.modelLibrary'),
onAssetSelected: (asset) => {
const error = startModelNodeDragFromAsset(asset)
if (error) {
const result = createModelNodeFromAsset(asset)
if (!result.success) {
toastStore.add({
severity: 'error',
summary: t('g.error'),
detail: t('assetBrowser.failedToCreateNode')
})
console.error('Node creation failed:', error)
console.error('Node creation failed:', result.error)
}
}
})

View File

@@ -22,6 +22,7 @@ import {
LOAD3D_NONE_MODEL,
SUPPORTED_EXTENSIONS_ACCEPT
} from '@/extensions/core/load3d/constants'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -413,16 +414,10 @@ useExtensionService().registerExtension({
if (cached) return cached
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
currentLoad3d.stopRecording()
const { camera_info, model_3d_info } = snapshotLoad3dState(
node,
currentLoad3d
)
const {
scene: imageData,
@@ -441,16 +436,11 @@ useExtensionService().registerExtension({
currentLoad3d.handleResize()
const modelInfo = currentLoad3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
const returnVal: Load3dCachedOutput = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
camera_info,
recording: '',
model_3d_info
}

View File

@@ -23,6 +23,7 @@ const mtlLoaderStub = {
const objLoaderStub = {
setWorkerUrl: vi.fn(),
setMaterials: vi.fn(),
setBaseObject3d: vi.fn(),
loadAsync: vi.fn<(url: string) => Promise<THREE.Object3D>>()
}
@@ -58,6 +59,7 @@ vi.mock('wwobjloader2', () => ({
OBJLoader2Parallel: class {
setWorkerUrl = objLoaderStub.setWorkerUrl
setMaterials = objLoaderStub.setMaterials
setBaseObject3d = objLoaderStub.setBaseObject3d
loadAsync = objLoaderStub.loadAsync
},
MtlObjBridge: {
@@ -247,6 +249,24 @@ describe('MeshModelAdapter', () => {
expect(ctx.registerOriginalMaterial).toHaveBeenCalledTimes(1)
})
it('resets baseObject3d on every load so meshes do not accumulate across calls', async () => {
objLoaderStub.loadAsync.mockResolvedValue(makeFbxLikeGroup())
const adapter = new MeshModelAdapter()
const ctx = makeContext('wireframe')
await adapter.load(ctx, '/api/view/', 'first.obj')
await adapter.load(ctx, '/api/view/', 'second.obj')
expect(objLoaderStub.setBaseObject3d).toHaveBeenCalledTimes(2)
const bases = objLoaderStub.setBaseObject3d.mock.calls.map(
([base]) => base
)
expect(bases[0]).toBeInstanceOf(THREE.Object3D)
expect(bases[1]).toBeInstanceOf(THREE.Object3D)
// Each call should hand the loader a fresh container, not the same one.
expect(bases[0]).not.toBe(bases[1])
})
})
describe('GLTF loader path', () => {

View File

@@ -102,6 +102,8 @@ export class MeshModelAdapter implements ModelAdapter {
path: string,
filename: string
): Promise<THREE.Object3D> {
this.objLoader.setBaseObject3d(new THREE.Object3D())
if (ctx.materialMode === 'original') {
try {
this.mtlLoader.setPath(path)

View File

@@ -0,0 +1,87 @@
import { describe, expect, it, vi } from 'vitest'
import type Load3d from '@/extensions/core/load3d/Load3d'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { CameraState } from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
function makeNode(props: Record<string, unknown> = {}): LGraphNode {
return { properties: { ...props } } as unknown as LGraphNode
}
const baseCameraState: CameraState = {
position: { x: 1, y: 2, z: 3 },
target: { x: 0, y: 0, z: 0 },
zoom: 1,
cameraType: 'perspective'
} as unknown as CameraState
function makeLoad3d({
cameraType = 'perspective',
fov = 35,
modelInfo = { transform: { position: [0, 0, 0] } } as unknown
}: {
cameraType?: string
fov?: number
modelInfo?: unknown
} = {}) {
return {
getCurrentCameraType: vi.fn(() => cameraType),
cameraManager: { perspectiveCamera: { fov } },
getCameraState: vi.fn(() => baseCameraState),
stopRecording: vi.fn(),
getModelInfo: vi.fn(() => modelInfo)
} as unknown as Load3d
}
describe('snapshotLoad3dState', () => {
it('returns only camera_info and model_3d_info', () => {
const result = snapshotLoad3dState(makeNode(), makeLoad3d())
expect(Object.keys(result).sort()).toEqual(['camera_info', 'model_3d_info'])
})
it('writes the camera state into properties["Camera Config"]', () => {
const node = makeNode()
snapshotLoad3dState(node, makeLoad3d({ fov: 42 }))
const cfg = node.properties['Camera Config'] as Record<string, unknown>
expect(cfg).toMatchObject({
cameraType: 'perspective',
fov: 42,
state: baseCameraState
})
})
it('preserves an existing Camera Config object instead of replacing it', () => {
const existing = { cameraType: 'orthographic', fov: 99 }
const node = makeNode({ 'Camera Config': existing })
snapshotLoad3dState(node, makeLoad3d())
// Same object reference (mutated in place), with state attached.
expect(node.properties['Camera Config']).toBe(existing)
expect(
(node.properties['Camera Config'] as Record<string, unknown>).state
).toBe(baseCameraState)
})
it('stops in-progress recording as a side effect', () => {
const load3d = makeLoad3d()
snapshotLoad3dState(makeNode(), load3d)
expect(load3d.stopRecording).toHaveBeenCalledOnce()
})
it('returns model_3d_info as a single-element list when a model is loaded', () => {
const info = { transform: { position: [1, 2, 3] } }
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: info })
)
expect(result.model_3d_info).toEqual([info])
})
it('returns an empty model_3d_info list when no model is loaded', () => {
const result = snapshotLoad3dState(
makeNode(),
makeLoad3d({ modelInfo: null })
)
expect(result.model_3d_info).toEqual([])
})
})

View File

@@ -0,0 +1,36 @@
import type Load3d from '@/extensions/core/load3d/Load3d'
import type {
CameraConfig,
CameraState,
Model3DInfo
} from '@/extensions/core/load3d/interfaces'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
export type Load3dSerializedBase = {
camera_info: CameraState | null
model_3d_info: Model3DInfo
}
export function snapshotLoad3dState(
node: LGraphNode,
load3d: Load3d
): Load3dSerializedBase {
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
| CameraConfig
| undefined) || {
cameraType: load3d.getCurrentCameraType(),
fov: load3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = load3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
load3d.stopRecording()
const modelInfo = load3d.getModelInfo()
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
return {
camera_info: cameraConfig.state ?? null,
model_3d_info
}
}

View File

@@ -9,7 +9,12 @@ const LOAD3D_PREVIEW_NODES = new Set([
'PreviewPointCloud'
])
const LOAD3D_ALL_NODES = new Set([...LOAD3D_PREVIEW_NODES, 'Load3D', 'SaveGLB'])
const LOAD3D_ALL_NODES = new Set([
...LOAD3D_PREVIEW_NODES,
'Load3D',
'Load3DAdvanced',
'SaveGLB'
])
export const isLoad3dPreviewNode = (nodeType: string): boolean =>
LOAD3D_PREVIEW_NODES.has(nodeType)

View File

@@ -0,0 +1,103 @@
import { nextTick } from 'vue'
import Load3DAdvanced from '@/components/load3d/Load3DAdvanced.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { snapshotLoad3dState } from '@/extensions/core/load3d/load3dSerialize'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
const inputSpecLoad3DAdvanced: CustomInputSpec = {
name: 'viewport_state',
type: 'LOAD_3D_ADVANCED',
isPreview: false
}
useExtensionService().registerExtension({
name: 'Comfy.Load3DAdvanced',
beforeRegisterNodeDef(_nodeType, nodeData) {
if (nodeData.name !== 'Load3DAdvanced') return
if (!nodeData.input?.required) return
nodeData.input.required.viewport_state = ['LOAD_3D_ADVANCED', {}]
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
LOAD_3D_ADVANCED(node) {
const widget = new ComponentWidgetImpl({
node,
name: 'viewport_state',
component: Load3DAdvanced,
inputSpec: inputSpecLoad3DAdvanced,
options: {}
})
widget.type = 'load3DAdvanced'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node: LGraphNode) {
if (node.constructor.comfyClass !== 'Load3DAdvanced') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 600)])
await nextTick()
useLoad3d(node).onLoad3dReady((load3d) => {
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
if (!modelWidget || !width || !height) return
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const config = new Load3DConfiguration(load3d, node.properties)
config.configure({
loadFolder: 'input',
modelWidget,
cameraState,
width,
height
})
})
useLoad3d(node).waitForLoad3d(() => {
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
if (!sceneWidget) return
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
return snapshotLoad3dState(node, currentLoad3d)
}
})
}
})

View File

@@ -37,6 +37,7 @@ async function loadLoad3dExtensions(): Promise<ComfyExtension[]> {
// Import extensions - they self-register via useExtensionService()
await Promise.all([
import('./load3d'),
import('./load3dAdvanced'),
import('./load3dPreviewExtensions'),
import('./saveMesh')
])
@@ -66,6 +67,12 @@ useExtensionService().registerExtension({
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
} else if (nodeData.name === 'Load3DAdvanced') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = ''
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,

View File

@@ -22,7 +22,7 @@ const zAsset = z.object({
})
const zAssetResponse = zListAssetsResponse
.pick({ total: true, has_more: true })
.pick({ total: true, has_more: true, next_cursor: true })
.extend({
assets: z.array(zAsset)
})

View File

@@ -53,6 +53,7 @@ const fetchApiMock = vi.mocked(api.fetchApi)
type AssetListResponseOptions = {
hasMore?: AssetResponse['has_more']
total?: AssetResponse['total']
nextCursor?: AssetResponse['next_cursor']
}
function buildResponse(
@@ -68,9 +69,18 @@ function buildResponse(
function buildAssetListResponse(
assets: AssetItem[],
{ hasMore = false, total = assets.length }: AssetListResponseOptions = {}
{
hasMore = false,
total = assets.length,
nextCursor
}: AssetListResponseOptions = {}
): Response {
return buildResponse({ assets, total, has_more: hasMore })
return buildResponse({
assets,
total,
has_more: hasMore,
...(nextCursor === undefined ? {} : { next_cursor: nextCursor })
})
}
function validAsset(overrides: Partial<AssetItem> = {}): AssetItem {
@@ -512,7 +522,7 @@ describe(assetService.getAllAssetsByTag, () => {
vi.clearAllMocks()
})
it('paginates tagged asset requests with include_public=true', async () => {
it('walks pages by keyset cursor with include_public=true', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse(
@@ -520,7 +530,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
{ hasMore: true, nextCursor: 'cursor-page-2' }
)
)
.mockResolvedValueOnce(
@@ -538,6 +548,8 @@ describe(assetService.getAllAssetsByTag, () => {
expect(firstParams.get('include_public')).toBe('true')
expect(firstParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(firstParams.get('limit')).toBe('2')
// First page carries neither a cursor nor an offset.
expect(firstParams.has('after')).toBe(false)
expect(firstParams.has('offset')).toBe(false)
const secondUrl = fetchApiMock.mock.calls[1]?.[0] as string
@@ -545,7 +557,9 @@ describe(assetService.getAllAssetsByTag, () => {
expect(secondParams.get('include_public')).toBe('true')
expect(secondParams.get('exclude_tags')).toBe(MISSING_TAG)
expect(secondParams.get('limit')).toBe('2')
expect(secondParams.get('offset')).toBe('2')
// Subsequent pages resume from the prior response's next_cursor, never offset.
expect(secondParams.get('after')).toBe('cursor-page-2')
expect(secondParams.has('offset')).toBe(false)
})
it('honors has_more when walking tagged asset pages', async () => {
@@ -556,7 +570,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'first', tags: ['input'] }),
validAsset({ id: 'second', tags: ['input'] })
],
{ hasMore: true }
{ hasMore: true, nextCursor: 'cursor-next' }
)
)
.mockResolvedValueOnce(
@@ -577,7 +591,45 @@ describe(assetService.getAllAssetsByTag, () => {
throw new Error('Expected a second asset request URL')
}
const secondParams = new URL(secondUrl, 'http://localhost').searchParams
expect(secondParams.get('offset')).toBe('2')
expect(secondParams.get('after')).toBe('cursor-next')
})
it('stops walking when next_cursor is absent even if has_more is true', async () => {
fetchApiMock.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'only', tags: ['input'] })], {
hasMore: true
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 2
})
expect(assets.map((a) => a.id)).toEqual(['only'])
expect(fetchApiMock).toHaveBeenCalledOnce()
})
it('stops walking when the server returns a non-advancing cursor', async () => {
fetchApiMock
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'a', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
.mockResolvedValueOnce(
buildAssetListResponse([validAsset({ id: 'b', tags: ['input'] })], {
hasMore: true,
nextCursor: 'stuck'
})
)
const assets = await assetService.getAllAssetsByTag('input', true, {
limit: 1
})
expect(assets.map((a) => a.id)).toEqual(['a', 'b'])
expect(fetchApiMock).toHaveBeenCalledTimes(2)
})
it.for([
@@ -636,7 +688,7 @@ describe(assetService.getAllAssetsByTag, () => {
validAsset({ id: 'a', tags: ['input'] }),
validAsset({ id: 'b', tags: ['input'] })
],
{ hasMore: true }
{ hasMore: true, nextCursor: 'cursor-page-2' }
)
})

View File

@@ -31,6 +31,11 @@ export interface PaginationOptions {
}
interface AssetPaginationOptions extends PaginationOptions {
/**
* Opaque keyset cursor from a prior response's `next_cursor`. When set, the
* server resumes after that cursor and `offset` is ignored.
*/
after?: string
signal?: AbortSignal
}
@@ -38,6 +43,7 @@ interface AssetRequestOptions extends PaginationOptions {
includeTags: string[]
excludeTags?: string[]
includePublic?: boolean
after?: string
signal?: AbortSignal
}
@@ -286,6 +292,7 @@ function createAssetService() {
excludeTags = DEFAULT_EXCLUDED_ASSET_TAGS,
limit = DEFAULT_LIMIT,
offset,
after,
includePublic,
signal
} = options
@@ -299,7 +306,11 @@ function createAssetService() {
if (normalizedExcludeTags.length > 0) {
queryParams.set('exclude_tags', normalizedExcludeTags.join(','))
}
if (offset !== undefined && offset > 0) {
// `after` (keyset cursor) takes precedence over `offset`; the server ignores
// `offset` when a cursor is supplied, so we avoid sending a redundant param.
if (after) {
queryParams.set('after', after)
} else if (offset !== undefined && offset > 0) {
queryParams.set('offset', offset.toString())
}
if (includePublic !== undefined) {
@@ -481,11 +492,17 @@ function createAssetService() {
async function getAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
): Promise<AssetItem[]> {
const data = await getAssetsPageByTag(tag, includePublic, {
limit,
offset,
after,
signal
})
@@ -498,17 +515,27 @@ function createAssetService() {
async function getAssetsPageByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, offset = 0, signal }: AssetPaginationOptions = {}
{
limit = DEFAULT_LIMIT,
offset = 0,
after,
signal
}: AssetPaginationOptions = {}
): Promise<AssetResponse> {
return await handleAssetRequest(
{ includeTags: [tag], limit, offset, includePublic, signal },
{ includeTags: [tag], limit, offset, after, includePublic, signal },
`assets for tag ${tag}`
)
}
/**
* Gets every asset for a tag by walking paginated asset API responses.
* Pagination follows the required server-provided `has_more` flag.
*
* Uses keyset (cursor) pagination: each page is fetched with the prior
* response's `next_cursor`, which is stable under concurrent inserts/deletes
* and avoids the duplicate/skip drift that offset paging exhibits when the
* underlying set changes mid-walk. Falls back to terminating on `has_more`
* when the server omits `next_cursor`.
*
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
@@ -520,18 +547,21 @@ function createAssetService() {
async function getAllAssetsByTag(
tag: string,
includePublic: boolean = true,
{ limit = DEFAULT_LIMIT, signal }: AssetPaginationOptions = {}
{
limit = DEFAULT_LIMIT,
signal
}: Pick<AssetPaginationOptions, 'limit' | 'signal'> = {}
): Promise<AssetItem[]> {
const assets: AssetItem[] = []
const pageSize = limit > 0 ? limit : DEFAULT_LIMIT
let offset = 0
let after: string | undefined
while (true) {
if (signal?.aborted) throw createAbortError()
const data = await getAssetsPageByTag(tag, includePublic, {
limit: pageSize,
offset,
after,
signal
})
const batch = data.assets
@@ -541,11 +571,12 @@ function createAssetService() {
assets.push(...batch)
if (!data.has_more) {
// A server that returns a non-advancing cursor would loop forever.
if (!data.has_more || !data.next_cursor || data.next_cursor === after) {
return assets
}
offset += batch.length
after = data.next_cursor
}
}

View File

@@ -0,0 +1,439 @@
// oxlint-disable no-misused-spread
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { markRaw } from 'vue'
import type { Raw } from 'vue'
import type { LGraphNode, Subgraph } from '@/lib/litegraph/src/litegraph'
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type * as LitegraphModule from '@/lib/litegraph/src/litegraph'
import type * as ModelToNodeStoreModule from '@/stores/modelToNodeStore'
import type * as WorkflowStoreModule from '@/platform/workflow/management/stores/workflowStore'
import type * as LitegraphServiceModule from '@/services/litegraphService'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { createModelNodeFromAsset } from '@/platform/assets/utils/createModelNodeFromAsset'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
// Mock dependencies
vi.mock('@/scripts/api', () => ({
api: {
apiURL: vi.fn((path: string) => `http://localhost:8188${path}`)
}
}))
vi.mock('@/stores/modelToNodeStore', async (importOriginal) => {
const actual = await importOriginal<typeof ModelToNodeStoreModule>()
return {
...actual,
useModelToNodeStore: vi.fn()
}
})
vi.mock(
'@/platform/workflow/management/stores/workflowStore',
async (importOriginal) => {
const actual = await importOriginal<typeof WorkflowStoreModule>()
return {
...actual,
useWorkflowStore: vi.fn()
}
}
)
vi.mock('@/services/litegraphService', async (importOriginal) => {
const actual = await importOriginal<typeof LitegraphServiceModule>()
return {
...actual,
useLitegraphService: vi.fn()
}
})
vi.mock('@/lib/litegraph/src/litegraph', async (importOriginal) => {
const actual = await importOriginal<typeof LitegraphModule>()
return {
...actual,
LiteGraph: {
...actual.LiteGraph,
createNode: vi.fn()
}
}
})
vi.mock('@/scripts/app', () => ({
app: {
canvas: {
graph: {
add: vi.fn()
}
}
}
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
name: 'test-model.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: {
filename: 'models/checkpoints/test-model.safetensors'
},
...overrides
}
}
async function createMockNode(overrides?: {
widgetName?: string
widgetValue?: string
hasWidgets?: boolean
}): Promise<LGraphNode> {
const {
widgetName = 'ckpt_name',
widgetValue = '',
hasWidgets = true
} = overrides || {}
const { LGraphNode: ActualLGraphNode } = await vi.importActual<
typeof LitegraphModule
>('@/lib/litegraph/src/litegraph')
if (!hasWidgets) {
return Object.create(ActualLGraphNode.prototype)
}
type Widget = NonNullable<LGraphNode['widgets']>[number]
const widget: Pick<Widget, 'name' | 'value' | 'type' | 'options' | 'y'> = {
name: widgetName,
value: widgetValue,
type: 'string',
options: {},
y: 0
}
return Object.create(ActualLGraphNode.prototype, {
widgets: { value: [widget], writable: true }
})
}
function createMockNodeProvider(
overrides: {
nodeDef?: { name: string; display_name: string }
key?: string
} = {}
) {
return {
nodeDef: {
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
...overrides.nodeDef
},
key: overrides.key ?? 'ckpt_name'
}
}
/**
* Configures all mocked dependencies with sensible defaults.
* Uses semantic parameters for clearer test intent.
* For error paths or edge cases, pass null values or specific overrides.
*/
async function setupMocks(
overrides: {
nodeProvider?: ReturnType<typeof createMockNodeProvider> | null
canvasCenter?: [number, number]
activeSubgraph?: Raw<Subgraph>
createdNode?: Awaited<ReturnType<typeof createMockNode>> | null
} = {}
) {
const {
nodeProvider = createMockNodeProvider(),
canvasCenter = [100, 200],
activeSubgraph = undefined,
createdNode = await createMockNode()
} = overrides
vi.mocked(useModelToNodeStore).mockReturnValue({
...useModelToNodeStore(),
getNodeProvider: vi.fn().mockReturnValue(nodeProvider)
})
vi.mocked(useLitegraphService).mockReturnValue({
...useLitegraphService(),
getCanvasCenter: vi.fn().mockReturnValue(canvasCenter)
})
vi.mocked(useWorkflowStore).mockReturnValue({
...useWorkflowStore(),
activeSubgraph,
isSubgraphActive: !!activeSubgraph
})
vi.mocked(LiteGraph.createNode).mockReturnValue(createdNode)
}
describe('createModelNodeFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'warn').mockImplementation(() => {})
})
describe('when creating nodes from valid assets', () => {
it('should create the appropriate loader node for the asset category', async () => {
const asset = createMockAsset()
await setupMocks()
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
if (result.success) {
expect(
vi.mocked(useModelToNodeStore)().getNodeProvider
).toHaveBeenCalledWith('checkpoints')
expect(LiteGraph.createNode).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'Load Checkpoint',
{ pos: [100, 200] }
)
}
})
it('should place node at canvas center by default', async () => {
const asset = createMockAsset()
await setupMocks({
canvasCenter: [150, 250]
})
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(
vi.mocked(useLitegraphService)().getCanvasCenter
).toHaveBeenCalled()
expect(LiteGraph.createNode).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'Load Checkpoint',
{ pos: [150, 250] }
)
})
it('should place node at specified position when position is provided', async () => {
const asset = createMockAsset()
await setupMocks()
const result = createModelNodeFromAsset(asset, { position: [300, 400] })
expect(result.success).toBe(true)
expect(
vi.mocked(useLitegraphService)().getCanvasCenter
).not.toHaveBeenCalled()
expect(LiteGraph.createNode).toHaveBeenCalledWith(
'CheckpointLoaderSimple',
'Load Checkpoint',
{ pos: [300, 400] }
)
})
it('should populate the loader widget with the asset file path', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockNode.widgets?.[0].value).toBe(
'models/checkpoints/test-model.safetensors'
)
})
it('should add node to root graph when no subgraph is active', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
})
it('should fallback to asset.metadata.filename when user_metadata.filename missing', async () => {
const asset = createMockAsset({
user_metadata: {},
metadata: { filename: 'models/checkpoints/from-metadata.safetensors' }
})
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockNode.widgets?.[0].value).toBe(
'models/checkpoints/from-metadata.safetensors'
)
})
it('should fallback to asset.name when both filename sources missing', async () => {
const asset = createMockAsset({
user_metadata: {},
metadata: undefined
})
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockNode.widgets?.[0].value).toBe('test-model.safetensors')
})
it('should add node to active subgraph when present', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode()
const { Subgraph } = await vi.importActual<typeof LitegraphModule>(
'@/lib/litegraph/src/litegraph'
)
const mockSubgraph = markRaw(
Object.create(Subgraph.prototype, {
add: { value: vi.fn() }
})
)
await setupMocks({
createdNode: mockNode,
activeSubgraph: mockSubgraph
})
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(mockSubgraph.add).toHaveBeenCalledWith(mockNode)
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
})
it('should succeed when provider has empty key (auto-load nodes)', async () => {
const asset = createMockAsset({
tags: ['models', 'chatterbox/chatterbox_vc'],
user_metadata: { filename: 'chatterbox_vc_model.pt' }
})
const mockNode = await createMockNode({ hasWidgets: false })
const nodeProvider = createMockNodeProvider({
nodeDef: {
name: 'FL_ChatterboxVC',
display_name: 'FL Chatterbox VC'
},
key: ''
})
await setupMocks({ createdNode: mockNode, nodeProvider })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(true)
expect(vi.mocked(app).canvas.graph!.add).toHaveBeenCalledWith(mockNode)
})
})
describe('when asset data is incomplete or invalid', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it.for([
{
case: 'missing user_metadata with no fallback',
overrides: { user_metadata: undefined, metadata: undefined, name: '' },
expectedCode: 'INVALID_ASSET' as const,
errorPattern: /Invalid filename.*expected non-empty string/
},
{
case: 'empty filename with no fallback',
overrides: {
user_metadata: { filename: '' },
metadata: undefined,
name: ''
},
expectedCode: 'INVALID_ASSET' as const,
errorPattern: /Invalid filename.*expected non-empty string/
}
])(
'should fail when asset has $case',
({ overrides, expectedCode, errorPattern }) => {
const asset = createMockAsset(overrides)
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe(expectedCode)
expect(result.error.message).toMatch(errorPattern)
expect(result.error.assetId).toBe('asset-123')
}
}
)
it.for([
{
case: 'no tags',
overrides: { tags: undefined },
expectedCode: 'INVALID_ASSET' as const,
errorMessage: 'Asset has no tags defined'
},
{
case: 'only excluded tags',
overrides: { tags: ['models', 'missing'] },
expectedCode: 'INVALID_ASSET' as const,
errorMessage: 'Asset has no valid category tag'
},
{
case: 'only the models tag',
overrides: { tags: ['models'] },
expectedCode: 'INVALID_ASSET' as const,
errorMessage: 'Asset has no valid category tag'
}
])(
'should fail when asset has $case',
({ overrides, expectedCode, errorMessage }) => {
const asset = createMockAsset(overrides)
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe(expectedCode)
expect(result.error.message).toBe(errorMessage)
}
}
)
})
describe('when system resources are unavailable', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
})
it('should fail when no provider registered for category', async () => {
const asset = createMockAsset()
await setupMocks({ nodeProvider: null })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('NO_PROVIDER')
expect(result.error.message).toContain('checkpoints')
expect(result.error.details?.category).toBe('checkpoints')
}
})
it('should fail when node creation fails', async () => {
const asset = createMockAsset()
await setupMocks()
vi.mocked(LiteGraph.createNode).mockReturnValue(null)
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('NODE_CREATION_FAILED')
expect(result.error.message).toContain('CheckpointLoaderSimple')
}
})
it('should fail when widget is missing from node', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode({ widgetName: 'wrong_widget' })
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('MISSING_WIDGET')
expect(result.error.message).toContain('ckpt_name')
expect(result.error.message).toContain('CheckpointLoaderSimple')
expect(result.error.details?.widgetName).toBe('ckpt_name')
}
})
it('should fail when node has no widgets array', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode({ hasWidgets: false })
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('MISSING_WIDGET')
expect(result.error.message).toContain('ckpt_name not found')
}
})
it('should not add node to graph when widget validation fails', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode({ hasWidgets: false })
await setupMocks({ createdNode: mockNode })
createModelNodeFromAsset(asset)
expect(vi.mocked(app).canvas.graph!.add).not.toHaveBeenCalled()
})
})
describe('when graph is null', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {})
vi.mocked(app).canvas.graph = null
})
it('should fail when no graph is available', async () => {
const asset = createMockAsset()
const mockNode = await createMockNode()
await setupMocks({ createdNode: mockNode })
const result = createModelNodeFromAsset(asset)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('NO_GRAPH')
expect(result.error.message).toBe('No active graph available')
}
})
})
})

View File

@@ -0,0 +1,198 @@
import { LiteGraph } from '@/lib/litegraph/src/litegraph'
import type { LGraphNode, Point } from '@/lib/litegraph/src/litegraph'
import { assetItemSchema } from '@/platform/assets/schemas/assetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { useWorkflowStore } from '@/platform/workflow/management/stores/workflowStore'
import { app } from '@/scripts/app'
import { useLitegraphService } from '@/services/litegraphService'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
interface ModelNodeCreateOptions {
position?: Point
}
type NodeCreationErrorCode =
| 'INVALID_ASSET'
| 'NO_PROVIDER'
| 'NODE_CREATION_FAILED'
| 'MISSING_WIDGET'
| 'NO_GRAPH'
interface NodeCreationError {
code: NodeCreationErrorCode
message: string
assetId: string
details?: Record<string, unknown>
}
type Result<T, E> = { success: true; value: T } | { success: false; error: E }
/**
* Creates a LiteGraph node from an asset item.
*
* **Boundary Function**: Bridges Vue reactive domain with LiteGraph canvas domain.
*
* @param asset - Asset item to create node from (Vue domain)
* @param options - Optional position and configuration
* @returns Result with LiteGraph node (Canvas domain) or error details
*
* @remarks
* This function performs side effects on the canvas graph. Validation failures
* return error results rather than throwing to allow graceful degradation in UI contexts.
* Widget validation occurs before graph mutation to prevent orphaned nodes.
*/
export function createModelNodeFromAsset(
asset: AssetItem,
options?: ModelNodeCreateOptions
): Result<LGraphNode, NodeCreationError> {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
const errorMessage = validatedAsset.error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')
console.error('Invalid asset item:', errorMessage)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset schema validation failed',
assetId: asset.id,
details: { validationErrors: errorMessage }
}
}
}
const validAsset = validatedAsset.data
const filename = getAssetFilename(validAsset)
if (filename.length === 0) {
console.error(
`Asset ${validAsset.id} has invalid user_metadata.filename (expected non-empty string, got ${typeof filename})`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: `Invalid filename (expected non-empty string, got ${typeof filename})`,
assetId: validAsset.id
}
}
}
if (validAsset.tags.length === 0) {
console.error(
`Asset ${validAsset.id} has no tags defined (expected at least one category tag)`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no tags defined',
assetId: validAsset.id
}
}
}
const category = validAsset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
if (!category) {
console.error(
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no valid category tag',
assetId: validAsset.id,
details: { availableTags: validAsset.tags }
}
}
}
const modelToNodeStore = useModelToNodeStore()
const provider = modelToNodeStore.getNodeProvider(category)
if (!provider) {
console.error(`No node provider registered for category: ${category}`)
return {
success: false,
error: {
code: 'NO_PROVIDER',
message: `No node provider registered for category: ${category}`,
assetId: validAsset.id,
details: { category }
}
}
}
const litegraphService = useLitegraphService()
const pos = options?.position ?? litegraphService.getCanvasCenter()
const node = LiteGraph.createNode(
provider.nodeDef.name,
provider.nodeDef.display_name,
{ pos }
)
if (!node) {
console.error(`Failed to create node for type: ${provider.nodeDef.name}`)
return {
success: false,
error: {
code: 'NODE_CREATION_FAILED',
message: `Failed to create node for type: ${provider.nodeDef.name}`,
assetId: validAsset.id,
details: { nodeType: provider.nodeDef.name }
}
}
}
const workflowStore = useWorkflowStore()
const targetGraph = workflowStore.isSubgraphActive
? workflowStore.activeSubgraph
: app.canvas.graph
if (!targetGraph) {
console.error('No active graph available')
return {
success: false,
error: {
code: 'NO_GRAPH',
message: 'No active graph available',
assetId: validAsset.id
}
}
}
// Set widget value if provider specifies a key (some nodes auto-load models without a widget)
if (provider.key) {
const widget = node.widgets?.find((w) => w.name === provider.key)
if (!widget) {
console.error(
`Widget ${provider.key} not found on node ${provider.nodeDef.name}`
)
return {
success: false,
error: {
code: 'MISSING_WIDGET',
message: `Widget ${provider.key} not found on node ${provider.nodeDef.name}`,
assetId: validAsset.id,
details: { widgetName: provider.key, nodeType: provider.nodeDef.name }
}
}
}
widget.value = filename
}
// Add the node to the graph
targetGraph.add(node)
return { success: true, value: node }
}

View File

@@ -1,208 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import { resolveModelNodeFromAsset } from '@/platform/assets/utils/resolveModelNodeFromAsset'
const mockGetNodeProvider = vi.hoisted(() => vi.fn())
vi.mock('@/stores/modelToNodeStore', () => ({
useModelToNodeStore: () => ({ getNodeProvider: mockGetNodeProvider })
}))
function createMockAsset(overrides: Partial<AssetItem> = {}): AssetItem {
return {
id: 'asset-123',
name: 'test-model.safetensors',
size: 1024,
created_at: '2025-10-01T00:00:00Z',
tags: ['models', 'checkpoints'],
user_metadata: {
filename: 'models/checkpoints/test-model.safetensors'
},
...overrides
}
}
function createMockNodeProvider(
overrides: {
nodeDef?: { name: string; display_name: string }
key?: string
} = {}
) {
return {
nodeDef: {
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
...overrides.nodeDef
},
key: overrides.key ?? 'ckpt_name'
}
}
function mockProvider(
provider: ReturnType<typeof createMockNodeProvider> | null
) {
mockGetNodeProvider.mockReturnValue(provider)
}
describe('resolveModelNodeFromAsset', () => {
beforeEach(() => {
vi.clearAllMocks()
vi.spyOn(console, 'error').mockImplementation(() => {})
})
describe('valid assets', () => {
it('resolves the provider for the asset category and the filename', () => {
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(createMockAsset())
expect(result.success).toBe(true)
if (result.success) {
expect(mockGetNodeProvider).toHaveBeenCalledWith('checkpoints')
expect(result.value.provider.nodeDef.name).toBe(
'CheckpointLoaderSimple'
)
expect(result.value.filename).toBe(
'models/checkpoints/test-model.safetensors'
)
}
})
it('falls back to metadata.filename when user_metadata.filename missing', () => {
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(
createMockAsset({
user_metadata: {},
metadata: { filename: 'models/checkpoints/from-metadata.safetensors' }
})
)
expect(result.success).toBe(true)
if (result.success) {
expect(result.value.filename).toBe(
'models/checkpoints/from-metadata.safetensors'
)
}
})
it('falls back to asset.name when both filename sources missing', () => {
mockProvider(createMockNodeProvider())
const result = resolveModelNodeFromAsset(
createMockAsset({ user_metadata: {}, metadata: undefined })
)
expect(result.success).toBe(true)
if (result.success) {
expect(result.value.filename).toBe('test-model.safetensors')
}
})
it('resolves a provider with an empty key (auto-load nodes)', () => {
mockProvider(
createMockNodeProvider({
nodeDef: {
name: 'FL_ChatterboxVC',
display_name: 'FL Chatterbox VC'
},
key: ''
})
)
const result = resolveModelNodeFromAsset(
createMockAsset({
tags: ['models', 'chatterbox/chatterbox_vc'],
user_metadata: { filename: 'chatterbox_vc_model.pt' }
})
)
expect(result.success).toBe(true)
if (result.success) {
expect(result.value.provider.key).toBe('')
expect(result.value.filename).toBe('chatterbox_vc_model.pt')
}
})
})
describe('invalid assets', () => {
it('fails when the asset does not match the schema', () => {
const invalid = {
id: 'asset-123',
tags: ['models', 'checkpoints']
} as unknown as AssetItem
const result = resolveModelNodeFromAsset(invalid)
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('INVALID_ASSET')
expect(result.error.message).toBe('Asset schema validation failed')
expect(result.error.assetId).toBe('asset-123')
expect(result.error.details?.validationErrors).toBeTruthy()
}
})
it.for([
{
case: 'missing user_metadata with no fallback',
overrides: {
user_metadata: undefined,
metadata: undefined,
name: ''
},
errorPattern: /Invalid filename.*expected non-empty string/
},
{
case: 'empty filename with no fallback',
overrides: {
user_metadata: { filename: '' },
metadata: undefined,
name: ''
},
errorPattern: /Invalid filename.*expected non-empty string/
}
])('fails when asset has $case', ({ overrides, errorPattern }) => {
const result = resolveModelNodeFromAsset(createMockAsset(overrides))
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('INVALID_ASSET')
expect(result.error.message).toMatch(errorPattern)
expect(result.error.assetId).toBe('asset-123')
}
})
it.for([
{
case: 'no tags',
overrides: { tags: undefined },
message: 'Asset has no tags defined'
},
{
case: 'only excluded tags',
overrides: { tags: ['models', 'missing'] },
message: 'Asset has no valid category tag'
},
{
case: 'only the models tag',
overrides: { tags: ['models'] },
message: 'Asset has no valid category tag'
}
])('fails when asset has $case', ({ overrides, message }) => {
const result = resolveModelNodeFromAsset(createMockAsset(overrides))
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('INVALID_ASSET')
expect(result.error.message).toBe(message)
}
})
it('fails when no provider is registered for the category', () => {
mockProvider(null)
const result = resolveModelNodeFromAsset(createMockAsset())
expect(result.success).toBe(false)
if (!result.success) {
expect(result.error.code).toBe('NO_PROVIDER')
expect(result.error.message).toContain('checkpoints')
expect(result.error.details?.category).toBe('checkpoints')
}
})
})
})

View File

@@ -1,117 +0,0 @@
import { assetItemSchema } from '@/platform/assets/schemas/assetSchema'
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
import {
MISSING_TAG,
MODELS_TAG
} from '@/platform/assets/services/assetService'
import { getAssetFilename } from '@/platform/assets/utils/assetMetadataUtils'
import { useModelToNodeStore } from '@/stores/modelToNodeStore'
import type { ModelNodeProvider } from '@/stores/modelToNodeStore'
type ResolveErrorCode = 'INVALID_ASSET' | 'NO_PROVIDER'
export interface ResolveModelNodeError {
code: ResolveErrorCode
message: string
assetId: string
details?: Record<string, unknown>
}
export interface ResolvedModelNode {
provider: ModelNodeProvider
filename: string
}
type Result<T, E> = { success: true; value: T } | { success: false; error: E }
/**
* Resolves an asset item to the node provider and filename needed to add a
* model loader node. Validation failures return error results rather than
* throwing, so callers can degrade gracefully in UI contexts.
*/
export function resolveModelNodeFromAsset(
asset: AssetItem
): Result<ResolvedModelNode, ResolveModelNodeError> {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
const errorMessage = validatedAsset.error.errors
.map((e) => `${e.path.join('.')}: ${e.message}`)
.join(', ')
console.error('Invalid asset item:', errorMessage)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset schema validation failed',
assetId: typeof asset?.id === 'string' ? asset.id : 'unknown',
details: { validationErrors: errorMessage }
}
}
}
const validAsset = validatedAsset.data
const filename = getAssetFilename(validAsset)
if (filename.length === 0) {
console.error(
`Asset ${validAsset.id} has invalid user_metadata.filename (expected non-empty string, got ${typeof filename})`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: `Invalid filename (expected non-empty string, got ${typeof filename})`,
assetId: validAsset.id
}
}
}
if (validAsset.tags.length === 0) {
console.error(
`Asset ${validAsset.id} has no tags defined (expected at least one category tag)`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no tags defined',
assetId: validAsset.id
}
}
}
const category = validAsset.tags.find(
(tag) => tag !== MODELS_TAG && tag !== MISSING_TAG
)
if (!category) {
console.error(
`Asset ${validAsset.id} has no valid category tag. Available tags: ${validAsset.tags.join(', ')} (expected tag other than '${MODELS_TAG}' or '${MISSING_TAG}')`
)
return {
success: false,
error: {
code: 'INVALID_ASSET',
message: 'Asset has no valid category tag',
assetId: validAsset.id,
details: { availableTags: validAsset.tags }
}
}
}
const provider = useModelToNodeStore().getNodeProvider(category)
if (!provider) {
console.error(`No node provider registered for category: ${category}`)
return {
success: false,
error: {
code: 'NO_PROVIDER',
message: `No node provider registered for category: ${category}`,
assetId: validAsset.id,
details: { category }
}
}
}
return { success: true, value: { provider, filename } }
}

View File

@@ -53,7 +53,8 @@ describe('useReleaseService', () => {
project: 'comfyui',
current_version: '1.0.0'
},
signal: undefined
signal: undefined,
headers: undefined
})
expect(result).toEqual(mockReleases)
@@ -76,7 +77,8 @@ describe('useReleaseService', () => {
current_version: '1.0.0',
form_factor: 'desktop-windows'
},
signal: undefined
signal: undefined,
headers: undefined
})
expect(result).toEqual(mockReleases)
@@ -86,11 +88,30 @@ describe('useReleaseService', () => {
const abortController = new AbortController()
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases({ project: 'comfyui' }, abortController.signal)
await service.getReleases(
{ project: 'comfyui' },
{ signal: abortController.signal }
)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: abortController.signal
signal: abortController.signal,
headers: undefined
})
})
it('should send Comfy-Env header when deployEnvironment is provided', async () => {
mockAxiosInstance.get.mockResolvedValue({ data: mockReleases })
await service.getReleases(
{ project: 'comfyui' },
{ deployEnvironment: 'local-desktop' }
)
expect(mockAxiosInstance.get).toHaveBeenCalledWith('/releases', {
params: { project: 'comfyui' },
signal: undefined,
headers: { 'Comfy-Env': 'local-desktop' }
})
})

View File

@@ -98,8 +98,9 @@ export const useReleaseService = () => {
// Fetch release notes from API
const getReleases = async (
params: GetReleasesParams,
signal?: AbortSignal
options: { signal?: AbortSignal; deployEnvironment?: string } = {}
): Promise<ReleaseNote[] | null> => {
const { signal, deployEnvironment } = options
const endpoint = '/releases'
const errorContext = 'Failed to get releases'
const routeSpecificErrors = {
@@ -110,7 +111,10 @@ export const useReleaseService = () => {
() =>
releaseApiClient.get<ReleaseNote[]>(endpoint, {
params,
signal
signal,
headers: deployEnvironment
? { 'Comfy-Env': deployEnvironment }
: undefined
}),
errorContext,
routeSpecificErrors

View File

@@ -228,12 +228,15 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
})
})
@@ -300,12 +303,15 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
})
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'git-windows',
locale: 'en'
},
{ deployEnvironment: undefined }
)
expect(store.releases).toEqual([mockRelease])
})
@@ -318,12 +324,30 @@ describe('useReleaseStore', () => {
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith({
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
})
expect(releaseService.getReleases).toHaveBeenCalledWith(
{
project: 'comfyui',
current_version: '1.0.0',
form_factor: 'desktop-mac',
locale: 'en'
},
{ deployEnvironment: undefined }
)
})
it('should pass deploy_environment from system stats', async () => {
const store = useReleaseStore()
const releaseService = useReleaseService()
const systemStatsStore = useSystemStatsStore()
systemStatsStore.systemStats!.system.deploy_environment = 'local-desktop'
vi.mocked(releaseService.getReleases).mockResolvedValue([mockRelease])
await store.initialize()
expect(releaseService.getReleases).toHaveBeenCalledWith(
expect.anything(),
{ deployEnvironment: 'local-desktop' }
)
})
it('should skip fetching when --disable-api-nodes is present', async () => {

View File

@@ -266,12 +266,18 @@ export const useReleaseStore = defineStore('release', () => {
await until(systemStatsStore.isInitialized)
}
const fetchedReleases = await releaseService.getReleases({
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
})
const fetchedReleases = await releaseService.getReleases(
{
project: isCloud ? 'cloud' : 'comfyui',
current_version: currentVersion.value,
form_factor: systemStatsStore.getFormFactor(),
locale: stringToLocale(locale.value)
},
{
deployEnvironment:
systemStatsStore.systemStats?.system?.deploy_environment
}
)
if (fetchedReleases !== null) {
releases.value = fetchedReleases

View File

@@ -0,0 +1,94 @@
import { createTestingPinia } from '@pinia/testing'
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { createI18n } from 'vue-i18n'
import WorkspaceSwitcherPopover from './WorkspaceSwitcherPopover.vue'
vi.mock('@/platform/workspace/composables/useWorkspaceSwitch', () => ({
useWorkspaceSwitch: () => ({ switchWorkspace: vi.fn() })
}))
vi.mock('@/composables/billing/useBillingContext', () => ({
useBillingContext: () => ({ subscription: ref(null) })
}))
const LONG_WORKSPACE_NAME =
'Quantum Renaissance Collective for Hyperdimensional Latent Diffusion Research and Experimental Workflow Engineering'
const i18n = createI18n({
legacy: false,
locale: 'en',
messages: {
en: {
workspaceSwitcher: {
personal: 'Personal',
roleOwner: 'Owner',
roleMember: 'Member',
createWorkspace: 'Create new workspace',
maxWorkspacesReached:
'You can only own 10 workspaces. Delete one to create a new one.'
}
}
}
})
function createWorkspaceState(overrides: Record<string, unknown>) {
return {
created_at: '2026-01-01T00:00:00Z',
joined_at: '2026-01-01T00:00:00Z',
isSubscribed: false,
subscriptionPlan: null,
subscriptionTier: null,
members: [],
pendingInvites: [],
...overrides
}
}
function renderComponent() {
return render(WorkspaceSwitcherPopover, {
global: {
plugins: [
createTestingPinia({
createSpy: vi.fn,
initialState: {
teamWorkspace: {
activeWorkspaceId: 'ws-personal',
isFetchingWorkspaces: false,
workspaces: [
createWorkspaceState({
id: 'ws-personal',
name: 'Personal Workspace',
type: 'personal',
role: 'owner'
}),
createWorkspaceState({
id: 'ws-team-long',
name: LONG_WORKSPACE_NAME,
type: 'team',
role: 'member'
})
]
}
}
}),
i18n
],
stubs: {
WorkspaceProfilePic: true
}
}
})
}
describe('WorkspaceSwitcherPopover', () => {
it('exposes the full team workspace name as a tooltip on the row', () => {
renderComponent()
const name = screen.getByText(LONG_WORKSPACE_NAME)
expect(name).toHaveAttribute('title', LONG_WORKSPACE_NAME)
})
})

View File

@@ -34,21 +34,20 @@
@click="handleSelectWorkspace(workspace)"
>
<WorkspaceProfilePic
class="size-8 text-sm"
class="size-8 shrink-0 text-sm"
:workspace-name="workspace.name"
/>
<div class="flex min-w-0 flex-1 flex-col items-start gap-1">
<div class="flex items-center gap-1.5">
<span class="text-sm text-base-foreground">
{{
workspace.type === 'personal'
? $t('workspaceSwitcher.personal')
: workspace.name
}}
<div class="flex max-w-full items-center gap-1.5">
<span
:title="getDisplayName(workspace)"
class="truncate text-sm text-base-foreground"
>
{{ getDisplayName(workspace) }}
</span>
<span
v-if="resolveTierLabel(workspace)"
class="rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
class="shrink-0 rounded-full bg-base-foreground px-1 py-0.5 text-2xs font-bold text-base-background uppercase"
>
{{ resolveTierLabel(workspace) }}
</span>
@@ -59,7 +58,7 @@
</div>
<i
v-if="isCurrentWorkspace(workspace)"
class="pi pi-check text-sm text-base-foreground"
class="pi pi-check shrink-0 text-sm text-base-foreground"
/>
</button>
</div>
@@ -171,6 +170,12 @@ function isCurrentWorkspace(workspace: AvailableWorkspace): boolean {
return workspace.id === workspaceId.value
}
function getDisplayName(workspace: AvailableWorkspace): string {
return workspace.type === 'personal'
? t('workspaceSwitcher.personal')
: workspace.name
}
function getRoleLabel(role: AvailableWorkspace['role']): string {
if (role === 'owner') return t('workspaceSwitcher.roleOwner')
if (role === 'member') return t('workspaceSwitcher.roleMember')

View File

@@ -33,6 +33,10 @@ const WorkspaceTokenResponseSchema = z.object({
permissions: z.array(z.string())
})
export type WorkspaceTokenResponse = z.infer<
typeof WorkspaceTokenResponseSchema
>
export class WorkspaceAuthError extends Error {
constructor(
message: string,

View File

@@ -1,71 +0,0 @@
import { render, screen } from '@testing-library/vue'
import { describe, expect, it, vi } from 'vitest'
import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import LGraphNodePreview from '@/renderer/extensions/vueNodes/components/LGraphNodePreview.vue'
import { fromPartial } from '@total-typescript/shoehorn'
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({ inputIsWidget: () => true })
}))
// Serializes the nodeData prop so tests can assert on the data contract
// LGraphNodePreview hands to NodeWidgets. How that data renders is covered
// by NodeWidgets.test.ts and browser_tests/tests/sidebar/modelLibrary.spec.ts.
const NodeWidgetsProbe = {
props: ['nodeData'],
template: '<div data-testid="node-data">{{ JSON.stringify(nodeData) }}</div>'
}
interface ProbedWidget {
name: string
options?: { values?: string[] }
}
const nodeDef = fromPartial<ComfyNodeDefV2>({
name: 'CheckpointLoaderSimple',
display_name: 'Load Checkpoint',
inputs: {
ckpt_name: { type: 'COMBO', options: ['a.safetensors', 'b.safetensors'] }
},
outputs: []
})
function renderedComboWidget(
props: { widgetValues?: Record<string, string> } = {}
) {
render(LGraphNodePreview, {
props: { nodeDef, ...props },
global: {
stubs: {
NodeHeader: true,
NodeSlots: true,
NodeWidgets: NodeWidgetsProbe
}
}
})
const nodeData: { widgets?: ProbedWidget[] } = JSON.parse(
screen.getByTestId('node-data').textContent ?? ''
)
return nodeData.widgets?.find((w) => w.name === 'ckpt_name')
}
describe('LGraphNodePreview', () => {
it('leads the combo options with the provided widget value', () => {
const widget = renderedComboWidget({
widgetValues: { ckpt_name: 'sd_xl_base_1.0.safetensors' }
})
expect(widget?.options?.values).toEqual([
'sd_xl_base_1.0.safetensors',
'a.safetensors',
'b.safetensors'
])
})
it('keeps the combo options untouched when no value is provided', () => {
const widget = renderedComboWidget()
expect(widget?.options?.values).toEqual(['a.safetensors', 'b.safetensors'])
})
})

View File

@@ -45,14 +45,9 @@ import type { ComfyNodeDef as ComfyNodeDefV2 } from '@/schemas/nodeDef/nodeDefSc
import { useWidgetStore } from '@/stores/widgetStore'
import { cn } from '@comfyorg/tailwind-utils'
const {
nodeDef,
position = 'absolute',
widgetValues
} = defineProps<{
const { nodeDef, position = 'absolute' } = defineProps<{
nodeDef: ComfyNodeDefV2
position?: 'absolute' | 'relative'
widgetValues?: Record<string, string>
}>()
const widgetStore = useWidgetStore()
@@ -61,32 +56,27 @@ const widgetStore = useWidgetStore()
const nodeData = computed<VueNodeData>(() => {
const widgets = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => widgetStore.inputIsWidget(input))
.map(([name, input]) => {
const comboValues =
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
// Preview nodes have no widget-value store entry, so combo widgets
// render their first option; lead with the requested value to show it.
const leadValue = widgetValues?.[name]
return {
nodeId: '-1',
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: (comboValues?.[0] ?? ''),
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
leadValue && comboValues
? [leadValue, ...comboValues.filter((o) => o !== leadValue)]
: comboValues
} satisfies IWidgetOptions
}
})
.map(([name, input]) => ({
nodeId: '-1',
name,
type: input.widgetType || input.type,
value:
input.default !== undefined
? input.default
: input.type === 'COMBO' &&
Array.isArray(input.options) &&
input.options.length > 0
? input.options[0]
: '',
options: {
hidden: input.hidden,
advanced: input.advanced,
values:
input.type === 'COMBO' && Array.isArray(input.options)
? input.options
: undefined
} satisfies IWidgetOptions
}))
const inputs: INodeInputSlot[] = Object.entries(nodeDef.inputs || {})
.filter(([_, input]) => !widgetStore.inputIsWidget(input))

View File

@@ -51,6 +51,9 @@ const AudioPreviewPlayer = defineAsyncComponent(
const Load3D = defineAsyncComponent(
() => import('@/components/load3d/Load3D.vue')
)
const Load3DAdvanced = defineAsyncComponent(
() => import('@/components/load3d/Load3DAdvanced.vue')
)
const WidgetImageCrop = defineAsyncComponent(
() => import('@/components/imagecrop/WidgetImageCrop.vue')
)
@@ -169,6 +172,14 @@ const coreWidgetDefinitions: Array<[string, WidgetDefinition]> = [
}
],
['load3D', { component: Load3D, aliases: ['LOAD_3D'], essential: false }],
[
'load3DAdvanced',
{
component: Load3DAdvanced,
aliases: ['LOAD_3D_ADVANCED'],
essential: false
}
],
[
'imagecrop',
{
@@ -243,6 +254,7 @@ const EXPANDING_TYPES = [
'textarea',
'markdown',
'load3D',
'load3DAdvanced',
'curve',
'painter',
'imagecompare',

View File

@@ -252,6 +252,7 @@ const zSystemStats = z.object({
python_version: z.string(),
embedded_python: z.boolean(),
comfyui_version: z.string(),
deploy_environment: z.string().optional(),
pytorch_version: z.string(),
required_frontend_version: z.string().optional(),
argv: z.array(z.string()),