mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-24 16:29:45 +00:00
feat: add filename mapping to frontend to display human readable names on input nodes
This commit is contained in:
@@ -18,9 +18,280 @@ import {
|
||||
type ComfyWidgetConstructorV2,
|
||||
addValueControlWidgets
|
||||
} from '@/scripts/widgets'
|
||||
import { fileNameMappingService } from '@/services/fileNameMappingService'
|
||||
|
||||
import { useRemoteWidget } from './useRemoteWidget'
|
||||
|
||||
// Extended interface for widgets with filename mapping
|
||||
interface IFilenameMappingWidget extends IComboWidget {
|
||||
serializeValue?: () => any
|
||||
getRawValues?: () => string[]
|
||||
refreshMappings?: () => void
|
||||
incrementValue?: (options: any) => void
|
||||
decrementValue?: (options: any) => void
|
||||
setValue?: (value: any, options?: any) => void
|
||||
_displayValue?: string
|
||||
computedDisabled?: boolean
|
||||
}
|
||||
|
||||
// Common file extensions that indicate file inputs
|
||||
const FILE_EXTENSIONS = [
|
||||
'.jpg',
|
||||
'.jpeg',
|
||||
'.png',
|
||||
'.gif',
|
||||
'.webp',
|
||||
'.bmp',
|
||||
'.tiff',
|
||||
'.svg',
|
||||
'.safetensors',
|
||||
'.ckpt',
|
||||
'.pt',
|
||||
'.pth',
|
||||
'.bin'
|
||||
]
|
||||
|
||||
/**
|
||||
* Check if options contain filename-like values
|
||||
*/
|
||||
function hasFilenameOptions(options: any[]): boolean {
|
||||
return options.some((opt: any) => {
|
||||
if (typeof opt !== 'string') return false
|
||||
// Check for common file extensions
|
||||
const hasExtension = FILE_EXTENSIONS.some((ext) =>
|
||||
opt.toLowerCase().endsWith(ext)
|
||||
)
|
||||
// Check for hash-like filenames (ComfyUI hashed files)
|
||||
const isHashLike = /^[a-f0-9]{8,}\./i.test(opt)
|
||||
return hasExtension || isHashLike
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply filename mapping to a widget using a simplified approach
|
||||
*/
|
||||
function applyFilenameMappingToWidget(
|
||||
widget: IComboWidget,
|
||||
node: LGraphNode,
|
||||
_inputSpec: ComboInputSpec
|
||||
) {
|
||||
// Validate widget exists
|
||||
if (!widget) {
|
||||
return
|
||||
}
|
||||
|
||||
// Simple approach: just override _displayValue for text display
|
||||
// Leave all widget functionality intact
|
||||
|
||||
// Cast to extended interface for type safety
|
||||
const mappingWidget = widget as IFilenameMappingWidget
|
||||
|
||||
// Override serializeValue to ensure hash is used for API
|
||||
mappingWidget.serializeValue = function () {
|
||||
// Always return the actual widget value (hash) for serialization
|
||||
return mappingWidget.value
|
||||
}
|
||||
|
||||
// Override _displayValue to show human-readable names
|
||||
try {
|
||||
Object.defineProperty(mappingWidget, '_displayValue', {
|
||||
get() {
|
||||
if (mappingWidget.computedDisabled) return ''
|
||||
|
||||
// Get current hash value
|
||||
const hashValue = mappingWidget.value
|
||||
if (typeof hashValue !== 'string') return String(hashValue)
|
||||
|
||||
// Try to get human-readable name from cache (deduplicated for display)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const humanName = mapping[hashValue]
|
||||
|
||||
// Return human name for display, fallback to hash
|
||||
return humanName || hashValue
|
||||
},
|
||||
configurable: true
|
||||
})
|
||||
} catch (error) {
|
||||
// Property might be non-configurable, continue without override
|
||||
}
|
||||
|
||||
// Also override the options.values to show human names in dropdown
|
||||
const originalOptions = mappingWidget.options as any
|
||||
|
||||
// Store original values array - maintain the same array reference
|
||||
const rawValues = Array.isArray(originalOptions.values)
|
||||
? originalOptions.values
|
||||
: []
|
||||
|
||||
// Create a computed property that returns mapped values
|
||||
if (mappingWidget.options) {
|
||||
try {
|
||||
Object.defineProperty(mappingWidget.options, 'values', {
|
||||
get() {
|
||||
if (!Array.isArray(rawValues)) return rawValues
|
||||
|
||||
// Map values to human-readable names (deduplicated for dropdown display)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const mapped = rawValues.map((value: any) => {
|
||||
if (typeof value === 'string') {
|
||||
const humanName = mapping[value]
|
||||
if (humanName) {
|
||||
return humanName
|
||||
}
|
||||
}
|
||||
return value
|
||||
})
|
||||
return mapped
|
||||
},
|
||||
set(newValues) {
|
||||
// Update raw values array in place to maintain reference
|
||||
rawValues.length = 0
|
||||
if (Array.isArray(newValues)) {
|
||||
rawValues.push(...newValues)
|
||||
}
|
||||
// Trigger UI update
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
},
|
||||
configurable: true,
|
||||
enumerable: true
|
||||
})
|
||||
} catch (error) {
|
||||
// Property might be non-configurable, continue without override
|
||||
}
|
||||
}
|
||||
|
||||
// Add helper methods for managing the raw values
|
||||
mappingWidget.getRawValues = function () {
|
||||
return rawValues
|
||||
}
|
||||
|
||||
// Add a method to force refresh the dropdown
|
||||
mappingWidget.refreshMappings = function () {
|
||||
// Force litegraph to re-read the values and trigger UI update
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
}
|
||||
|
||||
// Override incrementValue and decrementValue for arrow key navigation
|
||||
mappingWidget.incrementValue = function (options: any) {
|
||||
// Get the current human-readable value (deduplicated)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const currentHumanName = mapping[mappingWidget.value] || mappingWidget.value
|
||||
|
||||
// Get the values array (which contains human names through our proxy)
|
||||
const rawValues = mappingWidget.options?.values
|
||||
if (!rawValues || typeof rawValues === 'function') return
|
||||
|
||||
const values = Array.isArray(rawValues)
|
||||
? rawValues
|
||||
: Object.values(rawValues)
|
||||
const currentIndex = values.indexOf(currentHumanName as any)
|
||||
|
||||
if (currentIndex >= 0 && currentIndex < values.length - 1) {
|
||||
// Get next value and set it (setValue will handle conversion)
|
||||
const nextValue = values[currentIndex + 1]
|
||||
mappingWidget.setValue?.(nextValue, options)
|
||||
}
|
||||
}
|
||||
mappingWidget.decrementValue = function (options: any) {
|
||||
// Get the current human-readable value (deduplicated)
|
||||
const mapping = fileNameMappingService.getCachedMapping('input', true)
|
||||
const currentHumanName = mapping[mappingWidget.value] || mappingWidget.value
|
||||
|
||||
// Get the values array (which contains human names through our proxy)
|
||||
const rawValues = mappingWidget.options?.values
|
||||
if (!rawValues || typeof rawValues === 'function') return
|
||||
|
||||
const values = Array.isArray(rawValues)
|
||||
? rawValues
|
||||
: Object.values(rawValues)
|
||||
const currentIndex = values.indexOf(currentHumanName as any)
|
||||
|
||||
if (currentIndex > 0) {
|
||||
// Get previous value and set it (setValue will handle conversion)
|
||||
const prevValue = values[currentIndex - 1]
|
||||
mappingWidget.setValue?.(prevValue, options)
|
||||
}
|
||||
}
|
||||
|
||||
// Override setValue to handle human name selection from dropdown
|
||||
const originalSetValue = mappingWidget.setValue
|
||||
mappingWidget.setValue = function (selectedValue: any, options?: any) {
|
||||
if (typeof selectedValue === 'string') {
|
||||
// Check if this is a human-readable name that needs reverse mapping
|
||||
// Use deduplicated reverse mapping to handle suffixed names
|
||||
const reverseMapping = fileNameMappingService.getCachedReverseMapping(
|
||||
'input',
|
||||
true
|
||||
)
|
||||
const hashValue = reverseMapping[selectedValue] || selectedValue
|
||||
|
||||
// Set the hash value
|
||||
mappingWidget.value = hashValue
|
||||
|
||||
// Call original setValue with hash value if it exists
|
||||
if (originalSetValue) {
|
||||
originalSetValue.call(mappingWidget, hashValue, options)
|
||||
}
|
||||
|
||||
// Trigger callback with hash value
|
||||
if (mappingWidget.callback) {
|
||||
mappingWidget.callback.call(mappingWidget, hashValue)
|
||||
}
|
||||
} else {
|
||||
mappingWidget.value = selectedValue
|
||||
if (originalSetValue) {
|
||||
originalSetValue.call(mappingWidget, selectedValue, options)
|
||||
}
|
||||
if (mappingWidget.callback) {
|
||||
mappingWidget.callback.call(mappingWidget, selectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Override callback to handle human name selection
|
||||
const originalCallback = mappingWidget.callback
|
||||
if (mappingWidget.callback) {
|
||||
mappingWidget.callback = function (selectedValue: any) {
|
||||
if (typeof selectedValue === 'string') {
|
||||
// Check if this is a human-readable name that needs reverse mapping
|
||||
// Use deduplicated reverse mapping to handle suffixed names
|
||||
const reverseMapping = fileNameMappingService.getCachedReverseMapping(
|
||||
'input',
|
||||
true
|
||||
)
|
||||
const hashValue = reverseMapping[selectedValue] || selectedValue
|
||||
|
||||
// Set the hash value
|
||||
mappingWidget.value = hashValue
|
||||
|
||||
// Call original callback with hash value
|
||||
if (originalCallback) {
|
||||
originalCallback.call(mappingWidget, hashValue)
|
||||
}
|
||||
} else {
|
||||
mappingWidget.value = selectedValue
|
||||
if (originalCallback) {
|
||||
originalCallback.call(mappingWidget, selectedValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger async load of mappings and update display when ready
|
||||
fileNameMappingService
|
||||
.getMapping('input')
|
||||
.then(() => {
|
||||
// Mappings loaded, trigger redraw to update display
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
})
|
||||
.catch(() => {
|
||||
// Silently fail - will show hash values as fallback
|
||||
})
|
||||
}
|
||||
|
||||
const getDefaultValue = (inputSpec: ComboInputSpec) => {
|
||||
if (inputSpec.default) return inputSpec.default
|
||||
if (inputSpec.options?.length) return inputSpec.options[0]
|
||||
@@ -91,6 +362,17 @@ const addComboWidget = (node: LGraphNode, inputSpec: ComboInputSpec) => {
|
||||
)
|
||||
}
|
||||
|
||||
// For non-remote combo widgets, check if they contain filenames and apply mapping
|
||||
if (!inputSpec.remote && inputSpec.options) {
|
||||
// Check if options contain filename-like values
|
||||
const hasFilenames = hasFilenameOptions(inputSpec.options)
|
||||
|
||||
if (hasFilenames) {
|
||||
// Apply filename mapping for display
|
||||
applyFilenameMappingToWidget(widget, node, inputSpec)
|
||||
}
|
||||
}
|
||||
|
||||
return widget
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { IComboWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import type { ResultItem, ResultItemType } from '@/schemas/apiSchema'
|
||||
import type { InputSpec } from '@/schemas/nodeDefSchema'
|
||||
import type { ComfyWidgetConstructor } from '@/scripts/widgets'
|
||||
import { fileNameMappingService } from '@/services/fileNameMappingService'
|
||||
import { useNodeOutputStore } from '@/stores/imagePreviewStore'
|
||||
import { isImageUploadInput } from '@/types/nodeDefAugmentation'
|
||||
import { createAnnotatedPath } from '@/utils/formatUtil'
|
||||
@@ -76,11 +77,36 @@ export const useImageUploadWidget = () => {
|
||||
fileFilter,
|
||||
accept,
|
||||
folder,
|
||||
onUploadComplete: (output) => {
|
||||
output.forEach((path) => addToComboValues(fileComboWidget, path))
|
||||
onUploadComplete: async (output) => {
|
||||
// CRITICAL: Refresh mappings FIRST before updating dropdown
|
||||
// This ensures new hash→human mappings are available when dropdown renders
|
||||
try {
|
||||
await fileNameMappingService.refreshMapping('input')
|
||||
} catch (error) {
|
||||
// Continue anyway - will show hash values as fallback
|
||||
}
|
||||
|
||||
// Now add the files to dropdown - addToComboValues will trigger refreshMappings
|
||||
output.forEach((path) => {
|
||||
addToComboValues(fileComboWidget, path)
|
||||
})
|
||||
|
||||
// Set the widget value to the newly uploaded files
|
||||
// Use the last uploaded file for single selection widgets
|
||||
const selectedValue = allow_batch ? output : output[output.length - 1]
|
||||
|
||||
// @ts-expect-error litegraph combo value type does not support arrays yet
|
||||
fileComboWidget.value = output
|
||||
fileComboWidget.callback?.(output)
|
||||
fileComboWidget.value = selectedValue
|
||||
fileComboWidget.callback?.(selectedValue)
|
||||
|
||||
// Force one more refresh to ensure UI is in sync
|
||||
if (typeof (fileComboWidget as any).refreshMappings === 'function') {
|
||||
;(fileComboWidget as any).refreshMappings()
|
||||
}
|
||||
|
||||
// Trigger UI update to show human-readable names
|
||||
node.setDirtyCanvas?.(true, true)
|
||||
node.graph?.setDirtyCanvas?.(true, true)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user