Skip to content

Commit ea95881

Browse files
christian-byrnegithub-actions
andcommitted
feat: wire essentials_category for Essentials tab display (#9091)
## Summary Wire `essentials_category` through from backend to the Essentials tab UI. Creates a single source of truth for node categorization and ordering. ### Changes **New file — `src/constants/essentialsNodes.ts`:** - Single source of truth: `ESSENTIALS_NODES` (ordered nodes per category), `ESSENTIALS_CATEGORIES` (folder display order), `ESSENTIALS_CATEGORY_MAP` (flat lookup), `TOOLKIT_NOVEL_NODE_NAMES` (telemetry), `TOOLKIT_BLUEPRINT_MODULES` **Refactored files:** - `src/types/nodeSource.ts`: Removed inline `ESSENTIALS_CATEGORY_MOCK`, imports `ESSENTIALS_CATEGORY_MAP` from centralized constants - `src/services/nodeOrganizationService.ts`: Removed inline `NODE_ORDER_BY_FOLDER`, imports `ESSENTIALS_NODES` and `ESSENTIALS_CATEGORIES` - `src/constants/toolkitNodes.ts`: Re-exports from `essentialsNodes.ts` instead of maintaining a separate list **Subgraph passthrough:** - `src/stores/subgraphStore.ts`: Passes `essentials_category` from `GlobalSubgraphData` and extracts it from `definitions.subgraphs[0]` as fallback - `src/platform/workflow/validation/schemas/workflowSchema.ts`: Added `essentials_category` to `SubgraphDefinitionBase` and `zSubgraphDefinition` **Tests:** - `src/constants/essentialsNodes.test.ts`: 6 tests validating no duplicates, complete coverage, basics exclusion - `src/stores/subgraphStore.test.ts`: 2 tests for essentials_category passthrough All 43 relevant tests pass. Typecheck, lint, format clean. **Depends on:** Comfy-Org/ComfyUI#12573 Fixes COM-15221 ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-9091-feat-wire-essentials_category-for-Essentials-tab-display-30f6d73d3650814ab3d4c06b451c273b) by [Unito](https://www.unito.io) --------- Co-authored-by: github-actions <github-actions@github.com>
1 parent 1ffaa47 commit ea95881

11 files changed

Lines changed: 347 additions & 135 deletions

File tree

-796 Bytes
Loading
-1.91 KB
Loading
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, expect, it } from 'vitest'
2+
3+
import {
4+
ESSENTIALS_CATEGORIES,
5+
ESSENTIALS_CATEGORY_CANONICAL,
6+
ESSENTIALS_CATEGORY_MAP,
7+
ESSENTIALS_NODES,
8+
TOOLKIT_BLUEPRINT_MODULES,
9+
TOOLKIT_NOVEL_NODE_NAMES
10+
} from './essentialsNodes'
11+
12+
describe('essentialsNodes', () => {
13+
it('has no duplicate node names across categories', () => {
14+
const seen = new Map<string, string>()
15+
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
16+
for (const node of nodes) {
17+
expect(
18+
seen.has(node),
19+
`"${node}" duplicated in "${category}" and "${seen.get(node)}"`
20+
).toBe(false)
21+
seen.set(node, category)
22+
}
23+
}
24+
})
25+
26+
it('ESSENTIALS_CATEGORY_MAP covers every node in ESSENTIALS_NODES', () => {
27+
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
28+
for (const node of nodes) {
29+
expect(ESSENTIALS_CATEGORY_MAP[node]).toBe(category)
30+
}
31+
}
32+
})
33+
34+
it('TOOLKIT_NOVEL_NODE_NAMES excludes basics nodes', () => {
35+
for (const basicNode of ESSENTIALS_NODES.basics) {
36+
expect(TOOLKIT_NOVEL_NODE_NAMES.has(basicNode)).toBe(false)
37+
}
38+
})
39+
40+
it('TOOLKIT_NOVEL_NODE_NAMES excludes SubgraphBlueprint-prefixed nodes', () => {
41+
for (const name of TOOLKIT_NOVEL_NODE_NAMES) {
42+
expect(name.startsWith('SubgraphBlueprint.')).toBe(false)
43+
}
44+
})
45+
46+
it('ESSENTIALS_NODES keys match ESSENTIALS_CATEGORIES', () => {
47+
const nodeKeys = Object.keys(ESSENTIALS_NODES)
48+
expect(nodeKeys).toEqual([...ESSENTIALS_CATEGORIES])
49+
})
50+
51+
it('TOOLKIT_BLUEPRINT_MODULES contains comfy_essentials', () => {
52+
expect(TOOLKIT_BLUEPRINT_MODULES.has('comfy_essentials')).toBe(true)
53+
})
54+
55+
it('ESSENTIALS_CATEGORY_CANONICAL maps every category case-insensitively', () => {
56+
for (const category of ESSENTIALS_CATEGORIES) {
57+
expect(ESSENTIALS_CATEGORY_CANONICAL.get(category.toLowerCase())).toBe(
58+
category
59+
)
60+
}
61+
})
62+
})

src/constants/essentialsNodes.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
/**
2+
* Single source of truth for Essentials tab node categorization and ordering.
3+
*
4+
* Adding a new node to the Essentials tab? Add it here and nowhere else.
5+
*
6+
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
7+
*/
8+
9+
export const ESSENTIALS_CATEGORIES = [
10+
'basics',
11+
'text generation',
12+
'image generation',
13+
'video generation',
14+
'image tools',
15+
'video tools',
16+
'audio',
17+
'3D'
18+
] as const
19+
20+
export type EssentialsCategory = (typeof ESSENTIALS_CATEGORIES)[number]
21+
22+
/**
23+
* Ordered list of nodes per category.
24+
* Array order = display order in the Essentials tab.
25+
* Presence in a category = the node's essentials_category mock fallback.
26+
*/
27+
export const ESSENTIALS_NODES: Record<EssentialsCategory, readonly string[]> = {
28+
basics: [
29+
'LoadImage',
30+
'LoadVideo',
31+
'Load3D',
32+
'SaveImage',
33+
'SaveVideo',
34+
'SaveGLB',
35+
'PrimitiveStringMultiline',
36+
'PreviewImage'
37+
],
38+
'text generation': ['OpenAIChatNode'],
39+
'image generation': [
40+
'LoraLoader',
41+
'LoraLoaderModelOnly',
42+
'ConditioningCombine'
43+
],
44+
'video generation': [
45+
'SubgraphBlueprint.pose_to_video_ltx_2_0',
46+
'SubgraphBlueprint.canny_to_video_ltx_2_0',
47+
'KlingLipSyncAudioToVideoNode',
48+
'KlingOmniProEditVideoNode'
49+
],
50+
'image tools': [
51+
'ImageBatch',
52+
'ImageCrop',
53+
'ImageCropV2',
54+
'ImageScale',
55+
'ImageScaleBy',
56+
'ImageRotate',
57+
'ImageBlur',
58+
'ImageBlend',
59+
'ImageInvert',
60+
'ImageCompare',
61+
'Canny',
62+
'RecraftRemoveBackgroundNode',
63+
'RecraftVectorizeImageNode',
64+
'LoadImageMask',
65+
'GLSLShader'
66+
],
67+
'video tools': ['GetVideoComponents', 'CreateVideo', 'Video Slice'],
68+
audio: [
69+
'LoadAudio',
70+
'SaveAudio',
71+
'SaveAudioMP3',
72+
'StabilityTextToAudio',
73+
'EmptyLatentAudio'
74+
],
75+
'3D': ['TencentTextToModelNode', 'TencentImageToModelNode']
76+
}
77+
78+
/**
79+
* Flat map: node name → category (derived from ESSENTIALS_NODES).
80+
* Used as mock/fallback when backend doesn't provide essentials_category.
81+
*/
82+
export const ESSENTIALS_CATEGORY_MAP: Record<string, EssentialsCategory> =
83+
Object.fromEntries(
84+
Object.entries(ESSENTIALS_NODES).flatMap(([category, nodes]) =>
85+
nodes.map((node) => [node, category])
86+
)
87+
) as Record<string, EssentialsCategory>
88+
89+
/**
90+
* Case-insensitive lookup: lowercase category → canonical category.
91+
* Used to normalize backend categories (which may be title-cased) to the
92+
* canonical form used in ESSENTIALS_CATEGORIES.
93+
*/
94+
export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap<
95+
string,
96+
EssentialsCategory
97+
> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c]))
98+
99+
/**
100+
* "Novel" toolkit nodes for telemetry — basics excluded.
101+
* Derived from ESSENTIALS_NODES minus the 'basics' category.
102+
*/
103+
export const TOOLKIT_NOVEL_NODE_NAMES: ReadonlySet<string> = new Set(
104+
Object.entries(ESSENTIALS_NODES)
105+
.filter(([cat]) => cat !== 'basics')
106+
.flatMap(([, nodes]) => nodes)
107+
.filter((n) => !n.startsWith('SubgraphBlueprint.'))
108+
)
109+
110+
/**
111+
* python_module values that identify toolkit blueprint nodes.
112+
*/
113+
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
114+
'comfy_essentials'
115+
])

src/constants/toolkitNodes.ts

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,10 @@
11
/**
22
* Toolkit (Essentials) node detection constants.
33
*
4+
* Re-exported from essentialsNodes.ts — the single source of truth.
45
* Used by telemetry to track toolkit node adoption and popularity.
5-
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
6-
*
7-
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
8-
*/
9-
10-
/**
11-
* Canonical node type names for individual toolkit nodes.
12-
*/
13-
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
14-
// Image Tools
15-
'ImageCrop',
16-
'ImageRotate',
17-
'ImageBlur',
18-
'ImageInvert',
19-
'ImageCompare',
20-
'Canny',
21-
22-
// Video Tools
23-
'Video Slice',
24-
25-
// API Nodes
26-
'RecraftRemoveBackgroundNode',
27-
'RecraftVectorizeImageNode',
28-
'KlingOmniProEditVideoNode',
29-
30-
// Shader Nodes
31-
'GLSLShader'
32-
])
33-
34-
/**
35-
* python_module values that identify toolkit blueprint nodes.
36-
* Essentials blueprints are registered with node_pack 'comfy_essentials',
37-
* which maps to python_module on the node def.
386
*/
39-
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
40-
'comfy_essentials'
41-
])
7+
export {
8+
TOOLKIT_NOVEL_NODE_NAMES as TOOLKIT_NODE_NAMES,
9+
TOOLKIT_BLUEPRINT_MODULES
10+
} from './essentialsNodes'

src/platform/workflow/validation/schemas/workflowSchema.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ interface SubgraphDefinitionBase<
405405
/** Optional description shown as tooltip when hovering over the subgraph node. */
406406
description?: string
407407
category?: string
408+
essentials_category?: string
408409
/** Custom metadata for the subgraph (description, searchAliases, etc.) */
409410
extra?: T extends ComfyWorkflow1BaseInput
410411
? z.input<typeof zExtra> | null
@@ -443,6 +444,7 @@ const zSubgraphDefinition = zComfyWorkflow1
443444
/** Optional description shown as tooltip when hovering over the subgraph node. */
444445
description: z.string().optional(),
445446
category: z.string().optional(),
447+
essentials_category: z.string().optional(),
446448
inputNode: zExportedSubgraphIONode,
447449
outputNode: zExportedSubgraphIONode,
448450

src/services/nodeOrganizationService.ts

Lines changed: 13 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
import type { EssentialsCategory } from '@/constants/essentialsNodes'
2+
import {
3+
ESSENTIALS_CATEGORIES,
4+
ESSENTIALS_NODES
5+
} from '@/constants/essentialsNodes'
16
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
27
import { buildNodeDefTree } from '@/stores/nodeDefStore'
38
import type {
@@ -14,46 +19,6 @@ import { upperCase } from 'es-toolkit/string'
1419

1520
const DEFAULT_ICON = 'pi pi-sort'
1621

17-
const NODE_ORDER_BY_FOLDER = {
18-
basics: [
19-
'LoadImage',
20-
'LoadVideo',
21-
'Load3D',
22-
'SaveImage',
23-
'SaveVideo',
24-
'SaveGLB',
25-
'PrimitiveStringMultiline',
26-
'PreviewImage'
27-
],
28-
'image tools': [
29-
'ImageBatch',
30-
'ImageCrop',
31-
'ImageCropV2',
32-
'ImageScale',
33-
'ImageScaleBy',
34-
'ImageRotate',
35-
'ImageBlur',
36-
'ImageBlend',
37-
'ImageInvert',
38-
'Canny',
39-
'RecraftRemoveBackgroundNode',
40-
'LoadImageMask'
41-
],
42-
'video tools': ['GetVideoComponents', 'CreateVideo'],
43-
'image generation': [
44-
'LoraLoader',
45-
'LoraLoaderModelOnly',
46-
'ConditioningCombine'
47-
],
48-
audio: [
49-
'LoadAudio',
50-
'SaveAudio',
51-
'SaveAudioMP3',
52-
'StabilityTextToAudio',
53-
'EmptyLatentAudio'
54-
]
55-
} as const satisfies Record<string, readonly string[]>
56-
5722
export const DEFAULT_GROUPING_ID = 'category' as const
5823
export const DEFAULT_SORTING_ID = 'original' as const
5924
export const DEFAULT_TAB_ID = 'all' as const
@@ -178,34 +143,25 @@ class NodeOrganizationService {
178143
const tree = buildNodeDefTree(essentialNodes, {
179144
pathExtractor: essentialsPathExtractor
180145
})
181-
const folderOrder = [
182-
'basics',
183-
'text generation',
184-
'image generation',
185-
'video generation',
186-
'image tools',
187-
'video tools',
188-
'audio',
189-
'3D'
190-
]
191146
if (tree.children) {
192-
const len = folderOrder.length
147+
const len = ESSENTIALS_CATEGORIES.length
193148
const originalIndex = new Map(
194149
tree.children.map((child, i) => [child, i])
195150
)
196151
tree.children.sort((a, b) => {
197-
const ai = folderOrder.indexOf(a.label ?? '')
198-
const bi = folderOrder.indexOf(b.label ?? '')
152+
const ai = ESSENTIALS_CATEGORIES.indexOf(
153+
a.label as EssentialsCategory
154+
)
155+
const bi = ESSENTIALS_CATEGORIES.indexOf(
156+
b.label as EssentialsCategory
157+
)
199158
const orderA = ai === -1 ? len + originalIndex.get(a)! : ai
200159
const orderB = bi === -1 ? len + originalIndex.get(b)! : bi
201160
return orderA - orderB
202161
})
203162
for (const folder of tree.children) {
204163
if (!folder.children) continue
205-
const order =
206-
NODE_ORDER_BY_FOLDER[
207-
folder.label as keyof typeof NODE_ORDER_BY_FOLDER
208-
]
164+
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]
209165
if (!order) continue
210166
const nodeOrder: readonly string[] = order
211167
const orderLen = nodeOrder.length

0 commit comments

Comments
 (0)