Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
53 changes: 53 additions & 0 deletions src/constants/essentialsNodes.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe, expect, it } from 'vitest'

import {
ESSENTIALS_CATEGORIES,
ESSENTIALS_CATEGORY_MAP,
ESSENTIALS_NODES,
TOOLKIT_BLUEPRINT_MODULES,
TOOLKIT_NOVEL_NODE_NAMES
} from './essentialsNodes'

describe('essentialsNodes', () => {
it('has no duplicate node names across categories', () => {
const seen = new Map<string, string>()
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
for (const node of nodes) {
expect(
seen.has(node),
`"${node}" duplicated in "${category}" and "${seen.get(node)}"`
).toBe(false)
seen.set(node, category)
}
}
})

it('ESSENTIALS_CATEGORY_MAP covers every node in ESSENTIALS_NODES', () => {
for (const [category, nodes] of Object.entries(ESSENTIALS_NODES)) {
for (const node of nodes) {
expect(ESSENTIALS_CATEGORY_MAP[node]).toBe(category)
}
}
})

it('TOOLKIT_NOVEL_NODE_NAMES excludes basics nodes', () => {
for (const basicNode of ESSENTIALS_NODES.basics) {
expect(TOOLKIT_NOVEL_NODE_NAMES.has(basicNode)).toBe(false)
}
})

it('TOOLKIT_NOVEL_NODE_NAMES excludes SubgraphBlueprint-prefixed nodes', () => {
for (const name of TOOLKIT_NOVEL_NODE_NAMES) {
expect(name.startsWith('SubgraphBlueprint.')).toBe(false)
}
})

it('ESSENTIALS_NODES keys match ESSENTIALS_CATEGORIES', () => {
const nodeKeys = Object.keys(ESSENTIALS_NODES)
expect(nodeKeys).toEqual([...ESSENTIALS_CATEGORIES])
})

it('TOOLKIT_BLUEPRINT_MODULES contains comfy_essentials', () => {
expect(TOOLKIT_BLUEPRINT_MODULES.has('comfy_essentials')).toBe(true)
})
})
104 changes: 104 additions & 0 deletions src/constants/essentialsNodes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
/**
* Single source of truth for Essentials tab node categorization and ordering.
*
* Adding a new node to the Essentials tab? Add it here and nowhere else.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/

export const ESSENTIALS_CATEGORIES = [
'basics',
'text generation',
'image generation',
'video generation',
'image tools',
'video tools',
'audio',
'3D'
] as const

export type EssentialsCategory = (typeof ESSENTIALS_CATEGORIES)[number]

/**
* Ordered list of nodes per category.
* Array order = display order in the Essentials tab.
* Presence in a category = the node's essentials_category mock fallback.
*/
export const ESSENTIALS_NODES: Record<EssentialsCategory, readonly string[]> = {
basics: [
'LoadImage',
'LoadVideo',
'Load3D',
'SaveImage',
'SaveVideo',
'SaveGLB',
'PrimitiveStringMultiline',
'PreviewImage'
],
'text generation': ['OpenAIChatNode'],
'image generation': [
'LoraLoader',
'LoraLoaderModelOnly',
'ConditioningCombine'
],
'video generation': [
'SubgraphBlueprint.pose_to_video_ltx_2_0',
'SubgraphBlueprint.canny_to_video_ltx_2_0',
'KlingLipSyncAudioToVideoNode',
'KlingOmniProEditVideoNode'
],
'image tools': [
'ImageBatch',
'ImageCrop',
'ImageCropV2',
'ImageScale',
'ImageScaleBy',
'ImageRotate',
'ImageBlur',
'ImageBlend',
'ImageInvert',
'ImageCompare',
'Canny',
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'LoadImageMask'
],
'video tools': ['GetVideoComponents', 'CreateVideo', 'Video Slice'],
audio: [
'LoadAudio',
'SaveAudio',
'SaveAudioMP3',
'StabilityTextToAudio',
'EmptyLatentAudio'
],
'3D': ['TencentTextToModelNode', 'TencentImageToModelNode']
}

/**
* Flat map: node name → category (derived from ESSENTIALS_NODES).
* Used as mock/fallback when backend doesn't provide essentials_category.
*/
export const ESSENTIALS_CATEGORY_MAP: Record<string, string> =
Object.fromEntries(
Object.entries(ESSENTIALS_NODES).flatMap(([category, nodes]) =>
nodes.map((node) => [node, category])
)
)

/**
* "Novel" toolkit nodes for telemetry — basics excluded.
* Derived from ESSENTIALS_NODES minus the 'basics' category.
*/
export const TOOLKIT_NOVEL_NODE_NAMES: ReadonlySet<string> = new Set(
Object.entries(ESSENTIALS_NODES)
.filter(([cat]) => cat !== 'basics')
.flatMap(([, nodes]) => nodes)
.filter((n) => !n.startsWith('SubgraphBlueprint.'))
)

/**
* python_module values that identify toolkit blueprint nodes.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])
38 changes: 5 additions & 33 deletions src/constants/toolkitNodes.ts
Original file line number Diff line number Diff line change
@@ -1,38 +1,10 @@
/**
* Toolkit (Essentials) node detection constants.
*
* Re-exported from essentialsNodes.ts — the single source of truth.
* Used by telemetry to track toolkit node adoption and popularity.
* Only novel nodes — basic nodes (LoadImage, SaveImage, etc.) are excluded.
*
* Source: https://www.notion.so/comfy-org/2fe6d73d365080d0a951d14cdf540778
*/

/**
* Canonical node type names for individual toolkit nodes.
*/
export const TOOLKIT_NODE_NAMES: ReadonlySet<string> = new Set([
// Image Tools
'ImageCrop',
'ImageRotate',
'ImageBlur',
'ImageInvert',
'ImageCompare',
'Canny',

// Video Tools
'Video Slice',

// API Nodes
'RecraftRemoveBackgroundNode',
'RecraftVectorizeImageNode',
'KlingOmniProEditVideoNode'
])

/**
* python_module values that identify toolkit blueprint nodes.
* Essentials blueprints are registered with node_pack 'comfy_essentials',
* which maps to python_module on the node def.
*/
export const TOOLKIT_BLUEPRINT_MODULES: ReadonlySet<string> = new Set([
'comfy_essentials'
])
export {
TOOLKIT_NOVEL_NODE_NAMES as TOOLKIT_NODE_NAMES,
TOOLKIT_BLUEPRINT_MODULES
} from './essentialsNodes'
2 changes: 2 additions & 0 deletions src/platform/workflow/validation/schemas/workflowSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -399,6 +399,7 @@ interface SubgraphDefinitionBase<
/** Optional description shown as tooltip when hovering over the subgraph node. */
description?: string
category?: string
essentials_category?: string
/** Custom metadata for the subgraph (description, searchAliases, etc.) */
extra?: T extends ComfyWorkflow1BaseInput
? z.input<typeof zExtra> | null
Expand Down Expand Up @@ -437,6 +438,7 @@ const zSubgraphDefinition = zComfyWorkflow1
/** Optional description shown as tooltip when hovering over the subgraph node. */
description: z.string().optional(),
category: z.string().optional(),
essentials_category: z.string().optional(),
inputNode: zExportedSubgraphIONode,
outputNode: zExportedSubgraphIONode,

Expand Down
70 changes: 13 additions & 57 deletions src/services/nodeOrganizationService.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import type { EssentialsCategory } from '@/constants/essentialsNodes'
import {
ESSENTIALS_CATEGORIES,
ESSENTIALS_NODES
} from '@/constants/essentialsNodes'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
import { buildNodeDefTree } from '@/stores/nodeDefStore'
import type {
Expand All @@ -14,46 +19,6 @@ 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
Expand Down Expand Up @@ -178,34 +143,25 @@ class NodeOrganizationService {
const tree = buildNodeDefTree(essentialNodes, {
pathExtractor: essentialsPathExtractor
})
const folderOrder = [
'basics',
'text generation',
'image generation',
'video generation',
'image tools',
'video tools',
'audio',
'3D'
]
if (tree.children) {
const len = folderOrder.length
const len = ESSENTIALS_CATEGORIES.length
const originalIndex = new Map(
tree.children.map((child, i) => [child, i])
)
tree.children.sort((a, b) => {
const ai = folderOrder.indexOf(a.label ?? '')
const bi = folderOrder.indexOf(b.label ?? '')
const ai = ESSENTIALS_CATEGORIES.indexOf(
a.label as EssentialsCategory
)
const bi = ESSENTIALS_CATEGORIES.indexOf(
b.label as EssentialsCategory
)
const orderA = ai === -1 ? len + originalIndex.get(a)! : ai
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
]
const order = ESSENTIALS_NODES[folder.label as EssentialsCategory]
if (!order) continue
const nodeOrder: readonly string[] = order
const orderLen = nodeOrder.length
Expand Down
50 changes: 50 additions & 0 deletions src/stores/subgraphStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,6 +515,56 @@ describe('useSubgraphStore', () => {
})
})

describe('essentials_category passthrough', () => {
it('should pass essentials_category from GlobalSubgraphData to node def', async () => {
await mockFetch(
{},
{
bp_essentials: {
name: 'Test Essentials Blueprint',
info: { node_pack: 'test_pack', category: 'Test Category' },
data: JSON.stringify(mockGraph),
essentials_category: 'Image Generation'
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.bp_essentials'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.essentials_category).toBe('Image Generation')
})

it('should extract essentials_category from subgraph definition as fallback', async () => {
const graphWithEssentials = {
...mockGraph,
definitions: {
subgraphs: [
{
...mockGraph.definitions?.subgraphs?.[0],
essentials_category: 'Image Tools'
}
]
}
}
await mockFetch(
{},
{
bp_fallback: {
name: 'Fallback Blueprint',
info: { node_pack: 'test_pack' },
data: JSON.stringify(graphWithEssentials)
}
}
)
const nodeDef = useNodeDefStore().nodeDefs.find(
(d) => d.name === 'SubgraphBlueprint.bp_fallback'
)
expect(nodeDef).toBeDefined()
expect(nodeDef?.essentials_category).toBe('Image Tools')
})
})

describe('global blueprint filtering', () => {
function globalBlueprint(
overrides: Partial<GlobalSubgraphData['info']> = {}
Expand Down
Loading
Loading