feat(ComboWidget): add ability to have mapped inputs (#6585)

## Summary

1. Add a `getOptionLabel` option to `ComboWidget` so users of it can map
of custom labels to widget values. (e.g., `"My Photo" ->
"my_photo_1235.png"`).
2. Utilize this ability in Cloud environment to map user uploaded
filenames to their corresponding input asset.
3. Copious unit tests to make sure I didn't (AFAIK) break anything
during the refactoring portion of development.
4. Bonus: Scope model browser to only show in cloud distributions until
it's released elsewhere; should prevent some undesired UI behavior if a
user accidentally enables the assetAPI.

## Review Focus

Widget code: please double check the work there.

## Screenshots (if applicable)



https://github.com/user-attachments/assets/a94b3203-c87f-4285-b692-479996859a5a


┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-6585-Feat-input-mapping-2a26d73d365081faa667e49892c8d45a)
by [Unito](https://www.unito.io)
This commit is contained in:
Arjan Singh
2025-11-05 11:33:00 -08:00
committed by GitHub
parent 265f1257e7
commit a2ef569b9c
9 changed files with 1804 additions and 106 deletions

View File

@@ -29,6 +29,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
canvasOnly?: boolean
values?: TValues
/** Optional function to format values for display (e.g., hash → human-readable name) */
getOptionLabel?: (value?: string | null) => string
callback?: IWidget['callback']
}

View File

@@ -34,6 +34,18 @@ export class ComboWidget
override get _displayValue() {
if (this.computedDisabled) return ''
if (this.options.getOptionLabel) {
try {
return this.options.getOptionLabel(
this.value ? String(this.value) : null
)
} catch (e) {
console.error('Failed to map value:', e)
return this.value ? String(this.value) : ''
}
}
const { values: rawValues } = this.options
if (rawValues) {
const values = typeof rawValues === 'function' ? rawValues() : rawValues
@@ -131,7 +143,31 @@ export class ComboWidget
const values = this.getValues(node)
const values_list = toArray(values)
// Handle center click - show dropdown menu
// Use addItem to solve duplicate filename issues
if (this.options.getOptionLabel) {
const menuOptions = {
scale: Math.max(1, canvas.ds.scale),
event: e,
className: 'dark',
callback: (value: string) => {
this.setValue(value, { e, node, canvas })
}
}
const menu = new LiteGraph.ContextMenu([], menuOptions)
for (const value of values_list) {
try {
const label = this.options.getOptionLabel(String(value))
menu.addItem(label, value, menuOptions)
} catch (err) {
console.error('Failed to map value:', err)
menu.addItem(String(value), value, menuOptions)
}
}
return
}
// Show dropdown menu when user clicks on widget label
const text_values = values != values_list ? Object.values(values) : values
new LiteGraph.ContextMenu(text_values, {
scale: Math.max(1, canvas.ds.scale),

View File

@@ -194,20 +194,31 @@ function createAssetService() {
/**
* Gets assets filtered by a specific tag
*
* @param tag - The tag to filter by (e.g., 'models')
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
* @param options - Pagination options
* @param options.limit - Maximum number of assets to return (default: 500)
* @param options.offset - Number of assets to skip (default: 0)
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
*/
async function getAssetsByTag(
tag: string,
includePublic: boolean = true
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
offset = 0
}: { limit?: number; offset?: number } = {}
): Promise<AssetItem[]> {
const queryParams = new URLSearchParams({
include_tags: tag,
limit: DEFAULT_LIMIT.toString(),
limit: limit.toString(),
include_public: includePublic ? 'true' : 'false'
})
if (offset > 0) {
queryParams.set('offset', offset.toString())
}
const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?${queryParams.toString()}`,
`assets for tag ${tag}`

View File

@@ -11,6 +11,7 @@ import {
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
@@ -22,6 +23,8 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { addValueControlWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useAssetsStore } from '@/stores/assetsStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'
import { useRemoteWidget } from './useRemoteWidget'
@@ -32,6 +35,20 @@ const getDefaultValue = (inputSpec: ComboInputSpec) => {
return undefined
}
// Map node types to expected media types
const NODE_MEDIA_TYPE_MAP: Record<string, 'image' | 'video' | 'audio'> = {
LoadImage: 'image',
LoadVideo: 'video',
LoadAudio: 'audio'
}
// Map node types to placeholder i18n keys
const NODE_PLACEHOLDER_MAP: Record<string, string> = {
LoadImage: 'widgets.uploadSelect.placeholderImage',
LoadVideo: 'widgets.uploadSelect.placeholderVideo',
LoadAudio: 'widgets.uploadSelect.placeholderAudio'
}
const addMultiSelectWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
@@ -55,87 +72,168 @@ const addMultiSelectWidget = (
return widget
}
const createAssetBrowserWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec,
defaultValue: string | undefined
): IBaseWidget => {
const currentValue = defaultValue
const displayLabel = currentValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
async function (this: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
)
return widget
}
const createInputMappingWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec,
defaultValue: string | undefined
): IBaseWidget => {
const assetsStore = useAssetsStore()
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue ?? '',
() => {},
{
values: [],
getOptionLabel: (value?: string | null) => {
if (!value) {
const placeholderKey =
NODE_PLACEHOLDER_MAP[node.comfyClass ?? ''] ??
'widgets.uploadSelect.placeholder'
return t(placeholderKey)
}
return assetsStore.getInputName(value)
}
}
)
if (assetsStore.inputAssets.length === 0 && !assetsStore.inputLoading) {
void assetsStore.updateInputs().then(() => {
// edge for users using nodes with 0 prior inputs
// force canvas refresh the first time they add an asset
// so they see filenames instead of hashes.
node.setDirtyCanvas(true, false)
})
}
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
if (prop !== 'values') {
return target[prop as keyof typeof target]
}
return assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
}
})
if (inputSpec.control_after_generate) {
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}
return widget
}
const addComboWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
): IBaseWidget => {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
inputSpec.name
)
const defaultValue = getDefaultValue(inputSpec)
if (isUsingAssetAPI && isEligible) {
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()
const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
async function (this: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
if (isCloud) {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
inputSpec.name
)
return widget
if (isUsingAssetAPI && isEligible) {
return createAssetBrowserWidget(node, inputSpec, defaultValue)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
return createInputMappingWidget(node, inputSpec, defaultValue)
}
}
// Create normal combo widget
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []
// Standard combo widget
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
values: inputSpec.options ?? []
}
)
@@ -143,6 +241,7 @@ const addComboWidget = (
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
@@ -166,6 +265,7 @@ const addComboWidget = (
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
widget.linkedWidgets = addValueControlWidgets(
node,
widget,

View File

@@ -1,5 +1,6 @@
import { useAsyncState } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed } from 'vue'
import {
mapInputFileToAssetItem,
@@ -12,6 +13,8 @@ import { api } from '@/scripts/api'
import { TaskItemImpl } from './queueStore'
const INPUT_LIMIT = 100
/**
* Fetch input files from the internal API (OSS version)
*/
@@ -36,7 +39,9 @@ async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
* Fetch input files from cloud service
*/
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
return await assetService.getAssetsByTag('input', false)
return await assetService.getAssetsByTag('input', false, {
limit: INPUT_LIMIT
})
}
/**
@@ -117,6 +122,30 @@ export const useAssetsStore = defineStore('assets', () => {
}
})
/**
* Map of asset hash filename to asset item for O(1) lookup
* Cloud assets use asset_hash for the hash-based filename
*/
const inputAssetsByFilename = computed(() => {
const map = new Map<string, AssetItem>()
for (const asset of inputAssets.value) {
// Use asset_hash as the key (hash-based filename)
if (asset.asset_hash) {
map.set(asset.asset_hash, asset)
}
}
return map
})
/**
* Get human-readable name for input asset filename
* @param filename Hash-based filename (e.g., "72e786ff...efb7.png")
* @returns Human-readable asset name or original filename if not found
*/
function getInputName(filename: string): string {
return inputAssetsByFilename.value.get(filename)?.name ?? filename
}
return {
// States
inputAssets,
@@ -128,6 +157,10 @@ export const useAssetsStore = defineStore('assets', () => {
// Actions
updateInputs,
updateHistory
updateHistory,
// Input mapping helpers
inputAssetsByFilename,
getInputName
}
})