diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-badge-Hide-built-in-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-badge-Hide-built-in-chromium-linux.png index 7a2293ae15..d13b7dab3a 100644 Binary files a/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-badge-Hide-built-in-chromium-linux.png and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-badge-Hide-built-in-chromium-linux.png differ diff --git a/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-badge-Show-all-chromium-linux.png b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-badge-Show-all-chromium-linux.png index 8eb1320a83..d13b7dab3a 100644 Binary files a/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-badge-Show-all-chromium-linux.png and b/browser_tests/tests/nodeBadge.spec.ts-snapshots/node-badge-Show-all-chromium-linux.png differ diff --git a/src/constants/essentialsNodes.test.ts b/src/constants/essentialsNodes.test.ts new file mode 100644 index 0000000000..23a076799d --- /dev/null +++ b/src/constants/essentialsNodes.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' + +import { + ESSENTIALS_CATEGORIES, + ESSENTIALS_CATEGORY_CANONICAL, + 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() + 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) + }) + + it('ESSENTIALS_CATEGORY_CANONICAL maps every category case-insensitively', () => { + for (const category of ESSENTIALS_CATEGORIES) { + expect(ESSENTIALS_CATEGORY_CANONICAL.get(category.toLowerCase())).toBe( + category + ) + } + }) +}) diff --git a/src/constants/essentialsNodes.ts b/src/constants/essentialsNodes.ts new file mode 100644 index 0000000000..9dae955621 --- /dev/null +++ b/src/constants/essentialsNodes.ts @@ -0,0 +1,115 @@ +/** + * 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 = { + 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', + 'GLSLShader' + ], + '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 = + Object.fromEntries( + Object.entries(ESSENTIALS_NODES).flatMap(([category, nodes]) => + nodes.map((node) => [node, category]) + ) + ) as Record + +/** + * Case-insensitive lookup: lowercase category → canonical category. + * Used to normalize backend categories (which may be title-cased) to the + * canonical form used in ESSENTIALS_CATEGORIES. + */ +export const ESSENTIALS_CATEGORY_CANONICAL: ReadonlyMap< + string, + EssentialsCategory +> = new Map(ESSENTIALS_CATEGORIES.map((c) => [c.toLowerCase(), c])) + +/** + * "Novel" toolkit nodes for telemetry — basics excluded. + * Derived from ESSENTIALS_NODES minus the 'basics' category. + */ +export const TOOLKIT_NOVEL_NODE_NAMES: ReadonlySet = 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 = new Set([ + 'comfy_essentials' +]) diff --git a/src/constants/toolkitNodes.ts b/src/constants/toolkitNodes.ts index d2ba745115..1637445890 100644 --- a/src/constants/toolkitNodes.ts +++ b/src/constants/toolkitNodes.ts @@ -1,41 +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 = new Set([ - // Image Tools - 'ImageCrop', - 'ImageRotate', - 'ImageBlur', - 'ImageInvert', - 'ImageCompare', - 'Canny', - - // Video Tools - 'Video Slice', - - // API Nodes - 'RecraftRemoveBackgroundNode', - 'RecraftVectorizeImageNode', - 'KlingOmniProEditVideoNode', - - // Shader Nodes - 'GLSLShader' -]) - -/** - * 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 = new Set([ - 'comfy_essentials' -]) +export { + TOOLKIT_NOVEL_NODE_NAMES as TOOLKIT_NODE_NAMES, + TOOLKIT_BLUEPRINT_MODULES +} from './essentialsNodes' diff --git a/src/platform/workflow/validation/schemas/workflowSchema.ts b/src/platform/workflow/validation/schemas/workflowSchema.ts index c3dc4e2c92..20b32bb00b 100644 --- a/src/platform/workflow/validation/schemas/workflowSchema.ts +++ b/src/platform/workflow/validation/schemas/workflowSchema.ts @@ -405,6 +405,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 | null @@ -443,6 +444,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, diff --git a/src/services/nodeOrganizationService.ts b/src/services/nodeOrganizationService.ts index 6f6746557c..041cf29358 100644 --- a/src/services/nodeOrganizationService.ts +++ b/src/services/nodeOrganizationService.ts @@ -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 { @@ -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 - export const DEFAULT_GROUPING_ID = 'category' as const export const DEFAULT_SORTING_ID = 'original' as const export const DEFAULT_TAB_ID = 'all' as const @@ -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 diff --git a/src/stores/subgraphStore.test.ts b/src/stores/subgraphStore.test.ts index f2bef04c61..c79e4f67bf 100644 --- a/src/stores/subgraphStore.test.ts +++ b/src/stores/subgraphStore.test.ts @@ -515,6 +515,104 @@ describe('useSubgraphStore', () => { }) }) + describe('essentials_category passthrough', () => { + it('should prefer GlobalSubgraphData essentials_category over definition fallback', async () => { + const graphWithEssentials = { + ...mockGraph, + definitions: { + subgraphs: [ + { + ...mockGraph.definitions?.subgraphs?.[0], + essentials_category: 'Image Tools' + } + ] + } + } + await mockFetch( + {}, + { + bp_precedence: { + name: 'Precedence Blueprint', + info: { node_pack: 'test_pack' }, + data: JSON.stringify(graphWithEssentials), + essentials_category: 'Video Generation' + } + } + ) + const nodeDef = useNodeDefStore().nodeDefs.find( + (d) => d.name === 'SubgraphBlueprint.bp_precedence' + ) + expect(nodeDef?.essentials_category).toBe('video generation') + }) + + 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') + }) + + it('should normalize title-cased essentials_category to canonical form', async () => { + await mockFetch( + {}, + { + bp_3d: { + name: 'Test 3D Blueprint', + info: { node_pack: 'test_pack', category: 'Test Category' }, + data: JSON.stringify(mockGraph), + essentials_category: '3d' + } + } + ) + const nodeDef = useNodeDefStore().nodeDefs.find( + (d) => d.name === 'SubgraphBlueprint.bp_3d' + ) + expect(nodeDef).toBeDefined() + expect(nodeDef?.essentials_category).toBe('3D') + }) + }) + describe('global blueprint filtering', () => { function globalBlueprint( overrides: Partial = {} diff --git a/src/stores/subgraphStore.ts b/src/stores/subgraphStore.ts index 57b69f8176..2c6c4784c4 100644 --- a/src/stores/subgraphStore.ts +++ b/src/stores/subgraphStore.ts @@ -222,6 +222,9 @@ export const useSubgraphStore = defineStore('subgraph', () => { { display_name: v.name, ...(category && { category }), + ...(v.essentials_category && { + essentials_category: v.essentials_category + }), search_aliases: v.info.search_aliases, isGlobal: true }, @@ -291,6 +294,8 @@ export const useSubgraphStore = defineStore('subgraph', () => { const search_aliases = workflowExtra?.BlueprintSearchAliases const subgraphDefCategory = workflow.initialState.definitions?.subgraphs?.[0]?.category + const subgraphDefEssentialsCategory = + workflow.initialState.definitions?.subgraphs?.[0]?.essentials_category const category = subgraphDefCategory ? `Subgraph Blueprints/${subgraphDefCategory}` : 'Subgraph Blueprints' @@ -305,6 +310,7 @@ export const useSubgraphStore = defineStore('subgraph', () => { output_node: false, python_module: 'blueprint', search_aliases, + essentials_category: subgraphDefEssentialsCategory, ...overrides } const nodeDefImpl = new ComfyNodeDefImpl(nodedefv1) diff --git a/src/types/nodeSource.test.ts b/src/types/nodeSource.test.ts index ce03884e52..9a8cf7c34b 100644 --- a/src/types/nodeSource.test.ts +++ b/src/types/nodeSource.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest' -import { NodeSourceType, getNodeSource } from '@/types/nodeSource' +import { + NodeSourceType, + getEssentialsCategory, + getNodeSource +} from '@/types/nodeSource' describe('getNodeSource', () => { it('should return UNKNOWN_NODE_SOURCE when python_module is undefined', () => { @@ -105,6 +109,36 @@ describe('getNodeSource', () => { const result = getNodeSource('nodes.some_module', undefined, 'LoadImage') expect(result.type).toBe(NodeSourceType.Essentials) }) + + it('should normalize title-cased backend categories to canonical form', () => { + const result = getNodeSource( + 'nodes.some_module', + 'Image Generation', + 'SomeNode' + ) + expect(result.type).toBe(NodeSourceType.Essentials) + expect(getEssentialsCategory('SomeNode', 'Image Generation')).toBe( + 'image generation' + ) + }) + }) + + describe('getEssentialsCategory', () => { + it('should normalize title-cased essentials_category to canonical form', () => { + expect(getEssentialsCategory('SomeNode', 'Image Generation')).toBe( + 'image generation' + ) + expect(getEssentialsCategory('SomeNode', '3d')).toBe('3D') + expect(getEssentialsCategory('SomeNode', '3D')).toBe('3D') + }) + + it('should fall back to ESSENTIALS_CATEGORY_MAP when no category provided', () => { + expect(getEssentialsCategory('LoadImage')).toBe('basics') + }) + + it('should return undefined for unknown node without category', () => { + expect(getEssentialsCategory('UnknownNode')).toBeUndefined() + }) }) describe('blueprint nodes', () => { diff --git a/src/types/nodeSource.ts b/src/types/nodeSource.ts index 333fd7bf8c..09ae5562e3 100644 --- a/src/types/nodeSource.ts +++ b/src/types/nodeSource.ts @@ -1,3 +1,8 @@ +import { + ESSENTIALS_CATEGORY_CANONICAL, + ESSENTIALS_CATEGORY_MAP +} from '@/constants/essentialsNodes' + export enum NodeSourceType { Core = 'core', CustomNodes = 'custom_nodes', @@ -26,44 +31,6 @@ const shortenNodeName = (name: string) => { .replace(/(-ComfyUI|_ComfyUI|-Comfy|_Comfy)$/, '') } -// TODO: Remove this mock mapping once object_info/global_subgraphs returns essentials_category -const ESSENTIALS_CATEGORY_MOCK: Record = { - // basics - LoadImage: 'basics', - SaveImage: 'basics', - LoadVideo: 'basics', - SaveVideo: 'basics', - Load3D: 'basics', - SaveGLB: 'basics', - PrimitiveStringMultiline: 'basics', - // image tools - ImageBatch: 'image tools', - ImageCrop: 'image tools', - ImageScale: 'image tools', - ImageRotate: 'image tools', - ImageBlur: 'image tools', - ImageInvert: 'image tools', - Canny: 'image tools', - RecraftRemoveBackgroundNode: 'image tools', - // video tools - GetVideoComponents: 'video tools', - // image gen - LoraLoader: 'image generation', - // video gen - 'SubgraphBlueprint.pose_to_video_ltx_2_0': 'video generation', - 'SubgraphBlueprint.canny_to_video_ltx_2_0': 'video generation', - KlingLipSyncAudioToVideoNode: 'video generation', - // text gen - OpenAIChatNode: 'text generation', - // 3d - TencentTextToModelNode: '3D', - TencentImageToModelNode: '3D', - // audio - LoadAudio: 'audio', - SaveAudio: 'audio', - StabilityTextToAudio: 'audio' -} - /** * Get the essentials category for a node, falling back to mock data if not provided. */ @@ -71,9 +38,12 @@ export function getEssentialsCategory( name?: string, essentials_category?: string ): string | undefined { - return ( - essentials_category ?? (name ? ESSENTIALS_CATEGORY_MOCK[name] : undefined) - ) + const normalizedCategory = essentials_category?.trim().toLowerCase() + const canonical = normalizedCategory + ? (ESSENTIALS_CATEGORY_CANONICAL.get(normalizedCategory) ?? + normalizedCategory) + : undefined + return canonical ?? (name ? ESSENTIALS_CATEGORY_MAP[name] : undefined) } export const getNodeSource = (