feat: read category from blueprint subgraph definition (#9053)

Read `category` from `definitions.subgraphs[0].category` in blueprint
JSON files as a fallback default for node categorization.

This allows blueprint authors to set the category directly in the
blueprint file without needing backend `index.json` support. The
precedence order is:
1. Explicit overrides (e.g. `info.category` from API, or `'Subgraph
Blueprints/User'` for user blueprints)
2. `definitions.subgraphs[0].category` from the blueprint JSON content
3. Bare `'Subgraph Blueprints'` fallback

Companion PR: Comfy-Org/ComfyUI#12552 (adds essential blueprints with
categories matching the Figma design)

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9053-feat-read-category-from-blueprint-subgraph-definition-30e6d73d3650810ca23bfc5a1e97cb31)
by [Unito](https://www.unito.io)

---------

Co-authored-by: github-actions <github-actions@github.com>
This commit is contained in:
Christian Byrne
2026-02-20 23:58:15 -08:00
committed by GitHub
parent 4849d4a6c9
commit 39af93ae3e
6 changed files with 160 additions and 4 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 89 KiB

View File

@@ -137,6 +137,8 @@ export interface ISerialisedGraph extends BaseExportedGraph {
export interface ExportedSubgraph extends SerialisableGraph {
/** The display name of the subgraph. */
name: string
/** Optional category for organizing subgraph blueprints in the node library. */
category?: string
inputNode: ExportedSubgraphIONode
outputNode: ExportedSubgraphIONode
/** Ordered list of inputs to the subgraph itself. Similar to a reroute, with the input side in the graph, and the output side in the subgraph. */

View File

@@ -14,6 +14,46 @@ import { upperCase } from 'es-toolkit/string'
const DEFAULT_ICON = 'pi pi-sort'
const NODE_ORDER_BY_FOLDER = {
basics: [
'LoadImage',
'LoadVideo',
'Load3D',
'SaveImage',
'SaveVideo',
'SaveGLB',
'PrimitiveStringMultiline',
'PreviewImage'
],
'image tools': [
'ImageBatch',
'ImageCrop',
'ImageCropV2',
'ImageScale',
'ImageScaleBy',
'ImageRotate',
'ImageBlur',
'ImageBlend',
'ImageInvert',
'Canny',
'RecraftRemoveBackgroundNode',
'LoadImageMask'
],
'video tools': ['GetVideoComponents', 'CreateVideo'],
'image generation': [
'LoraLoader',
'LoraLoaderModelOnly',
'ConditioningCombine'
],
audio: [
'LoadAudio',
'SaveAudio',
'SaveAudioMP3',
'StabilityTextToAudio',
'EmptyLatentAudio'
]
} as const satisfies Record<string, readonly string[]>
export const DEFAULT_GROUPING_ID = 'category' as const
export const DEFAULT_SORTING_ID = 'original' as const
export const DEFAULT_TAB_ID = 'all' as const
@@ -160,6 +200,25 @@ class NodeOrganizationService {
const orderB = bi === -1 ? len + originalIndex.get(b)! : bi
return orderA - orderB
})
for (const folder of tree.children) {
if (!folder.children) continue
const order =
NODE_ORDER_BY_FOLDER[
folder.label as keyof typeof NODE_ORDER_BY_FOLDER
]
if (!order) continue
const nodeOrder: readonly string[] = order
const orderLen = nodeOrder.length
folder.children.sort((a, b) => {
const nameA = a.data?.name ?? a.label ?? ''
const nameB = b.data?.name ?? b.label ?? ''
const ai = nodeOrder.indexOf(nameA)
const bi = nodeOrder.indexOf(nameB)
const orderA = ai === -1 ? orderLen : ai
const orderB = bi === -1 ? orderLen : bi
return orderA - orderB
})
}
}
return [{ tree }]
}

View File

@@ -363,6 +363,96 @@ describe('useSubgraphStore', () => {
})
})
describe('subgraph definition category', () => {
it('should use category from subgraph definition as default', async () => {
const mockGraphWithCategory = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', category: 'Image Processing' }]
}
}
await mockFetch(
{},
{
categorized: {
name: 'Categorized Blueprint',
info: { node_pack: 'test_pack' },
data: JSON.stringify(mockGraphWithCategory)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.categorized'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints/Image Processing')
})
it('should use User override for user blueprints even with definition category', async () => {
const mockGraphWithCategory = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', category: 'Image Processing' }]
}
}
await mockFetch({ 'user-bp.json': mockGraphWithCategory })
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.user-bp'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints/User')
})
it('should fallback to bare Subgraph Blueprints when no category anywhere', async () => {
await mockFetch(
{},
{
no_cat_global: {
name: 'No Category Global',
info: { node_pack: 'test_pack' },
data: JSON.stringify(mockGraph)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.no_cat_global'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints')
})
it('should let overrides take precedence over definition category', async () => {
const mockGraphWithCategory = {
nodes: [{ type: '123' }],
definitions: {
subgraphs: [{ id: '123', category: 'Image Processing' }]
}
}
await mockFetch(
{},
{
bp_override: {
name: 'Override Blueprint',
info: {
node_pack: 'test_pack',
category: 'Custom Category'
},
data: JSON.stringify(mockGraphWithCategory)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.bp_override'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.category).toBe('Subgraph Blueprints/Custom Category')
})
})
describe('global blueprint filtering', () => {
function globalBlueprint(
overrides: Partial<GlobalSubgraphData['info']> = {}

View File

@@ -210,12 +210,12 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const loaded = await blueprint.load()
const category = v.info.category
? `Subgraph Blueprints/${v.info.category}`
: 'Subgraph Blueprints'
: undefined
registerNodeDef(
loaded,
{
display_name: v.name,
category,
...(category && { category }),
search_aliases: v.info.search_aliases,
isGlobal: true
},
@@ -280,6 +280,11 @@ export const useSubgraphStore = defineStore('subgraph', () => {
const description =
workflowExtra?.BlueprintDescription ?? 'User generated subgraph blueprint'
const search_aliases = workflowExtra?.BlueprintSearchAliases
const subgraphDefCategory =
workflow.initialState.definitions?.subgraphs?.[0]?.category
const category = subgraphDefCategory
? `Subgraph Blueprints/${subgraphDefCategory}`
: 'Subgraph Blueprints'
const nodedefv1: ComfyNodeDefV1 = {
input: { required: inputs },
output: subgraphNode.outputs.map((o) => `${o.type}`),
@@ -287,7 +292,7 @@ export const useSubgraphStore = defineStore('subgraph', () => {
name: typePrefix + name,
display_name: name,
description,
category: 'Subgraph Blueprints',
category,
output_node: false,
python_module: 'blueprint',
search_aliases,

View File

@@ -35,7 +35,7 @@ const ESSENTIALS_CATEGORY_MOCK: Record<string, string> = {
SaveVideo: 'basics',
Load3D: 'basics',
SaveGLB: 'basics',
CLIPTextEncode: 'basics',
PrimitiveStringMultiline: 'basics',
// image tools
ImageBatch: 'image tools',
ImageCrop: 'image tools',