feat: add display name mappings for Essentials tab nodes (#9072)

## Summary

Add frontend-only display name mappings for nodes shown in the
Essentials tab, plus parse the new `essentials_category` field from the
backend.

## Changes

- **What**: Created `src/constants/essentialsDisplayNames.ts` with a
static mapping of node names to user-friendly display names (e.g.
`CLIPTextEncode` → "Text", `ImageScale` → "Resize Image"). Regular nodes
use exact name matching; blueprint nodes use prefix matching since their
filenames include model-specific suffixes. Integrated into
`NodeLibrarySidebarTab.vue`'s `renderedRoot` computed for leaf node
labels with fallback to `display_name`. Added `essentials_category`
(z.string().optional()) to the node def schema and `ComfyNodeDefImpl` to
parse the field already sent by the backend (PR #12357).

## Review Focus

Display names are resolved only in the Essentials tab tree view
(`NodeLibrarySidebarTab.vue`), not globally, to avoid side effects on
search, bookmarks, or other views. Blueprint prefix matching is ordered
longest-first so more specific prefixes (e.g. `image_inpainting_`) match
before shorter ones (e.g. `image_edit`).

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-9072-feat-add-display-name-mappings-for-Essentials-tab-nodes-30f6d73d3650817c9acdc9b0315ed0be)
by [Unito](https://www.unito.io)
This commit is contained in:
Christian Byrne
2026-02-22 01:03:15 -08:00
committed by GitHub
parent c972dca61e
commit 54f13930a4
7 changed files with 270 additions and 8 deletions

View File

@@ -154,6 +154,7 @@ import {
render
} from 'vue'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchBox from '@/components/common/SearchBox.vue'
import type { SearchFilter } from '@/components/common/SearchFilterChip.vue'
import TreeExplorer from '@/components/common/TreeExplorer.vue'
@@ -276,7 +277,9 @@ const renderedRoot = computed<TreeExplorerNode<ComfyNodeDefImpl>>(() => {
return {
key: node.key,
label: node.leaf ? node.data.display_name : node.label,
label: node.leaf
? (resolveEssentialsDisplayName(node.data) ?? node.data.display_name)
: node.label,
leaf: node.leaf,
data: node.data,
getIcon() {

View File

@@ -114,6 +114,7 @@ import {
import { computed, nextTick, onMounted, ref, watchEffect } from 'vue'
import { useI18n } from 'vue-i18n'
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
import SearchBox from '@/components/common/SearchBoxV2.vue'
import { useFeatureFlags } from '@/composables/useFeatureFlags'
import { useNodeDragToCanvas } from '@/composables/node/useNodeDragToCanvas'
@@ -230,16 +231,23 @@ function findFirstLeaf(node: TreeNode): TreeNode | undefined {
}
function fillNodeInfo(
node: TreeNode
node: TreeNode,
{ useEssentialsLabels = false }: { useEssentialsLabels?: boolean } = {}
): RenderedTreeExplorerNode<ComfyNodeDefImpl> {
const children = node.children?.map(fillNodeInfo)
const children = node.children?.map((child) =>
fillNodeInfo(child, { useEssentialsLabels })
)
const totalLeaves = node.leaf
? 1
: (children?.reduce((acc, child) => acc + child.totalLeaves, 0) ?? 0)
return {
key: node.key,
label: node.leaf ? node.data?.display_name : node.label,
label: node.leaf
? useEssentialsLabels
? (resolveEssentialsDisplayName(node.data) ?? node.data?.display_name)
: node.data?.display_name
: node.label,
leaf: node.leaf,
data: node.data,
icon: node.leaf ? 'icon-[comfy--node]' : getFolderIcon(node),
@@ -274,7 +282,7 @@ const essentialSections = computed(() => {
const renderedEssentialRoot = computed(() => {
const section = essentialSections.value[0]
return section
? fillNodeInfo(applySorting(section.tree))
? fillNodeInfo(applySorting(section.tree), { useEssentialsLabels: true })
: fillNodeInfo({ key: 'root', label: '', children: [] })
})

View File

@@ -44,7 +44,7 @@ describe('EssentialNodeCard', () => {
return {
key: 'test-key',
label: 'Test Node',
label: data.display_name,
icon: 'icon-[comfy--node]',
type: 'node',
totalLeaves: 1,

View File

@@ -1,7 +1,7 @@
<template>
<div
class="group relative flex flex-col items-center justify-center py-4 px-2 rounded-2xl cursor-pointer select-none transition-colors duration-150 box-content bg-component-node-background hover:bg-secondary-background-hover border border-component-node-border aspect-square"
:data-node-name="node.data?.display_name"
:data-node-name="node.label"
draggable="true"
@click="handleClick"
@dragstart="handleDragStart"
@@ -16,7 +16,7 @@
<TextTickerMultiLine
class="shrink-0 h-8 w-full text-xs font-bold text-foreground leading-4"
>
{{ node.data?.display_name }}
{{ node.label }}
</TextTickerMultiLine>
</div>

View File

@@ -0,0 +1,105 @@
import { describe, expect, it, vi } from 'vitest'
vi.mock('@/i18n', () => ({
t: vi.fn((key: string) => key)
}))
import { resolveEssentialsDisplayName } from '@/constants/essentialsDisplayNames'
describe('resolveEssentialsDisplayName', () => {
describe('exact name matches', () => {
it.each([
['LoadImage', 'essentials.loadImage'],
['SaveImage', 'essentials.saveImage'],
['PrimitiveStringMultiline', 'essentials.text'],
['ImageScale', 'essentials.resizeImage'],
['LoraLoader', 'essentials.loadStyleLora'],
['OpenAIChatNode', 'essentials.textGenerationLLM'],
['RecraftRemoveBackgroundNode', 'essentials.removeBackground'],
['ImageCompare', 'essentials.imageCompare'],
['StabilityTextToAudio', 'essentials.musicGeneration'],
['BatchImagesNode', 'essentials.batchImage'],
['Video Slice', 'essentials.extractFrame'],
['KlingLipSyncAudioToVideoNode', 'essentials.lipsync'],
['KlingLipSyncTextToVideoNode', 'essentials.lipsync']
])('%s -> %s', (name, expected) => {
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
})
})
describe('3D API node alternatives', () => {
it.each([
['TencentTextToModelNode', 'essentials.textTo3DModel'],
['MeshyTextToModelNode', 'essentials.textTo3DModel'],
['TripoTextToModelNode', 'essentials.textTo3DModel'],
['TencentImageToModelNode', 'essentials.imageTo3DModel'],
['MeshyImageToModelNode', 'essentials.imageTo3DModel'],
['TripoImageToModelNode', 'essentials.imageTo3DModel']
])('%s -> %s', (name, expected) => {
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
})
})
describe('blueprint prefix matches', () => {
it.each([
[
'SubgraphBlueprint.text_to_image_flux_schnell.json',
'essentials.textToImage'
],
['SubgraphBlueprint.text_to_image_sd15.json', 'essentials.textToImage'],
[
'SubgraphBlueprint.image_edit_something.json',
'essentials.imageToImage'
],
['SubgraphBlueprint.pose_to_image_v2.json', 'essentials.poseToImage'],
[
'SubgraphBlueprint.canny_to_image_z_image_turbo.json',
'essentials.cannyToImage'
],
[
'SubgraphBlueprint.depth_to_image_z_image_turbo.json',
'essentials.depthToImage'
],
['SubgraphBlueprint.text_to_video_ltx.json', 'essentials.textToVideo'],
['SubgraphBlueprint.image_to_video_wan.json', 'essentials.imageToVideo'],
[
'SubgraphBlueprint.pose_to_video_ltx_2_0.json',
'essentials.poseToVideo'
],
[
'SubgraphBlueprint.canny_to_video_ltx_2_0.json',
'essentials.cannyToVideo'
],
[
'SubgraphBlueprint.depth_to_video_ltx_2_0.json',
'essentials.depthToVideo'
],
[
'SubgraphBlueprint.image_inpainting_qwen_image_instantx.json',
'essentials.inpaintImage'
],
[
'SubgraphBlueprint.image_outpainting_qwen_image_instantx.json',
'essentials.outpaintImage'
]
])('%s -> %s', (name, expected) => {
expect(resolveEssentialsDisplayName({ name })).toBe(expected)
})
})
describe('unmapped nodes', () => {
it('returns undefined for unknown node names', () => {
expect(resolveEssentialsDisplayName({ name: 'SomeRandomNode' })).toBe(
undefined
)
})
it('returns undefined for unknown blueprint prefixes', () => {
expect(
resolveEssentialsDisplayName({
name: 'SubgraphBlueprint.unknown_workflow.json'
})
).toBe(undefined)
})
})
})

View File

@@ -0,0 +1,108 @@
import { t } from '@/i18n'
import type { ComfyNodeDefImpl } from '@/stores/nodeDefStore'
const BLUEPRINT_PREFIX = 'SubgraphBlueprint.'
/**
* Static mapping of node names to their Essentials tab display name i18n keys.
*/
const EXACT_NAME_MAP: Record<string, string> = {
// Basics
LoadImage: 'essentials.loadImage',
SaveImage: 'essentials.saveImage',
LoadVideo: 'essentials.loadVideo',
SaveVideo: 'essentials.saveVideo',
Load3D: 'essentials.load3DModel',
SaveGLB: 'essentials.save3DModel',
PrimitiveStringMultiline: 'essentials.text',
// Image Tools
BatchImagesNode: 'essentials.batchImage',
ImageCrop: 'essentials.cropImage',
ImageScale: 'essentials.resizeImage',
ImageRotate: 'essentials.rotate',
ImageInvert: 'essentials.invert',
Canny: 'essentials.canny',
RecraftRemoveBackgroundNode: 'essentials.removeBackground',
ImageCompare: 'essentials.imageCompare',
// Video Tools
'Video Slice': 'essentials.extractFrame',
// Image Generation
LoraLoader: 'essentials.loadStyleLora',
// Video Generation
KlingLipSyncAudioToVideoNode: 'essentials.lipsync',
KlingLipSyncTextToVideoNode: 'essentials.lipsync',
// Text Generation
OpenAIChatNode: 'essentials.textGenerationLLM',
// 3D
TencentTextToModelNode: 'essentials.textTo3DModel',
TencentImageToModelNode: 'essentials.imageTo3DModel',
MeshyTextToModelNode: 'essentials.textTo3DModel',
MeshyImageToModelNode: 'essentials.imageTo3DModel',
TripoTextToModelNode: 'essentials.textTo3DModel',
TripoImageToModelNode: 'essentials.imageTo3DModel',
// Audio
StabilityTextToAudio: 'essentials.musicGeneration',
LoadAudio: 'essentials.loadAudio',
SaveAudio: 'essentials.saveAudio'
}
/**
* Blueprint prefix patterns mapped to display name i18n keys.
* Entries are matched by checking if the blueprint filename
* (after removing the SubgraphBlueprint. prefix) starts with the key.
* Ordered longest-first so more specific prefixes match before shorter ones.
*/
const BLUEPRINT_PREFIX_MAP: [prefix: string, displayNameKey: string][] = [
// Image Generation
['image_inpainting_', 'essentials.inpaintImage'],
['image_outpainting_', 'essentials.outpaintImage'],
['image_edit', 'essentials.imageToImage'],
['text_to_image', 'essentials.textToImage'],
['pose_to_image', 'essentials.poseToImage'],
['canny_to_image', 'essentials.cannyToImage'],
['depth_to_image', 'essentials.depthToImage'],
// Video Generation
['text_to_video', 'essentials.textToVideo'],
['image_to_video', 'essentials.imageToVideo'],
['pose_to_video', 'essentials.poseToVideo'],
['canny_to_video', 'essentials.cannyToVideo'],
['depth_to_video', 'essentials.depthToVideo']
]
function resolveBlueprintDisplayName(
blueprintName: string
): string | undefined {
for (const [prefix, displayNameKey] of BLUEPRINT_PREFIX_MAP) {
if (blueprintName.startsWith(prefix)) {
return t(displayNameKey)
}
}
return undefined
}
/**
* Resolves the Essentials tab display name for a given node definition.
* Returns `undefined` if the node has no Essentials display name mapping.
*/
export function resolveEssentialsDisplayName(
nodeDef: Pick<ComfyNodeDefImpl, 'name'> | undefined
): string | undefined {
if (!nodeDef) return undefined
const { name } = nodeDef
if (name.startsWith(BLUEPRINT_PREFIX)) {
const blueprintName = name.slice(BLUEPRINT_PREFIX.length)
return resolveBlueprintDisplayName(blueprintName)
}
const key = EXACT_NAME_MAP[name]
return key ? t(key) : undefined
}

View File

@@ -3152,5 +3152,43 @@
"duplicateName": "A secret with this name already exists",
"duplicateProvider": "A secret for this provider already exists"
}
},
"essentials": {
"loadImage": "Load Image",
"saveImage": "Save Image",
"loadVideo": "Load Video",
"saveVideo": "Save Video",
"load3DModel": "Load 3D model",
"save3DModel": "Save 3D Model",
"text": "Text",
"batchImage": "Batch Image",
"cropImage": "Crop Image",
"resizeImage": "Resize Image",
"rotate": "Rotate",
"invert": "Invert",
"canny": "Canny",
"removeBackground": "Remove Background",
"imageCompare": "Image compare",
"extractFrame": "Extract frame",
"loadStyleLora": "Load style (LoRA)",
"lipsync": "Lipsync",
"textGenerationLLM": "Text generation (LLM)",
"textTo3DModel": "Text to 3D model",
"imageTo3DModel": "Image to 3D Model",
"musicGeneration": "Music generation",
"loadAudio": "Load Audio",
"saveAudio": "Save Audio",
"inpaintImage": "Inpaint image",
"outpaintImage": "Outpaint image",
"imageToImage": "Image to image",
"textToImage": "Text to image",
"poseToImage": "Pose to image",
"cannyToImage": "Canny to image",
"depthToImage": "Depth to image",
"textToVideo": "Text to video",
"imageToVideo": "Image to video",
"poseToVideo": "Pose to video",
"cannyToVideo": "Canny to video",
"depthToVideo": "Depth to video"
}
}