Compare commits

...

13 Commits

Author SHA1 Message Date
Alexander Brown
e20bcd5485 Merge branch 'main' into glary/remove-group-node-creation 2026-05-21 20:34:42 -07:00
AustinMroz
551c595bbb Remove template vram sorting (#12414)
With the dynamic vram changes, vram is both much more difficult to
measure, and much less useful of a metric. To prevent confusion, it has
been removed as a metric.

See also: #9074
2026-05-22 02:56:17 +00:00
AustinMroz
ee286291d4 Fix reactivity on matchType output slots (#12397)
Relevant: #9935. A PR claimed to solve the same issue (and was approved
by me), but the issue persists. Even when checking out that exact
commit, the issue does not appear affected.

This PR is somewhat heavier. It converts the outputs into
shallowReactive. Since there is no individual moment of registration for
outputs, this conversion happens on type change and leverages that
calling `shallowReactive` on a shallow reactive is low cost and
reflexive. It also adds a test to ensure that regression can not happen
in the future.

| Before | After |
| ------ | ----- |
| <img width="360" alt="before"
src="https://github.com/user-attachments/assets/3e4f4a0a-906f-4539-95b6-b2e80de7ceff"
/> | <img width="360" alt="after"
src="https://github.com/user-attachments/assets/1a29ac66-ed5e-4874-82dc-ce9f6135dea5"
/>|
2026-05-22 02:56:02 +00:00
Daxiong (Lin)
efb214efe7 Update favicon and favicon progress with new logo (#12407)
Replace the favicon and favicon progress images with the new logo
2026-05-22 02:51:21 +00:00
Comfy Org PR Bot
9a2bea7283 chore(website): refresh Ashby and cloud nodes snapshots (#12410)
Automated refresh of remote-data snapshots used by the website
build:

- `apps/website/src/data/ashby-roles.snapshot.json` — Ashby job
  board API
- `apps/website/src/data/cloud-nodes.snapshot.json` — Comfy Cloud
  `/api/object_info`

**Flow:**
1. `Release: Website` workflow ran (manual trigger).
2. This PR opens with the regenerated snapshots.
3. `CI: Vercel Website Preview` deploys a preview for review.
4. Merging to `main` triggers the production Vercel deploy.

The snapshot fallback in `apps/website/src/utils/ashby.ts` and
`apps/website/src/utils/cloudNodes.ts` remains intact: builds
without the respective API keys continue to use the committed
snapshot (with a warning annotation in CI).

Triggered by workflow run `26260485885`.

Co-authored-by: christian-byrne <72887196+christian-byrne@users.noreply.github.com>
2026-05-22 00:21:09 +00:00
Christian Byrne
0a07781a76 fix(website): fetch cloud nodes from registry API instead of object_info (#12408)
## Summary

- Fixes cloud-nodes search not finding nodes like FaceDetailer
- The `/api/object_info` endpoint only returns a subset of nodes per
pack (~39 for Impact Pack), but the registry API has the full list (~197
nodes)
- Now fetches complete node list from registry API while still using
object_info to determine which packs are cloud-supported

## Changes

- Add `fetchRegistryPacksWithNodes()` to fetch full node list from
registry (`/nodes/{packId}/versions/{version}/comfy-nodes`)
- Keep using object_info to determine which packs are cloud-supported
- Prefer registry nodes when available, fall back to object_info nodes
- Add retry logic for comfy-nodes fetching
- Add comprehensive tests (13 new tests, 36 total)

## Test plan

- [x] All existing cloudNodes tests pass (36 tests)
- [x] New tests cover registry node fetching, pagination, retry logic
- [x] Type check passes
- [x] Lint passes
- [ ] Verify search for "FaceDetailer" returns Impact Pack on deployed
preview

## Related

- Fixes failing test in #12388 (the data refresh PR)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-21 23:46:33 +00:00
github-actions
41d5476abe [automated] Update test expectations 2026-05-20 04:33:27 +00:00
Glary-Bot
90d9bdfc43 test: drop redundant action-restating comments in groupNode spec 2026-05-20 01:37:19 +00:00
Glary-Bot
26f9f0631e test: restore legacy group node coverage; address review feedback
- Restore the legacy-workflow tests in groupNode.spec.ts that were
  removed earlier. Tests that previously created a group node via the
  (now-removed) Convert UI now load the canonical workflow fixture
  groupnodes/group_node_v1.3.3 and exercise the same downstream
  behaviour (node library sidebar, search, tooltip, manage dialog).
  Tests that fundamentally required creating a brand-new group node
  type (manage-dialog-selects-newly-created-group, reconnect-after-
  manage-save, and the Alt+G keybinding) are intentionally not
  restored because they cannot be exercised without the creation
  surface this PR removes.
- Revert two auto-generated snapshot updates that the CI snapshot
  refresh bot pushed (interaction.spec dragged-node1 and rightClickMenu
  add-group-group-added). Neither is on the menu paths this PR
  touches and the visual diff is sub-pixel noise.
- Restore BadgeVariant.DEPRECATED so future deprecated menu options
  can still use it.
2026-05-20 01:31:19 +00:00
Glary-Bot
19fd581900 test: drop file-header comment from groupNode spec 2026-05-20 01:22:43 +00:00
Glary-Bot
519886831e test: shrink overflow-test viewport so node menu still overflows
Removing the 'Convert to Group Node (Deprecated)' entry from the node
right-click menu made the menu just short enough to fit in the 520px
viewport these tests use, breaking their overflow precondition. Lower
the viewport to 420px so the menu reliably overflows again.
2026-05-20 01:09:15 +00:00
github-actions
abf17f2f53 [automated] Update test expectations 2026-05-20 00:27:27 +00:00
Glary-Bot
f460e105e9 feat: remove ability to create Group Nodes
Group Nodes are a legacy feature superseded by Subgraphs. This removes
all UI entry points for creating new Group Nodes, while keeping the
loading, ungrouping, and management code intact so existing workflows
that contain Group Nodes continue to load and can still be unpacked
or managed.

Removed entry points:
- 'Convert selected nodes to group node' command
- Alt+G keybinding
- 'Convert to Group Node (Deprecated)' canvas and node context menu items
- 'Convert to Group Node' option in the Vue selection menu
- Associated en locale strings
- Browser tests that exercised the creation flow
2026-05-19 23:42:31 +00:00
42 changed files with 11311 additions and 695 deletions

View File

@@ -1,5 +1,5 @@
{
"fetchedAt": "2026-05-12T16:10:34.114Z",
"fetchedAt": "2026-05-22T00:07:48.353Z",
"departments": [
{
"name": "DESIGN",
@@ -36,14 +36,14 @@
"id": "6a6d865eeb3c10a8",
"title": "Senior Software Engineer, Frontend",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/c3e0584d-5490-491f-aae4-b5922ef63fd2"
},
{
"id": "1b4f7f1da9616e14",
"title": "Senior Software Engineer, Backend Generalist",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/732f8b39-076d-4847-afe3-f54d4451607e"
},
{
@@ -71,14 +71,14 @@
"id": "91604c4182a1bc3c",
"title": "Software Engineer, Core ComfyUI Contributor",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7d4062d6-d500-445a-9a5f-014971af259f"
},
{
"id": "a1dbc0576ab14034",
"title": "Software Engineer, ComfyUI Desktop",
"department": "Engineering",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/ad2f76cb-a787-47d8-81c5-7e7f917747c0"
},
{
@@ -105,21 +105,21 @@
"id": "23dd98cab77ff459",
"title": "Freelance Motion Designer",
"department": "Marketing",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/a7ccc2b4-4d9d-4e04-b39c-28a711995b5b"
},
{
"id": "a998b9fc973ff3c0",
"title": "Creative Artist",
"department": "Marketing",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/19ba10aa-4961-45e8-8473-66a8a7a8079d"
},
{
"id": "3e730938026d6e70",
"title": "Graphic Designer",
"department": "Marketing",
"location": "San Francisco",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/49fa0b07-3fa1-4a3a-b2c6-d2cc684ad63f"
},
{
@@ -135,6 +135,20 @@
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/89d3ff75-2055-4e92-9c69-81feff55627c"
},
{
"id": "e11f8b9e58dbea81",
"title": "Creative Producer",
"department": "Marketing",
"location": "Remote",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/7be2d690-7a2b-4ebf-b1c4-6907b273d3d9"
},
{
"id": "6eac654593208ec3",
"title": "Forward Deployed Creative Technologist",
"department": "Marketing",
"location": "San Francisco",
"jobUrl": "https://jobs.ashbyhq.com/comfy-org/af49c05f-dcd8-4c3d-a464-43eb3b1c6efc"
}
]
},

File diff suppressed because one or more lines are too long

View File

@@ -2,7 +2,8 @@ import { describe, expect, it, vi } from 'vitest'
import {
DEFAULT_REGISTRY_BASE_URL,
fetchRegistryPacks
fetchRegistryPacks,
fetchRegistryPacksWithNodes
} from './cloudNodes.registry'
function jsonResponse(
@@ -142,3 +143,315 @@ describe('fetchRegistryPacks', () => {
expect(result.size).toBe(0)
})
})
describe('fetchRegistryPacksWithNodes', () => {
it('fetches pack metadata and comfy nodes for each pack', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
// Pack metadata request
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '8.0.0', createdAt: '2026-01-01' }
}
]
})
}
// Comfy nodes request
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['comfyui-impact-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(1)
const packData = result.get('comfyui-impact-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('ComfyUI Impact Pack')
expect(packData?.nodes).toHaveLength(2)
expect(packData?.nodes[0]?.comfy_node_name).toBe('FaceDetailer')
})
it('handles pagination for comfy nodes', async () => {
let comfyNodesCallCount = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'big-pack',
name: 'Big Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesCallCount++
const page = Number(url.searchParams.get('page') ?? '1')
if (page === 1) {
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: 'Node1', category: 'cat1' },
{ comfy_node_name: 'Node2', category: 'cat1' }
],
totalNumberOfPages: 2
})
} else {
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'Node3', category: 'cat2' }],
totalNumberOfPages: 2
})
}
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['big-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesCallCount).toBe(2)
const packData = result.get('big-pack')
expect(packData?.nodes).toHaveLength(3)
})
it('returns null for packs without latest_version', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'no-version-pack',
name: 'No Version Pack',
latest_version: null
}
]
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['no-version-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.get('no-version-pack')).toBeNull()
})
it('returns empty nodes array when comfy-nodes request fails', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'failing-pack',
name: 'Failing Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return new Response('Server error', { status: 500 })
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['failing-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('failing-pack')
expect(packData).not.toBeNull()
expect(packData?.pack.name).toBe('Failing Pack')
expect(packData?.nodes).toHaveLength(0)
})
it('handles null comfy_nodes in response', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'null-nodes-pack',
name: 'Null Nodes Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: null,
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['null-nodes-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('null-nodes-pack')
expect(packData?.nodes).toHaveLength(0)
})
it('fetches nodes for multiple packs in parallel', async () => {
const packIds = ['pack-a', 'pack-b', 'pack-c']
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
const requestedIds = url.searchParams.getAll('node_id')
return jsonResponse({
nodes: requestedIds.map((id) => ({
id,
name: id.toUpperCase(),
latest_version: { version: '1.0.0' }
}))
})
}
if (url.pathname.includes('/comfy-nodes')) {
const packId = url.pathname.split('/nodes/')[1]?.split('/')[0]
return jsonResponse({
comfy_nodes: [
{ comfy_node_name: `${packId}-node`, category: 'test' }
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: fetchImpl as typeof fetch
})
expect(result.size).toBe(3)
for (const packId of packIds) {
const packData = result.get(packId)
expect(packData).not.toBeNull()
expect(packData?.nodes[0]?.comfy_node_name).toBe(`${packId}-node`)
}
})
it('retries comfy-nodes fetch once on failure', async () => {
let comfyNodesAttempts = 0
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'retry-pack',
name: 'Retry Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
comfyNodesAttempts++
if (comfyNodesAttempts === 1) {
return new Response('Server error', { status: 500 })
}
return jsonResponse({
comfy_nodes: [{ comfy_node_name: 'RetryNode', category: 'test' }],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['retry-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
expect(comfyNodesAttempts).toBe(2)
const packData = result.get('retry-pack')
expect(packData?.nodes).toHaveLength(1)
expect(packData?.nodes[0]?.comfy_node_name).toBe('RetryNode')
})
it('normalizes null boolean fields in comfy nodes', async () => {
const fetchImpl = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(String(input))
if (url.pathname === '/nodes') {
return jsonResponse({
nodes: [
{
id: 'bool-pack',
name: 'Bool Pack',
latest_version: { version: '1.0.0' }
}
]
})
}
if (url.pathname.includes('/comfy-nodes')) {
return jsonResponse({
comfy_nodes: [
{
comfy_node_name: 'TestNode',
category: 'test',
deprecated: null,
experimental: null
}
],
totalNumberOfPages: 1
})
}
return new Response('Not found', { status: 404 })
})
const result = await fetchRegistryPacksWithNodes(['bool-pack'], {
fetchImpl: fetchImpl as typeof fetch
})
const packData = result.get('bool-pack')
expect(packData?.nodes[0]?.deprecated).toBeUndefined()
expect(packData?.nodes[0]?.experimental).toBeUndefined()
})
})

View File

@@ -5,8 +5,10 @@ import type { components } from '@comfyorg/registry-types'
export const DEFAULT_REGISTRY_BASE_URL = 'https://api.comfy.org'
const DEFAULT_TIMEOUT_MS = 5_000
const BATCH_SIZE = 50
const COMFY_NODES_PAGE_SIZE = 500
export type RegistryPack = components['schemas']['Node']
export type RegistryComfyNode = components['schemas']['ComfyNode']
function nullToUndefined<T>(value: T | null | undefined): T | undefined {
return value ?? undefined
@@ -58,6 +60,29 @@ const RegistryListResponseSchema = z
})
.passthrough()
const RegistryComfyNodeSchema = z
.object({
comfy_node_name: optionalString,
category: optionalString,
description: optionalString,
deprecated: z
.boolean()
.nullish()
.transform((v) => v ?? undefined),
experimental: z
.boolean()
.nullish()
.transform((v) => v ?? undefined)
})
.passthrough()
const RegistryComfyNodesResponseSchema = z
.object({
comfy_nodes: z.array(RegistryComfyNodeSchema).nullish(),
totalNumberOfPages: z.number().nullish()
})
.passthrough()
interface FetchRegistryOptions {
baseUrl?: string
timeoutMs?: number
@@ -122,6 +147,142 @@ export async function fetchRegistryPacks(
return resolved
}
export interface RegistryPackWithNodes {
pack: RegistryPack
nodes: RegistryComfyNode[]
}
export async function fetchRegistryPacksWithNodes(
packIds: readonly string[],
options: FetchRegistryOptions = {}
): Promise<Map<string, RegistryPackWithNodes | null>> {
const packs = await fetchRegistryPacks(packIds, options)
const baseUrl = options.baseUrl ?? DEFAULT_REGISTRY_BASE_URL
const timeoutMs = clampTimeoutMs(options.timeoutMs)
const fetchImpl = options.fetchImpl ?? fetch
const entries = await Promise.all(
[...packs.entries()].map(
async ([packId, pack]): Promise<
[string, RegistryPackWithNodes | null]
> => {
if (!pack?.latest_version?.version) {
return [packId, null]
}
const nodes = await fetchComfyNodesForPack(
fetchImpl,
baseUrl,
packId,
pack.latest_version.version,
timeoutMs
)
return [packId, { pack, nodes }]
}
)
)
return new Map(entries)
}
async function fetchComfyNodesForPack(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
timeoutMs: number
): Promise<RegistryComfyNode[]> {
const allNodes: RegistryComfyNode[] = []
let page = 1
let totalPages = 1
while (page <= totalPages) {
const result = await fetchComfyNodesPageWithRetry(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (!result) break
allNodes.push(...result.nodes)
totalPages = result.totalPages
page++
}
return allNodes
}
async function fetchComfyNodesPageWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const firstAttempt = await fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
if (firstAttempt) return firstAttempt
// Retry once on failure
return fetchComfyNodesPage(
fetchImpl,
baseUrl,
packId,
version,
page,
timeoutMs
)
}
async function fetchComfyNodesPage(
fetchImpl: typeof fetch,
baseUrl: string,
packId: string,
version: string,
page: number,
timeoutMs: number
): Promise<{ nodes: RegistryComfyNode[]; totalPages: number } | null> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeoutMs)
try {
const url = `${baseUrl}/nodes/${encodeURIComponent(packId)}/versions/${encodeURIComponent(version)}/comfy-nodes?limit=${COMFY_NODES_PAGE_SIZE}&page=${page}`
const res = await fetchImpl(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal
})
if (!res.ok) return null
const rawBody: unknown = await res.json()
const parsed = RegistryComfyNodesResponseSchema.safeParse(rawBody)
if (!parsed.success) return null
return {
nodes: (parsed.data.comfy_nodes ?? []) as RegistryComfyNode[],
totalPages: parsed.data.totalNumberOfPages ?? 1
}
} catch {
return null
} finally {
clearTimeout(timer)
}
}
async function fetchBatchWithRetry(
fetchImpl: typeof fetch,
baseUrl: string,

View File

@@ -8,12 +8,16 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NodesSnapshot } from '../data/cloudNodes'
import type * as ObjectInfoParser from '@comfyorg/object-info-parser'
const fetchRegistryPacksMock = vi.hoisted(() => vi.fn(async () => new Map()))
import type { RegistryPackWithNodes } from './cloudNodes.registry'
const fetchRegistryPacksWithNodesMock = vi.hoisted(() =>
vi.fn(async () => new Map<string, RegistryPackWithNodes | null>())
)
const sanitizeCallSpy = vi.hoisted(() => vi.fn())
vi.mock('./cloudNodes.registry', () => ({
DEFAULT_REGISTRY_BASE_URL: 'https://api.comfy.org',
fetchRegistryPacks: fetchRegistryPacksMock
fetchRegistryPacksWithNodes: fetchRegistryPacksWithNodesMock
}))
vi.mock('@comfyorg/object-info-parser', async (importOriginal) => {
@@ -90,8 +94,8 @@ describe('fetchCloudNodesForBuild', () => {
beforeEach(() => {
resetCloudNodesFetcherForTests()
fetchRegistryPacksMock.mockReset()
fetchRegistryPacksMock.mockResolvedValue(new Map())
fetchRegistryPacksWithNodesMock.mockReset()
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
sanitizeCallSpy.mockReset()
delete process.env.WEBSITE_CLOUD_API_KEY
})
@@ -102,14 +106,21 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh when API succeeds', async () => {
fetchRegistryPacksMock.mockResolvedValue(
new Map([
fetchRegistryPacksWithNodesMock.mockResolvedValue(
new Map<string, RegistryPackWithNodes | null>([
[
'comfyui-impact-pack',
{
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
pack: {
id: 'comfyui-impact-pack',
name: 'ComfyUI Impact Pack',
repository: 'https://github.com/ltdrdata/ComfyUI-Impact-Pack',
latest_version: { version: '1.0.0' }
},
nodes: [
{ comfy_node_name: 'FaceDetailer', category: 'detailer' },
{ comfy_node_name: 'DetailerForEach', category: 'detailer' }
]
}
]
])
@@ -129,6 +140,10 @@ describe('fetchCloudNodesForBuild', () => {
expect(outcome.snapshot.packs[0]?.repoUrl).toBe(
'https://github.com/ltdrdata/ComfyUI-Impact-Pack'
)
// Nodes should come from registry, not object_info
expect(outcome.snapshot.packs[0]?.nodes).toHaveLength(2)
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('DetailerForEach')
expect(outcome.snapshot.packs[0]?.nodes[1]?.name).toBe('FaceDetailer')
})
it('drops invalid nodes individually and keeps valid nodes', async () => {
@@ -297,7 +312,7 @@ describe('fetchCloudNodesForBuild', () => {
})
it('returns fresh even when registry enrichment fails', async () => {
fetchRegistryPacksMock.mockResolvedValue(new Map())
fetchRegistryPacksWithNodesMock.mockResolvedValue(new Map())
const fetchImpl = vi.fn(async () => response({ ImpactNode: validNode() }))
const outcome = await fetchCloudNodesForBuild({
apiKey: KEY,
@@ -305,5 +320,8 @@ describe('fetchCloudNodesForBuild', () => {
fetchImpl: fetchImpl as typeof fetch
})
expect(outcome.status).toBe('fresh')
// Falls back to object_info nodes when registry fails
if (outcome.status !== 'fresh') return
expect(outcome.snapshot.packs[0]?.nodes[0]?.name).toBe('ImpactNode')
})
})

View File

@@ -6,12 +6,15 @@ import {
validateComfyNodeDef
} from '@comfyorg/object-info-parser'
import type { RegistryPack } from './cloudNodes.registry'
import type {
RegistryComfyNode,
RegistryPackWithNodes
} from './cloudNodes.registry'
import type { NodesSnapshot, Pack, PackNode } from '../data/cloudNodes'
import bundledSnapshot from '../data/cloud-nodes.snapshot.json' with { type: 'json' }
import { isNodesSnapshot } from '../data/cloudNodes'
import { fetchRegistryPacks } from './cloudNodes.registry'
import { fetchRegistryPacksWithNodes } from './cloudNodes.registry'
import { CloudNodesEnvelopeSchema } from './cloudNodes.schema'
const DEFAULT_BASE_URL = 'https://cloud.comfy.org'
@@ -235,26 +238,28 @@ async function parseCloudNodes(
const sanitizedDefs = sanitizeUserContent(
validDefs as Record<string, NonNullable<(typeof validDefs)[string]>>
)
const grouped = groupNodesByPack(sanitizedDefs)
let registryMap = new Map<string, RegistryPack | null>()
// Use object_info to determine which packs are cloud-supported
const grouped = groupNodesByPack(sanitizedDefs)
const packIds = grouped.map((pack) => pack.id)
// Fetch full pack metadata and node list from registry
let registryMap = new Map<string, RegistryPackWithNodes | null>()
try {
registryMap = await fetchRegistryPacks(
grouped.map((pack) => pack.id),
{ fetchImpl: options.fetchImpl }
)
registryMap = await fetchRegistryPacksWithNodes(packIds, {
fetchImpl: options.fetchImpl
})
} catch {
registryMap = new Map()
}
const packs = grouped.map((pack) =>
toDomainPack(
pack.id,
pack.displayName,
pack.nodes,
registryMap.get(pack.id)
)
)
const packs = grouped
.map((pack) => {
const registryData = registryMap.get(pack.id)
// Use registry nodes if available, otherwise fall back to object_info nodes
return toDomainPack(pack.id, pack.displayName, pack.nodes, registryData)
})
.filter((pack) => pack.nodes.length > 0)
return { kind: 'ok', packs, droppedNodes }
}
@@ -274,7 +279,7 @@ function safeExternalUrl(value: string | undefined): string | undefined {
function toDomainPack(
packId: string,
fallbackDisplayName: string,
nodes: Array<{
objectInfoNodes: Array<{
className: string
def: {
display_name: string
@@ -284,8 +289,18 @@ function toDomainPack(
experimental?: boolean
}
}>,
registryPack: RegistryPack | null | undefined
registryData: RegistryPackWithNodes | null | undefined
): Pack {
const registryPack = registryData?.pack
// Prefer registry nodes if available, fall back to object_info nodes
const nodes =
registryData?.nodes && registryData.nodes.length > 0
? registryData.nodes
.map((node) => toDomainNodeFromRegistry(node))
.filter((n): n is PackNode => n !== null)
: objectInfoNodes.map((node) => toDomainNode(node.className, node.def))
return {
id: packId,
registryId: registryPack?.id,
@@ -308,9 +323,20 @@ function toDomainPack(
registryPack?.latest_version?.createdAt ?? registryPack?.created_at,
supportedOs: registryPack?.supported_os,
supportedAccelerators: registryPack?.supported_accelerators,
nodes: nodes
.map((node) => toDomainNode(node.className, node.def))
.sort((a, b) => a.displayName.localeCompare(b.displayName))
nodes: nodes.sort((a, b) => a.displayName.localeCompare(b.displayName))
}
}
function toDomainNodeFromRegistry(node: RegistryComfyNode): PackNode | null {
if (!node.comfy_node_name) return null
return {
name: node.comfy_node_name,
displayName: node.comfy_node_name,
category: node.category || '',
description: node.description || undefined,
deprecated: node.deprecated,
experimental: node.experimental
}
}

View File

@@ -216,16 +216,6 @@ export class NodeOperationsHelper {
}
}
async convertAllNodesToGroupNode(groupNodeName: string): Promise<void> {
await this.comfyPage.canvas.press('Control+a')
const node = await this.getFirstNodeRef()
if (!node) {
throw new Error('No nodes found to convert')
}
await node.clickContextMenuOption('Convert to Group Node')
await this.fillPromptDialog(groupNodeName)
}
async fillPromptDialog(value: string): Promise<void> {
await this.promptDialogInput.fill(value)
await this.page.keyboard.press('Enter')

View File

@@ -514,17 +514,6 @@ export class NodeReference {
const ctx = this.comfyPage.page.locator('.litecontextmenu')
await ctx.getByText(optionText).click()
}
async convertToGroupNode(groupNodeName: string = 'GroupNode') {
await this.clickContextMenuOption('Convert to Group Node')
await this.comfyPage.nodeOps.fillPromptDialog(groupNodeName)
const nodes = await this.comfyPage.nodeOps.getNodeRefsByType(
`workflow>${groupNodeName}`
)
if (nodes.length !== 1) {
throw new Error(`Did not find single group node (found=${nodes.length})`)
}
return nodes[0]
}
async convertToSubgraph() {
await this.clickContextMenuOption('Convert to Subgraph')
await this.comfyPage.nextFrame()

View File

@@ -7,9 +7,14 @@ import {
} from '@e2e/fixtures/ComfyPage'
import type { NodeLibrarySidebarTab } from '@e2e/fixtures/components/SidebarTab'
import { TestIds } from '@e2e/fixtures/selectors'
import { DefaultGraphPositions } from '@e2e/fixtures/constants/defaultGraphPositions'
import type { NodeReference } from '@e2e/fixtures/utils/litegraphUtils'
const LOADED_WORKFLOW = 'groupnodes/group_node_v1.3.3'
const GROUP_NODE_NAME = 'group_node'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_TYPE = `workflow>${GROUP_NODE_NAME}`
const GROUP_NODE_BOOKMARK = GROUP_NODE_TYPE
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.NodeLibrary.NewDesign', false)
@@ -18,22 +23,19 @@ test.beforeEach(async ({ comfyPage }) => {
test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Node library sidebar', () => {
const groupNodeName = 'DefautWorkflowGroupNode'
const groupNodeCategory = 'group nodes>workflow'
const groupNodeBookmarkName = `workflow>${groupNodeName}`
let libraryTab: NodeLibrarySidebarTab
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
libraryTab = comfyPage.menu.nodeLibraryTab
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await libraryTab.open()
})
test('Is added to node library sidebar', async ({
comfyPage: _comfyPage
}) => {
await expect(libraryTab.getFolder(groupNodeCategory)).toHaveCount(1)
await expect(libraryTab.getFolder(GROUP_NODE_CATEGORY)).toHaveCount(1)
})
test('Can be added to canvas using node library sidebar', async ({
@@ -41,9 +43,8 @@ test.describe('Group Node', { tag: '@node' }, () => {
}) => {
const initialNodeCount = await comfyPage.nodeOps.getGraphNodesCount()
// Add group node from node library sidebar
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getNode(groupNodeName).click()
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab.getNode(GROUP_NODE_NAME).click()
// Verify the node is added to the canvas
await expect
@@ -52,9 +53,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
test('Can be bookmarked and unbookmarked', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab
.getNode(groupNodeName)
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.click()
@@ -63,13 +64,12 @@ test.describe('Group Node', { tag: '@node' }, () => {
.poll(() =>
comfyPage.settings.getSetting('Comfy.NodeLibrary.Bookmarks.V2')
)
.toEqual([groupNodeBookmarkName])
.toEqual([GROUP_NODE_BOOKMARK])
// Verify the bookmark node with the same name is added to the tree
await expect(libraryTab.getNode(groupNodeName)).not.toHaveCount(0)
await expect(libraryTab.getNode(GROUP_NODE_NAME)).not.toHaveCount(0)
// Unbookmark the node
await libraryTab
.getNode(groupNodeName)
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.first()
.click()
@@ -83,9 +83,9 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
test('Displays preview on bookmark hover', async ({ comfyPage }) => {
await libraryTab.getFolder(groupNodeCategory).click()
await libraryTab.getFolder(GROUP_NODE_CATEGORY).click()
await libraryTab
.getNode(groupNodeName)
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.click()
await comfyPage.page
@@ -96,72 +96,57 @@ test.describe('Group Node', { tag: '@node' }, () => {
comfyPage.page.locator('.node-lib-node-preview')
).toBeVisible()
await libraryTab
.getNode(groupNodeName)
.getNode(GROUP_NODE_NAME)
.locator('.bookmark-button')
.first()
.click()
})
})
test(
'Can be added to canvas using search',
{ tag: '@screenshot' },
async ({ comfyPage }) => {
const groupNodeName = 'DefautWorkflowGroupNode'
await comfyPage.nodeOps.convertAllNodesToGroupNode(groupNodeName)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(groupNodeName)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
test('Can be added to canvas using search', async ({ comfyPage }) => {
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
await comfyPage.canvasOps.doubleClick()
await comfyPage.nextFrame()
await comfyPage.searchBox.input.waitFor({ state: 'visible' })
await comfyPage.searchBox.input.fill(GROUP_NODE_NAME)
await comfyPage.searchBox.dropdown.waitFor({ state: 'visible' })
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${groupNodeName}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
const exactGroupNodeResult = comfyPage.searchBox.dropdown
.locator(`li[aria-label="${GROUP_NODE_NAME}"]`)
.first()
await expect(exactGroupNodeResult).toBeVisible()
await exactGroupNodeResult.click()
await expect(comfyPage.canvas).toHaveScreenshot(
'group-node-copy-added-from-search.png'
)
}
)
await expect
.poll(() => comfyPage.nodeOps.getNodeRefsByType(GROUP_NODE_TYPE))
.toHaveLength(2)
})
test('Displays tooltip on title hover', async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.EnableTooltips', true)
await comfyPage.nodeOps.convertAllNodesToGroupNode('Group Node')
await comfyPage.page.mouse.move(47, 173)
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const pos = await groupNode.getPosition()
await comfyPage.page.mouse.move(pos.x + 40, pos.y + 10)
await expect(comfyPage.page.locator('.node-tooltip')).toBeVisible()
})
test('Manage group opens with the correct group selected', async ({
comfyPage
}) => {
const makeGroup = async (name: string, type1: string, type2: string) => {
const node1 = (await comfyPage.nodeOps.getNodeRefsByType(type1))[0]
const node2 = (await comfyPage.nodeOps.getNodeRefsByType(type2))[0]
await node1.click('title')
await node2.click('title', {
modifiers: ['Shift']
})
return await node2.convertToGroupNode(name)
}
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
const groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
const group1 = await makeGroup(
'g1',
'CLIPTextEncode',
'CheckpointLoaderSimple'
)
const group2 = await makeGroup('g2', 'EmptyLatentImage', 'KSampler')
const manage1 = await group1.manageGroupNode()
const manage = await groupNode.manageGroupNode()
await comfyPage.nextFrame()
await expect(manage1.selectedNodeTypeSelect).toHaveValue('g1')
await manage1.close()
await expect(manage1.root).toBeHidden()
const manage2 = await group2.manageGroupNode()
await expect(manage2.selectedNodeTypeSelect).toHaveValue('g2')
await expect(manage.selectedNodeTypeSelect).toHaveValue(GROUP_NODE_NAME)
await manage.close()
await expect(manage.root).toBeHidden()
})
test('Preserves hidden input configuration when containing duplicate node types', async ({
@@ -201,42 +186,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
.toBe(2)
})
test('Reconnects inputs after configuration changed via manage dialog save', async ({
comfyPage
}) => {
const expectSingleNode = async (type: string) => {
const nodes = await comfyPage.nodeOps.getNodeRefsByType(type)
expect(nodes).toHaveLength(1)
return nodes[0]
}
const latent = await expectSingleNode('EmptyLatentImage')
const sampler = await expectSingleNode('KSampler')
// Remove existing link
const samplerInput = await sampler.getInput(0)
await samplerInput.removeLinks()
// Group latent + sampler
await latent.click('title', {
modifiers: ['Shift']
})
await sampler.click('title', {
modifiers: ['Shift']
})
const groupNode = await sampler.convertToGroupNode()
// Connect node to group
const ckpt = await expectSingleNode('CheckpointLoaderSimple')
const input = await ckpt.connectOutput(0, groupNode, 0)
await expect.poll(() => input.getLinkCount()).toBe(1)
// Modify the group node via manage dialog
const manage = await groupNode.manageGroupNode()
await manage.selectNode('KSampler')
await manage.changeTab('Inputs')
await manage.setLabel('model', 'test')
await manage.save()
await manage.close()
// Ensure the link is still present
await expect.poll(() => input.getLinkCount()).toBe(1)
})
test('Loads from a workflow using the legacy path separator ("/")', async ({
comfyPage
}) => {
@@ -249,11 +198,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.describe('Copy and paste', () => {
let groupNode: NodeReference | null
const WORKFLOW_NAME = 'groupnodes/group_node_v1.3.3'
const GROUP_NODE_CATEGORY = 'group nodes>workflow'
const GROUP_NODE_PREFIX = 'workflow>'
const GROUP_NODE_NAME = 'group_node' // Node name in given workflow
const GROUP_NODE_TYPE = `${GROUP_NODE_PREFIX}${GROUP_NODE_NAME}`
const isRegisteredLitegraph = async (comfyPage: ComfyPage) => {
return await comfyPage.page.evaluate((nodeType: string) => {
@@ -282,10 +226,10 @@ test.describe('Group Node', { tag: '@node' }, () => {
test.beforeEach(async ({ comfyPage }) => {
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Top')
await comfyPage.workflow.loadWorkflow(WORKFLOW_NAME)
await comfyPage.workflow.loadWorkflow(LOADED_WORKFLOW)
groupNode = await comfyPage.nodeOps.getFirstNodeRef()
if (!groupNode)
throw new Error(`Group node not found in workflow ${WORKFLOW_NAME}`)
throw new Error(`Group node not found in workflow ${LOADED_WORKFLOW}`)
await groupNode.copy()
})
@@ -299,10 +243,7 @@ test.describe('Group Node', { tag: '@node' }, () => {
test('Copies and pastes group node after clearing workflow', async ({
comfyPage
}) => {
// Set setting
await comfyPage.settings.setSetting('Comfy.ConfirmClear', false)
// Clear workflow
await comfyPage.command.executeCommand('Comfy.ClearWorkflow')
await comfyPage.clipboard.paste()
@@ -342,24 +283,6 @@ test.describe('Group Node', { tag: '@node' }, () => {
})
})
})
test.describe('Keybindings', () => {
test('Convert to group node, no selection', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
test('Convert to group node, selected 1 node', async ({ comfyPage }) => {
await expect(comfyPage.toast.visibleToasts).toHaveCount(0)
await comfyPage.canvas.click({
position: DefaultGraphPositions.textEncodeNode1
})
await comfyPage.nextFrame()
await comfyPage.page.keyboard.press('Alt+g')
await expect(comfyPage.toast.visibleToasts).toHaveCount(1)
})
})
})
test('Convert to subgraph unpacks the group Node @vue-nodes', async ({

View File

@@ -9,7 +9,7 @@ test.describe(
() => {
test.beforeEach(async ({ comfyPage }) => {
// Keep the viewport well below the menu content height so overflow is guaranteed.
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
await comfyPage.settings.setSetting('Comfy.UseNewMenu', 'Disabled')
await comfyPage.settings.setSetting('Comfy.Canvas.SelectionToolbox', true)
await comfyPage.workflow.loadWorkflow('nodes/single_ksampler')

View File

@@ -46,7 +46,7 @@ test.describe(
test('Shape popover opens even when the menu must scroll', async ({
comfyPage
}) => {
await comfyPage.page.setViewportSize({ width: 1280, height: 520 })
await comfyPage.page.setViewportSize({ width: 1280, height: 420 })
const menu = await openMoreOptionsMenu(comfyPage, 'KSampler')
const rootList = menu.locator(':scope > ul')

View File

@@ -35,23 +35,6 @@ test.describe(
'add-group-group-added.png'
)
})
test('Can convert to group node', async ({ comfyPage }) => {
await comfyPage.nodeOps.selectNodes(['CLIP Text Encode (Prompt)'])
await expect(comfyPage.canvas).toHaveScreenshot('selected-2-nodes.png')
await comfyPage.canvasOps.rightClick()
await comfyPage.contextMenu.clickMenuItem(
'Convert to Group Node (Deprecated)'
)
await comfyPage.contextMenu.waitForHidden()
await comfyPage.nodeOps.promptDialogInput.fill('GroupNode2CLIP')
await comfyPage.page.keyboard.press('Enter')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'hidden' })
await comfyPage.expectScreenshot(
comfyPage.canvas,
'right-click-node-group-node.png'
)
})
}
)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 93 KiB

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

After

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 103 KiB

After

Width:  |  Height:  |  Size: 105 KiB

View File

@@ -507,25 +507,6 @@ test.describe('Vue Node Context Menu', { tag: '@vue-nodes' }, () => {
.toBe(initialGroupCount + 1)
})
test('should convert to group node via context menu', async ({
comfyPage
}) => {
await openMultiNodeContextMenu(comfyPage, nodeTitles)
await clickExactMenuItem(comfyPage, 'Convert to Group Node')
await comfyPage.nodeOps.promptDialogInput.waitFor({ state: 'visible' })
await comfyPage.nodeOps.fillPromptDialog('TestGroupNode')
await expect
.poll(async () => {
const groupNodes = await comfyPage.nodeOps.getNodeRefsByType(
'workflow>TestGroupNode'
)
return groupNodes.length
})
.toBe(1)
})
test('should convert selected nodes to subgraph via context menu', async ({
comfyPage
}) => {

View File

@@ -19,3 +19,19 @@ test('Can display a slot mismatched from widget type', async ({
await expect(width.locator('path[fill*="INT"]')).toBeVisible()
await expect(width.locator('path[fill*="FLOAT"]')).toBeVisible()
})
test('MatchType updates output color @vue-nodes', async ({ comfyPage }) => {
await comfyPage.menu.topbar.newWorkflowButton.click()
await comfyPage.nextFrame()
await comfyPage.searchBoxV2.addNode('Load Image')
const loadImage = await comfyPage.vueNodes.getFixtureByTitle('Load Image')
await comfyPage.searchBoxV2.addNode('Switch', {
position: { x: 600, y: 200 }
})
const switchNode = await comfyPage.vueNodes.getFixtureByTitle('switch')
await loadImage.getSlot('MASK').dragTo(switchNode.getSlot('on_false'))
const slotEl = switchNode.getSlot('output').locator('.slot-dot')
await expect.poll(() => slotEl.getAttribute('style')).toContain('MASK')
})

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 647 B

After

Width:  |  Height:  |  Size: 274 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 269 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 674 B

After

Width:  |  Height:  |  Size: 284 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 698 B

After

Width:  |  Height:  |  Size: 281 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 700 B

After

Width:  |  Height:  |  Size: 277 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 280 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 302 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 708 B

After

Width:  |  Height:  |  Size: 285 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 705 B

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -744,10 +744,6 @@ const sortOptions = computed(() => [
value: 'popular'
},
{ name: t('templateWorkflows.sort.newest', 'Newest'), value: 'newest' },
{
name: t('templateWorkflows.sort.vramLowToHigh', 'VRAM Usage (Low to High)'),
value: 'vram-low-to-high'
},
{
name: t(
'templateWorkflows.sort.modelSizeLowToHigh',

View File

@@ -53,7 +53,6 @@ const sortOptions: SelectOption[] = [
{ name: 'Recommended', value: 'recommended' },
{ name: 'Popular', value: 'popular' },
{ name: 'Newest', value: 'newest' },
{ name: 'VRAM Usage (Low to High)', value: 'vram-low-to-high' },
{ name: 'Model Size (Low to High)', value: 'model-size-low-to-high' },
{ name: 'Alphabetical (A-Z)', value: 'alphabetical' }
]

View File

@@ -245,9 +245,7 @@ const MENU_ORDER: string[] = [
'Paste Image',
'Save Image',
'Copy (Clipspace)',
'Paste (Clipspace)',
// Fallback for other core items
'Convert to Group Node (Deprecated)'
'Paste (Clipspace)'
]
/**

View File

@@ -45,8 +45,7 @@ export interface SubMenuOption {
}
export enum BadgeVariant {
NEW = 'new',
DEPRECATED = 'deprecated'
NEW = 'new'
}
// Global singleton for NodeOptions component reference

View File

@@ -72,14 +72,14 @@ describe('useSelectionMenuOptions - multiple nodes options', () => {
expect(mocks.frameNodes).toHaveBeenCalledOnce()
})
it('returns Convert to Group Node option from getMultipleNodesOptions', () => {
it('does not include a Convert to Group Node option', () => {
const { getMultipleNodesOptions } = useSelectionMenuOptions()
const options = getMultipleNodesOptions()
const groupNodeOption = options.find(
(opt) => opt.label === 'contextMenu.Convert to Group Node'
)
expect(groupNodeOption).toBeDefined()
expect(groupNodeOption).toBeUndefined()
})
})

View File

@@ -1,8 +1,6 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useCommandStore } from '@/stores/commandStore'
import { useFrameNodes } from './useFrameNodes'
import { BadgeVariant } from './useMoreOptionsMenu'
import type { MenuOption } from './useMoreOptionsMenu'
@@ -102,28 +100,13 @@ export function useSelectionMenuOptions() {
return options
}
const getMultipleNodesOptions = (): MenuOption[] => {
const convertToGroupNodes = () => {
const commandStore = useCommandStore()
void commandStore.execute(
'Comfy.GroupNode.ConvertSelectedNodesToGroupNode'
)
const getMultipleNodesOptions = (): MenuOption[] => [
{
label: t('g.frameNodes'),
icon: 'icon-[lucide--frame]',
action: frameNodes
}
return [
{
label: t('contextMenu.Convert to Group Node'),
icon: 'icon-[lucide--group]',
action: convertToGroupNodes,
badge: BadgeVariant.DEPRECATED
},
{
label: t('g.frameNodes'),
icon: 'icon-[lucide--frame]',
action: frameNodes
}
]
}
]
const getAlignmentOptions = (): MenuOption[] => [
{

View File

@@ -78,59 +78,6 @@ describe('useTemplateFiltering', () => {
vi.unstubAllGlobals()
})
it('sorts templates by VRAM from low to high and pushes missing values last', () => {
const gb = (value: number) => value * 1024 ** 3
const templates = ref<TemplateInfo[]>([
{
name: 'missing-vram',
description: 'no vram value',
mediaType: 'image',
mediaSubtype: 'png'
},
{
name: 'highest-vram',
description: 'high usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(12)
},
{
name: 'mid-vram',
description: 'medium usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(7.5)
},
{
name: 'low-vram',
description: 'low usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: gb(5)
},
{
name: 'zero-vram',
description: 'unknown usage',
mediaType: 'image',
mediaSubtype: 'png',
vram: 0
}
])
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
sortBy.value = 'vram-low-to-high'
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
'low-vram',
'mid-vram',
'highest-vram',
'missing-vram',
'zero-vram'
])
})
it('filters by search text, models, tags, and license with debounce handling', async () => {
vi.useFakeTimers()

View File

@@ -220,17 +220,6 @@ export function useTemplateFiltering(
})
})
const getVramMetric = (template: TemplateInfo) => {
if (
typeof template.vram === 'number' &&
Number.isFinite(template.vram) &&
template.vram > 0
) {
return template.vram
}
return Number.POSITIVE_INFINITY
}
watch(
filteredByRunsOn,
(templates) => {
@@ -279,22 +268,6 @@ export function useTemplateFiltering(
const dateB = new Date(b.date || '1970-01-01')
return dateB.getTime() - dateA.getTime()
})
case 'vram-low-to-high':
return templates.sort((a, b) => {
const vramA = getVramMetric(a)
const vramB = getVramMetric(b)
if (vramA === vramB) {
const nameA = a.title || a.name || ''
const nameB = b.title || b.name || ''
return nameA.localeCompare(nameB)
}
if (vramA === Number.POSITIVE_INFINITY) return 1
if (vramB === Number.POSITIVE_INFINITY) return -1
return vramA - vramB
})
case 'model-size-low-to-high':
return templates.sort((a, b) => {
const sizeA =

View File

@@ -321,12 +321,9 @@ function withComfyMatchType(node: LGraphNode): asserts node is MatchTypeNode {
if (!outputType) throw new Error('invalid connection')
this.outputs.forEach((output, idx) => {
if (!(outputGroups?.[idx] == matchKey)) return
this.outputs[idx] = shallowReactive(this.outputs[idx])
changeOutputType(this, output, outputType)
})
// Force Vue reactivity update for output slot types.
// Outputs are wrapped in shallowReactive by useGraphNodeManager,
// so mutating output.type alone doesn't trigger re-render.
this.outputs = [...this.outputs]
app.canvas?.setDirty(true, true)
}
)

View File

@@ -1833,38 +1833,6 @@ const replaceLegacySeparators = (nodes: ComfyNode[]): void => {
}
}
/**
* Convert selected nodes to a group node
* @throws {Error} if no nodes are selected
* @throws {Error} if a group node is already selected
* @throws {Error} if a group node is selected
*
* The context menu item should not be available if any of the above conditions are met.
* The error is automatically handled by the commandStore when the command is executed.
*/
async function convertSelectedNodesToGroupNode() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
if (nodes.length === 0) {
throw new Error('No nodes selected')
}
if (nodes.length === 1) {
throw new Error('Please select multiple nodes to convert to group node')
}
for (const node of nodes) {
if (node instanceof SubgraphNode) {
throw new Error('Selected nodes contain a subgraph node')
}
if (GroupNodeHandler.isGroupNode(node)) {
throw new Error('Selected nodes contain a group node')
}
}
return await GroupNodeHandler.fromNodes(nodes)
}
const convertDisabled = (selected: LGraphNode[]) =>
selected.length < 2 || !!selected.find((n) => GroupNodeHandler.isGroupNode(n))
function ungroupSelectedGroupNodes() {
const nodes = Object.values(app.canvas.selected_nodes ?? {})
for (const node of nodes) {
@@ -1900,13 +1868,6 @@ let globalDefs: Record<string, ComfyNodeDef>
const ext: ComfyExtension = {
name: id,
commands: [
{
id: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
label: 'Convert selected nodes to group node',
icon: 'pi pi-sitemap',
versionAdded: '1.3.17',
function: () => convertSelectedNodesToGroupNode()
},
{
id: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
label: 'Ungroup selected group nodes',
@@ -1924,13 +1885,6 @@ const ext: ComfyExtension = {
}
],
keybindings: [
{
commandId: 'Comfy.GroupNode.ConvertSelectedNodesToGroupNode',
combo: {
alt: true,
key: 'g'
}
},
{
commandId: 'Comfy.GroupNode.UngroupSelectedGroupNodes',
combo: {
@@ -1942,42 +1896,13 @@ const ext: ComfyExtension = {
],
getCanvasMenuItems(canvas): IContextMenuValue[] {
const items: IContextMenuValue[] = []
const selected = Object.values(canvas.selected_nodes ?? {})
const convertEnabled = !convertDisabled(selected)
items.push({
content: `Convert to Group Node (Deprecated)`,
disabled: !convertEnabled,
// @ts-expect-error async callback - legacy menu API doesn't expect Promise
callback: async () => convertSelectedNodesToGroupNode()
})
const groups = canvas.graph?.extra?.groupNodes
const manageDisabled = !groups || !Object.keys(groups).length
items.push({
content: `Manage Group Nodes`,
disabled: manageDisabled,
callback: () => manageGroupNodes()
})
return items
},
getNodeMenuItems(node): IContextMenuValue[] {
if (GroupNodeHandler.isGroupNode(node)) {
return []
}
const selected = Object.values(app.canvas.selected_nodes ?? {})
const convertEnabled = !convertDisabled(selected)
return [
{
content: `Convert to Group Node (Deprecated)`,
disabled: !convertEnabled,
// @ts-expect-error async callback - legacy menu API doesn't expect Promise
callback: async () => convertSelectedNodesToGroupNode()
content: `Manage Group Nodes`,
disabled: manageDisabled,
callback: () => manageGroupNodes()
}
]
},

View File

@@ -158,9 +158,6 @@
"Comfy_Graph_UnpackSubgraph": {
"label": "Unpack the selected Subgraph"
},
"Comfy_GroupNode_ConvertSelectedNodesToGroupNode": {
"label": "Convert selected nodes to group node"
},
"Comfy_GroupNode_ManageGroupNodes": {
"label": "Manage group nodes"
},

View File

@@ -584,7 +584,6 @@
"Copy (Clipspace)": "Copy (Clipspace)",
"Add Node": "Add Node",
"Add Group": "Add Group",
"Convert to Group Node": "Convert to Group Node",
"Manage Group Nodes": "Manage Group Nodes",
"Add Group For Selected Nodes": "Add Group For Selected Nodes",
"Save Selected as Template": "Save Selected as Template",
@@ -1112,7 +1111,6 @@
"alphabetical": "A → Z",
"newest": "Newest",
"searchPlaceholder": "Search...",
"vramLowToHigh": "VRAM Usage (Low to High)",
"modelSizeLowToHigh": "Model Size (Low to High)",
"default": "Default"
},
@@ -1381,7 +1379,6 @@
"Group Selected Nodes": "Group Selected Nodes",
"Toggle promotion of hovered widget": "Toggle promotion of hovered widget",
"Unpack the selected Subgraph": "Unpack the selected Subgraph",
"Convert selected nodes to group node": "Convert selected nodes to group node",
"Manage group nodes": "Manage group nodes",
"Ungroup selected group nodes": "Ungroup selected group nodes",
"About ComfyUI": "About ComfyUI",