Compare commits

...

1 Commits

Author SHA1 Message Date
bymyself
5829e6ec27 fix: make cloud input-mapping widget spec-driven instead of node-name-driven
Replace hardcoded NODE_MEDIA_TYPE_MAP and NODE_PLACEHOLDER_MAP (keyed by
core node class names) with spec-driven detection using ComboInputSpec
upload flags (video_upload, image_upload, audio_upload).

This allows custom nodes like VHS LoadVideoUpload that declare upload
flags in their Python INPUT_TYPES to automatically get the cloud
input-mapping widget path without being in an allowlist.

- Add getSpecMediaType() helper to derive media type from spec flags
- Replace NODE_PLACEHOLDER_MAP with MEDIA_TYPE_PLACEHOLDER_MAP keyed by
  media type
- Pass mediaType to createInputMappingWidget instead of looking up from
  node.comfyClass
- Add tests for custom nodes with upload flags and proxy value filtering

Amp-Thread-ID: https://ampcode.com/threads/T-019c799e-c5e7-7469-ae07-d7c70540e875
2026-02-19 22:24:35 -08:00
2 changed files with 115 additions and 35 deletions

View File

@@ -296,12 +296,24 @@ describe('useComboWidget', () => {
'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
it.each([
{ nodeClass: 'LoadImage', inputName: 'image' },
{ nodeClass: 'LoadVideo', inputName: 'video' },
{ nodeClass: 'LoadAudio', inputName: 'audio' }
{
nodeClass: 'LoadImage',
inputName: 'image',
uploadFlag: 'image_upload'
},
{
nodeClass: 'LoadVideo',
inputName: 'video',
uploadFlag: 'video_upload'
},
{
nodeClass: 'LoadAudio',
inputName: 'audio',
uploadFlag: 'audio_upload'
}
])(
'should create combo widget with getOptionLabel for $nodeClass in cloud',
({ nodeClass, inputName }) => {
({ nodeClass, inputName, uploadFlag }) => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
@@ -314,7 +326,8 @@ describe('useComboWidget', () => {
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: inputName,
options: [HASH_FILENAME, HASH_FILENAME_2]
options: [HASH_FILENAME, HASH_FILENAME_2],
[uploadFlag]: true
})
const widget = constructor(mockNode, inputSpec)
@@ -347,7 +360,8 @@ describe('useComboWidget', () => {
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
options: [HASH_FILENAME],
image_upload: true
})
constructor(mockNode, inputSpec)
@@ -374,30 +388,94 @@ describe('useComboWidget', () => {
expect(result).toBe('Beautiful Sunset.png')
})
it('should create normal combo widget for non-input nodes in cloud', () => {
it('should create input mapping widget for custom node with video_upload flag', () => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockWidget = createMockWidget({
type: 'combo',
name: 'video'
})
const mockNode = createMockNode('VHS_LoadVideo')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'video',
options: ['video1.mp4'],
video_upload: true
})
constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'video',
'video1.mp4',
expect.any(Function),
expect.objectContaining({
values: [],
getOptionLabel: expect.any(Function)
})
)
})
it('should create normal combo widget for combo without upload flags on cloud', () => {
mockDistributionState.isCloud = true
const constructor = useComboWidget()
const mockWidget = createMockWidget()
const mockNode = createMockNode('SomeOtherNode')
const mockNode = createMockNode('SomeNode')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'option',
options: [HASH_FILENAME, HASH_FILENAME_2]
name: 'sampler',
options: ['euler', 'ddim']
})
const widget = constructor(mockNode, inputSpec)
expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo',
'option',
HASH_FILENAME,
'sampler',
'euler',
expect.any(Function),
{ values: [HASH_FILENAME, HASH_FILENAME_2] }
{ values: ['euler', 'ddim'] }
)
expect(widget).toBe(mockWidget)
})
it('should filter proxy values by media type', () => {
mockDistributionState.isCloud = true
mockAssetsStoreState.inputAssets = [
createMockAssetItem({
id: 'img-1',
name: 'photo.png',
asset_hash: 'hash-img',
mime_type: 'image/png'
}),
createMockAssetItem({
id: 'vid-1',
name: 'clip.mp4',
asset_hash: 'hash-vid',
mime_type: 'video/mp4'
})
]
const constructor = useComboWidget()
const mockWidget = createMockWidget({ type: 'combo', name: 'image' })
const mockNode = createMockNode('LoadImage')
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: ['photo.png'],
image_upload: true
})
constructor(mockNode, inputSpec)
const proxyValues = (mockWidget.options as Record<string, unknown>)
.values as string[]
expect(proxyValues).toEqual(['hash-img'])
})
it('should create normal combo widget for LoadImage in OSS', () => {
mockDistributionState.isCloud = false
@@ -435,7 +513,8 @@ describe('useComboWidget', () => {
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
options: [HASH_FILENAME],
image_upload: true
})
constructor(mockNode, inputSpec)
@@ -454,7 +533,8 @@ describe('useComboWidget', () => {
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
options: [HASH_FILENAME],
image_upload: true
})
constructor(mockNode, inputSpec)
@@ -479,7 +559,8 @@ describe('useComboWidget', () => {
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
const inputSpec = createMockInputSpec({
name: 'image',
options: [HASH_FILENAME]
options: [HASH_FILENAME],
image_upload: true
})
constructor(mockNode, inputSpec)

View File

@@ -31,18 +31,19 @@ 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'
function getSpecMediaType(
inputSpec: ComboInputSpec
): 'image' | 'video' | 'audio' | null {
if (inputSpec.video_upload) return 'video'
if (inputSpec.image_upload || inputSpec.animated_image_upload) return 'image'
if (inputSpec.audio_upload) return 'audio'
return null
}
// 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 MEDIA_TYPE_PLACEHOLDER_MAP: Record<string, string> = {
image: 'widgets.uploadSelect.placeholderImage',
video: 'widgets.uploadSelect.placeholderVideo',
audio: 'widgets.uploadSelect.placeholderAudio'
}
const addMultiSelectWidget = (
@@ -101,7 +102,8 @@ function createAssetBrowserWidget(
const createInputMappingWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec,
defaultValue: string | undefined
defaultValue: string | undefined,
mediaType: 'image' | 'video' | 'audio'
): IBaseWidget => {
const assetsStore = useAssetsStore()
@@ -115,7 +117,7 @@ const createInputMappingWidget = (
getOptionLabel: (value?: string | null) => {
if (!value) {
const placeholderKey =
NODE_PLACEHOLDER_MAP[node.comfyClass ?? ''] ??
MEDIA_TYPE_PLACEHOLDER_MAP[mediaType] ??
'widgets.uploadSelect.placeholder'
return t(placeholderKey)
}
@@ -140,11 +142,7 @@ const createInputMappingWidget = (
return target[prop as keyof typeof target]
}
return assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.filter((asset) => getMediaTypeFromFilename(asset.name) === mediaType)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
}
@@ -188,8 +186,9 @@ const addComboWidget = (
return createAssetBrowserWidget(node, inputSpec, defaultValue)
}
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
return createInputMappingWidget(node, inputSpec, defaultValue)
const mediaType = getSpecMediaType(inputSpec)
if (mediaType) {
return createInputMappingWidget(node, inputSpec, defaultValue, mediaType)
}
}