This commit is contained in:
Richard Yu
2025-09-02 16:56:40 -07:00
parent 4899c9d25b
commit d83af149b0
7 changed files with 1595 additions and 8 deletions

View File

@@ -18,9 +18,262 @@ import {
type ComfyWidgetConstructorV2,
addValueControlWidgets
} from '@/scripts/widgets'
import { fileNameMappingService } from '@/services/fileNameMappingService'
import { useRemoteWidget } from './useRemoteWidget'
// 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
) {
// Simple approach: just override _displayValue for text display
// Leave all widget functionality intact
console.debug(
`[FilenameMapping] STARTING applyFilenameMappingToWidget for:`,
{
inputName: inputSpec.name,
widgetName: widget.name,
currentOptions: widget.options,
currentValues: Array.isArray(widget.options?.values)
? widget.options.values.slice(0, 3)
: widget.options?.values || 'none'
}
)
// Override serializeValue to ensure hash is used for API
;(widget as any).serializeValue = function () {
// Always return the actual widget value (hash) for serialization
return widget.value
}
// Override _displayValue to show human-readable names
Object.defineProperty(widget, '_displayValue', {
get() {
if ((widget as any).computedDisabled) return ''
// Get current hash value
const hashValue = widget.value
if (typeof hashValue !== 'string') return String(hashValue)
// Try to get human-readable name from cache
const mapping = fileNameMappingService.getCachedMapping('input')
const humanName = mapping[hashValue]
// Return human name for display, fallback to hash
return humanName || hashValue
},
configurable: true
})
// Also override the options.values to show human names in dropdown
const originalOptions = widget.options as any
// Store original values array - maintain the same array reference
const rawValues = Array.isArray(originalOptions.values)
? originalOptions.values
: []
console.debug('[FilenameMapping] Initial raw values:', rawValues)
// Create a computed property that returns mapped values
Object.defineProperty(widget.options, 'values', {
get() {
if (!Array.isArray(rawValues)) return rawValues
// Map values to human-readable names
const mapping = fileNameMappingService.getCachedMapping('input')
const mapped = rawValues.map((value: any) => {
if (typeof value === 'string') {
const humanName = mapping[value]
if (humanName) {
console.debug(`[FilenameMapping] Mapped ${value} -> ${humanName}`)
return humanName
}
}
return value
})
console.debug('[FilenameMapping] Returning mapped values:', mapped)
return mapped
},
set(newValues) {
// Update raw values array in place to maintain reference
rawValues.length = 0
if (Array.isArray(newValues)) {
rawValues.push(...newValues)
}
console.debug('[FilenameMapping] Values set to:', rawValues)
// Trigger UI update
node.setDirtyCanvas?.(true, true)
node.graph?.setDirtyCanvas?.(true, true)
},
configurable: true,
enumerable: true
})
// Add helper methods for managing the raw values
;(widget as any).getRawValues = function () {
return rawValues
}
// Add a method to force refresh the dropdown
;(widget as any).refreshMappings = function () {
console.debug('[FilenameMapping] Force refreshing dropdown')
// Force litegraph to re-read the values
const currentValues = widget.options.values
console.debug('[FilenameMapping] Current mapped values:', currentValues)
// Trigger UI update
node.setDirtyCanvas?.(true, true)
node.graph?.setDirtyCanvas?.(true, true)
}
// Override incrementValue and decrementValue for arrow key navigation
;(widget as any).incrementValue = function (options: any) {
// Get the current human-readable value
const mapping = fileNameMappingService.getCachedMapping('input')
const currentHumanName = mapping[widget.value] || widget.value
// Get the values array (which contains human names through our proxy)
const rawValues = widget.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]
;(widget as any).setValue(nextValue, options)
}
}
;(widget as any).decrementValue = function (options: any) {
// Get the current human-readable value
const mapping = fileNameMappingService.getCachedMapping('input')
const currentHumanName = mapping[widget.value] || widget.value
// Get the values array (which contains human names through our proxy)
const rawValues = widget.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]
;(widget as any).setValue(prevValue, options)
}
}
// Override setValue to handle human name selection from dropdown
const originalSetValue = (widget as any).setValue
;(widget as any).setValue = function (selectedValue: any, options?: any) {
if (typeof selectedValue === 'string') {
// Check if this is a human-readable name that needs reverse mapping
const reverseMapping =
fileNameMappingService.getCachedReverseMapping('input')
const hashValue = reverseMapping[selectedValue] || selectedValue
// Set the hash value
widget.value = hashValue
// Call original setValue with hash value if it exists
if (originalSetValue) {
originalSetValue.call(widget, hashValue, options)
}
// Trigger callback with hash value
if (widget.callback) {
widget.callback.call(widget, hashValue)
}
} else {
widget.value = selectedValue
if (originalSetValue) {
originalSetValue.call(widget, selectedValue, options)
}
if (widget.callback) {
widget.callback.call(widget, selectedValue)
}
}
}
// Override callback to handle human name selection
const originalCallback = widget.callback
widget.callback = function (selectedValue: any) {
if (typeof selectedValue === 'string') {
// Check if this is a human-readable name that needs reverse mapping
const reverseMapping =
fileNameMappingService.getCachedReverseMapping('input')
const hashValue = reverseMapping[selectedValue] || selectedValue
// Set the hash value
widget.value = hashValue
// Call original callback with hash value
if (originalCallback) {
originalCallback.call(widget, hashValue)
}
} else {
widget.value = selectedValue
if (originalCallback) {
originalCallback.call(widget, 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 +344,31 @@ 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)
console.debug(
'[FilenameMapping] Checking combo widget for filename mapping:',
{
inputName: inputSpec.name,
hasFilenames,
optionsCount: inputSpec.options.length,
sampleOptions: inputSpec.options.slice(0, 3)
}
)
if (hasFilenames) {
// Apply filename mapping for display
console.debug(
'[FilenameMapping] Applying filename mapping to widget:',
inputSpec.name
)
applyFilenameMappingToWidget(widget, node, inputSpec)
}
}
return widget
}

View File

@@ -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,49 @@ export const useImageUploadWidget = () => {
fileFilter,
accept,
folder,
onUploadComplete: (output) => {
output.forEach((path) => addToComboValues(fileComboWidget, path))
onUploadComplete: async (output) => {
console.debug('[ImageUpload] Upload complete, output:', output)
// CRITICAL: Refresh mappings FIRST before updating dropdown
// This ensures new hash→human mappings are available when dropdown renders
try {
await fileNameMappingService.refreshMapping('input')
console.debug(
'[ImageUpload] Filename mappings refreshed, updating dropdown'
)
} catch (error) {
console.debug(
'[ImageUpload] Failed to refresh filename mappings:',
error
)
// Continue anyway - will show hash values as fallback
}
// Now add the files to dropdown - addToComboValues will trigger refreshMappings
output.forEach((path) => {
console.debug('[ImageUpload] Adding to combo values:', 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') {
console.debug('[ImageUpload] Final refreshMappings call for UI sync')
;(fileComboWidget as any).refreshMappings()
}
// Trigger UI update to show human-readable names
node.setDirtyCanvas?.(true, true)
node.graph?.setDirtyCanvas?.(true, true)
console.debug('[ImageUpload] Upload handling complete')
}
})

View File

@@ -14,6 +14,7 @@ import type { ResultItemType } from '@/schemas/apiSchema'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { DOMWidget } from '@/scripts/domWidget'
import { useAudioService } from '@/services/audioService'
import { fileNameMappingService } from '@/services/fileNameMappingService'
import { useToastStore } from '@/stores/toastStore'
import { NodeLocatorId } from '@/types'
import { getNodeByLocatorId } from '@/utils/graphTraversalUtil'
@@ -66,10 +67,26 @@ async function uploadFile(
if (resp.status === 200) {
const data = await resp.json()
// Add the file to the dropdown list and update the widget value
// Build the file path
let path = data.name
if (data.subfolder) path = data.subfolder + '/' + path
// CRITICAL: Refresh mappings FIRST before updating dropdown
// This ensures new hash→human mappings are available when dropdown renders
try {
await fileNameMappingService.refreshMapping('input')
console.debug(
'[AudioUpload] Filename mappings refreshed, updating dropdown'
)
} catch (error) {
console.debug(
'[AudioUpload] Failed to refresh filename mappings:',
error
)
// Continue anyway - will show hash values as fallback
}
// Now add the file to the dropdown list - any filename proxy will use fresh mappings
// @ts-expect-error fixme ts strict error
if (!audioWidget.options.values.includes(path)) {
// @ts-expect-error fixme ts strict error

View File

@@ -0,0 +1,321 @@
import { api } from '@/scripts/api'
export type FileType = 'input' | 'output' | 'temp'
export interface FileNameMapping {
[hashFilename: string]: string // hash -> human readable name
}
export interface CacheEntry {
data: FileNameMapping
timestamp: number
error?: Error | null
fetchPromise?: Promise<FileNameMapping>
failed?: boolean
}
/**
* Service for fetching and caching filename mappings from the backend.
* Maps SHA256 hash filenames to their original human-readable names.
*/
export class FileNameMappingService {
private cache = new Map<FileType, CacheEntry>()
private readonly TTL = 5 * 60 * 1000 // 5 minutes
/**
* Get filename mapping for the specified file type.
* @param fileType - The type of files to get mappings for
* @returns Promise resolving to the filename mapping
*/
async getMapping(fileType: FileType = 'input'): Promise<FileNameMapping> {
const cached = this.cache.get(fileType)
// Return cached data if valid and not expired
if (cached && !this.isExpired(cached) && !cached.failed) {
return cached.data
}
// Return cached data if we're already fetching or if previous fetch failed recently
if (cached?.fetchPromise || (cached?.failed && !this.shouldRetry(cached))) {
return cached?.data ?? {}
}
// Fetch new data
return this.fetchMapping(fileType)
}
/**
* Get human-readable filename from hash filename.
* @param hashFilename - The SHA256 hash filename
* @param fileType - The type of file
* @returns Promise resolving to human-readable name or original if not found
*/
async getHumanReadableName(
hashFilename: string,
fileType: FileType = 'input'
): Promise<string> {
try {
const mapping = await this.getMapping(fileType)
return mapping[hashFilename] ?? hashFilename
} catch (error) {
console.warn(
`Failed to get human readable name for ${hashFilename}:`,
error
)
return hashFilename
}
}
/**
* Apply filename mapping to an array of hash filenames.
* @param hashFilenames - Array of SHA256 hash filenames
* @param fileType - The type of files
* @returns Promise resolving to array of human-readable names
*/
async applyMappingToArray(
hashFilenames: string[],
fileType: FileType = 'input'
): Promise<string[]> {
try {
const mapping = await this.getMapping(fileType)
return hashFilenames.map((filename) => mapping[filename] ?? filename)
} catch (error) {
console.warn('Failed to apply filename mapping:', error)
return hashFilenames
}
}
/**
* Get cached mapping synchronously (returns empty object if not cached).
* @param fileType - The file type to get cached mapping for
* @returns The cached mapping or empty object
*/
getCachedMapping(fileType: FileType = 'input'): FileNameMapping {
const cached = this.cache.get(fileType)
if (cached && !this.isExpired(cached) && !cached.failed) {
const result = cached.data
console.debug(
`[FileNameMapping] getCachedMapping returning cached data:`,
{
fileType,
mappingCount: Object.keys(result).length,
sampleMappings: Object.entries(result).slice(0, 3)
}
)
return result
}
console.debug(
`[FileNameMapping] getCachedMapping returning empty object for ${fileType} (cache miss)`
)
return {}
}
/**
* Get reverse mapping (human-readable name to hash) synchronously.
* @param fileType - The file type to get reverse mapping for
* @returns The reverse mapping object
*/
getCachedReverseMapping(
fileType: FileType = 'input'
): Record<string, string> {
const mapping = this.getCachedMapping(fileType)
const reverseMapping: Record<string, string> = {}
// Build reverse mapping: humanName -> hashName
for (const [hash, humanName] of Object.entries(mapping)) {
reverseMapping[humanName] = hash
}
return reverseMapping
}
/**
* Convert a human-readable name back to its hash filename.
* @param humanName - The human-readable filename
* @param fileType - The file type
* @returns The hash filename or the original if no mapping exists
*/
getHashFromHumanName(
humanName: string,
fileType: FileType = 'input'
): string {
const reverseMapping = this.getCachedReverseMapping(fileType)
return reverseMapping[humanName] ?? humanName
}
/**
* Invalidate cached mapping for a specific file type.
* @param fileType - The file type to invalidate, or undefined to clear all
*/
invalidateCache(fileType?: FileType): void {
if (fileType) {
this.cache.delete(fileType)
} else {
this.cache.clear()
}
}
/**
* Refresh the mapping for a specific file type by clearing cache and fetching new data.
* @param fileType - The file type to refresh
* @returns Promise resolving to the new mapping
*/
async refreshMapping(fileType: FileType = 'input'): Promise<FileNameMapping> {
console.debug(`[FileNameMapping] Refreshing mapping for ${fileType}`)
this.invalidateCache(fileType)
const freshMapping = await this.getMapping(fileType)
console.debug(`[FileNameMapping] Fresh mapping fetched:`, {
fileType,
mappingCount: Object.keys(freshMapping).length,
sampleMappings: Object.entries(freshMapping).slice(0, 3)
})
return freshMapping
}
/**
* Ensures mappings are loaded and cached for immediate synchronous access.
* Use this to preload mappings before widget creation.
* @param fileType - The file type to preload
* @returns Promise that resolves when mappings are loaded
*/
async ensureMappingsLoaded(fileType: FileType = 'input'): Promise<void> {
try {
await this.getMapping(fileType)
} catch (error) {
// Errors are already handled in getMapping/performFetch
// This ensures we don't break the app initialization
console.debug(
'[FileNameMappingService] Preload completed with fallback to empty mapping'
)
}
}
private async fetchMapping(fileType: FileType): Promise<FileNameMapping> {
const cacheKey = fileType
let entry = this.cache.get(cacheKey)
if (!entry) {
entry = { data: {}, timestamp: 0 }
this.cache.set(cacheKey, entry)
}
// Prevent concurrent requests for the same fileType
if (entry.fetchPromise) {
return entry.fetchPromise
}
// Set up fetch promise to prevent concurrent requests
entry.fetchPromise = this.performFetch(fileType)
try {
const data = await entry.fetchPromise
// Update cache with successful result
entry.data = data
entry.timestamp = Date.now()
entry.error = null
entry.failed = false
return data
} catch (error) {
// Should not happen as performFetch now returns empty mapping on error
// But keep for safety
entry.error = error instanceof Error ? error : new Error(String(error))
entry.failed = true
console.debug(`[FileNameMappingService] Using fallback for ${fileType}`)
return entry.data // Return existing data or empty object
} finally {
// Clear the promise after completion
entry.fetchPromise = undefined
}
}
private async performFetch(fileType: FileType): Promise<FileNameMapping> {
// Check if api is available
if (!api || typeof api.fetchApi !== 'function') {
console.warn(
'[FileNameMappingService] API not available, returning empty mapping'
)
return {}
}
let response: Response
try {
response = await api.fetchApi(`/files/mappings`)
} catch (error) {
console.warn(
'[FileNameMappingService] Network error fetching mappings:',
error
)
return {} // Return empty mapping instead of throwing
}
if (!response.ok) {
console.warn(
`[FileNameMappingService] Server returned ${response.status} ${response.statusText}, using empty mapping`
)
return {} // Graceful degradation
}
let data: any
try {
// Check if response has json method
if (typeof response.json !== 'function') {
console.warn('[FileNameMappingService] Response has no json() method')
return {}
}
data = await response.json()
} catch (jsonError) {
console.warn(
'[FileNameMappingService] Failed to parse JSON response:',
jsonError
)
return {} // Return empty mapping on parse error
}
// Validate response structure
if (typeof data !== 'object' || data === null || Array.isArray(data)) {
console.warn(
'[FileNameMappingService] Invalid response format, expected object'
)
return {}
}
// Validate and filter entries
const validEntries: FileNameMapping = {}
let invalidEntryCount = 0
for (const [key, value] of Object.entries(data)) {
if (typeof key === 'string' && typeof value === 'string') {
validEntries[key] = value
} else {
invalidEntryCount++
}
}
if (invalidEntryCount > 0) {
console.debug(
`[FileNameMappingService] Filtered out ${invalidEntryCount} invalid entries`
)
}
console.debug(
`[FileNameMappingService] Loaded ${Object.keys(validEntries).length} mappings for '${fileType}'`
)
return validEntries
}
private isExpired(entry: CacheEntry): boolean {
return Date.now() - entry.timestamp > this.TTL
}
private shouldRetry(entry: CacheEntry): boolean {
// Allow retry after 30 seconds for failed requests
return entry.timestamp > 0 && Date.now() - entry.timestamp > 30000
}
}
// Singleton instance
export const fileNameMappingService = new FileNameMappingService()

View File

@@ -43,10 +43,31 @@ export function isAudioNode(node: LGraphNode | undefined): boolean {
export function addToComboValues(widget: IComboWidget, value: string) {
if (!widget.options) widget.options = { values: [] }
if (!widget.options.values) widget.options.values = []
// @ts-expect-error Combo widget values may be a dictionary or legacy function type
if (!widget.options.values.includes(value)) {
// Check if this widget has our filename mapping (has getRawValues method)
const mappingWidget = widget as any
if (
mappingWidget.getRawValues &&
typeof mappingWidget.getRawValues === 'function'
) {
// This is a filename mapping widget - work with raw values directly
const rawValues = mappingWidget.getRawValues()
if (!rawValues.includes(value)) {
console.debug('[FilenameMapping] Adding to raw values:', value)
rawValues.push(value)
// Trigger refresh
if (mappingWidget.refreshMappings) {
mappingWidget.refreshMappings()
}
}
} else {
// Regular widget without mapping
// @ts-expect-error Combo widget values may be a dictionary or legacy function type
widget.options.values.push(value)
if (!widget.options.values.includes(value)) {
// @ts-expect-error Combo widget values may be a dictionary or legacy function type
widget.options.values.push(value)
}
}
}