Compare commits

...

5 Commits

Author SHA1 Message Date
bymyself
55bec130f6 test: improve test robustness and add negative case
- Replace fragile beforeAll + dynamic import with static import + vi.hoisted()
- Extract helpers (getExtension, callBeforeRegister, getUpload) to remove
  repeated non-null assertions and type casts
- Add negative test for unknown node types (UnknownNode)
- Use Partial<ComfyNodeDef> with targeted cast instead of as never

Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11356#pullrequestreview-2919342692
2026-04-20 02:51:37 -07:00
bymyself
a2b6e0e7df fix: remove redundant optional chaining and type cast
Addresses review feedback:
https://github.com/Comfy-Org/ComfyUI_frontend/pull/11356#pullrequestreview-2919342692
2026-04-20 02:47:34 -07:00
dante01yoon
144e60b4ae fix: scope IMAGEUPLOAD fallback to image+video and guard existing upload
Address review feedback on the cloud LoadImage paste fallback:

- Drop LoadAudio (and LoadImageMask) from the fallback. LoadAudio has its
  own AUDIOUPLOAD widget via Comfy.UploadAudio; routing audio through
  IMAGEUPLOAD would reject every pasted/dropped audio file.
- Synthesize video_upload: true when falling back for LoadVideo so
  useImageUploadWidget runs in video mode (correct accept filter and
  preview), instead of silently filtering out video files.
- Bail out early when required.upload is already set so we never clobber
  a sibling uploader (Comfy.UploadAudio runs first via core/index import
  order).
- Tests: cover LoadVideo (video_upload synthesized), LoadAudio (no
  IMAGEUPLOAD attached), and an existing upload widget being preserved.
2026-04-18 17:20:47 +09:00
dante01yoon
027233c3d6 fix: attach IMAGEUPLOAD widget to known media loader nodes
Cloud's backend may serve LoadImage / LoadVideo / LoadAudio without the
image_upload / video_upload / animated_image_upload flag on their media
input. Without that flag, Comfy.UploadImage skips wiring the IMAGEUPLOAD
widget, so node.pasteFiles is never set and the right-click 'Paste Image'
context menu item never appears.

Add a node-name fallback that attaches IMAGEUPLOAD when the input is a
combo on a known media loader node, restoring paste/drag-drop on cloud
LoadImage.
2026-04-18 16:03:32 +09:00
dante01yoon
82c6398972 test: add failing test for missing IMAGEUPLOAD on cloud LoadImage 2026-04-18 16:00:40 +09:00
2 changed files with 193 additions and 1 deletions

View File

@@ -0,0 +1,160 @@
import { describe, expect, it, vi } from 'vitest'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { ComfyNodeDef } from '@/schemas/nodeDefSchema'
import type { ComfyExtension } from '@/types/comfy'
interface CapturedExtension {
name: string
beforeRegisterNodeDef?: ComfyExtension['beforeRegisterNodeDef']
}
const registerExtension = vi.hoisted(() =>
vi.fn<(ext: CapturedExtension) => void>()
)
vi.mock('@/scripts/app', () => ({
app: {
registerExtension: (ext: CapturedExtension) => registerExtension(ext)
}
}))
// Static import — vi.mock hoisting ensures the mock is ready before the
// module's top-level app.registerExtension() call executes.
import './uploadImage'
function getExtension(): CapturedExtension {
const ext = registerExtension.mock.calls
.map(([e]) => e)
.find((e) => e.name === 'Comfy.UploadImage')
if (!ext) throw new Error('Comfy.UploadImage not registered')
return ext
}
function callBeforeRegister(nodeData: {
name: string
input: { required: Record<string, unknown> }
}) {
getExtension().beforeRegisterNodeDef!(
undefined as unknown as typeof LGraphNode,
nodeData as ComfyNodeDef,
undefined as never
)
}
function getUpload(
required: Record<string, unknown>
): [string, Record<string, unknown>] | undefined {
return required.upload as [string, Record<string, unknown>] | undefined
}
describe('Comfy.UploadImage extension', () => {
it('attaches an IMAGEUPLOAD widget when the image input declares image_upload', () => {
const nodeData = {
name: 'LoadImage',
input: {
required: {
image: ['COMBO', { image_upload: true }]
}
}
}
callBeforeRegister(nodeData)
const upload = getUpload(nodeData.input.required as Record<string, unknown>)
expect(upload).toBeDefined()
expect(upload![0]).toBe('IMAGEUPLOAD')
expect(upload![1].imageInputName).toBe('image')
})
it('attaches an IMAGEUPLOAD widget for LoadImage even when the backend omits image_upload', () => {
const nodeData = {
name: 'LoadImage',
input: {
required: {
image: ['COMBO', {}]
}
}
}
callBeforeRegister(nodeData)
const upload = getUpload(nodeData.input.required as Record<string, unknown>)
expect(upload).toBeDefined()
expect(upload![0]).toBe('IMAGEUPLOAD')
expect(upload![1].imageInputName).toBe('image')
expect(upload![1].image_upload).toBe(true)
})
it('attaches an IMAGEUPLOAD widget for LoadVideo with video_upload synthesized', () => {
const nodeData = {
name: 'LoadVideo',
input: {
required: {
file: ['COMBO', {}]
}
}
}
callBeforeRegister(nodeData)
const upload = getUpload(nodeData.input.required as Record<string, unknown>)
expect(upload).toBeDefined()
expect(upload![0]).toBe('IMAGEUPLOAD')
expect(upload![1].imageInputName).toBe('file')
expect(upload![1].video_upload).toBe(true)
})
it('does not attach an upload widget for unknown node types', () => {
const nodeData = {
name: 'UnknownNode',
input: {
required: {
image: ['COMBO', {}]
}
}
}
callBeforeRegister(nodeData)
expect(
getUpload(nodeData.input.required as Record<string, unknown>)
).toBeUndefined()
})
it('does not touch LoadAudio — audio is handled by Comfy.UploadAudio', () => {
const nodeData = {
name: 'LoadAudio',
input: {
required: {
audio: ['COMBO', {}]
}
}
}
callBeforeRegister(nodeData)
expect(
getUpload(nodeData.input.required as Record<string, unknown>)
).toBeUndefined()
})
it('never overwrites an upload widget another extension already attached', () => {
const existingUpload = ['AUDIOUPLOAD', {}]
const nodeData = {
name: 'LoadAudio',
input: {
required: {
audio: ['COMBO', {}],
upload: existingUpload
}
}
}
callBeforeRegister(nodeData)
expect((nodeData.input.required as Record<string, unknown>).upload).toBe(
existingUpload
)
})
})

View File

@@ -2,6 +2,7 @@ import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import {
type ComfyNodeDef,
type InputSpec,
isComboInputSpec,
isMediaUploadComboInput
} from '@/schemas/nodeDefSchema'
@@ -9,13 +10,31 @@ import { app } from '../../scripts/app'
// Adds an upload button to the nodes
// Cloud's backend may serve these loader nodes without the image_upload /
// video_upload flag on their media input. Without a fallback the IMAGEUPLOAD
// widget is never attached, so node.pasteFiles stays unset and the right-click
// "Paste Image" menu item never appears on cloud LoadImage nodes.
//
// LoadAudio is intentionally excluded — audio uses a separate AUDIOUPLOAD
// widget owned by Comfy.UploadAudio. Routing audio through IMAGEUPLOAD would
// reject every audio file the user pasted or dropped.
const FALLBACK_MEDIA_LOADER_INPUTS: Record<
string,
{ inputName: string; flag: 'image_upload' | 'video_upload' }
> = {
LoadImage: { inputName: 'image', flag: 'image_upload' },
LoadVideo: { inputName: 'file', flag: 'video_upload' }
}
const createUploadInput = (
imageInputName: string,
imageInputOptions: InputSpec
imageInputOptions: InputSpec,
extraOptions: Record<string, unknown> = {}
): InputSpec => [
'IMAGEUPLOAD',
{
...imageInputOptions[1],
...extraOptions,
imageInputName
}
]
@@ -26,6 +45,8 @@ app.registerExtension({
const { input } = nodeData ?? {}
const { required } = input ?? {}
if (!required) return
// Don't clobber a sibling uploader (e.g. Comfy.UploadAudio's AUDIOUPLOAD).
if (required.upload) return
const found = Object.entries(required).find(([_, input]) =>
isMediaUploadComboInput(input)
@@ -35,6 +56,17 @@ app.registerExtension({
if (found) {
const [inputName, inputSpec] = found
required.upload = createUploadInput(inputName, inputSpec)
return
}
const fallback = FALLBACK_MEDIA_LOADER_INPUTS[nodeData.name]
if (!fallback) return
const fallbackSpec = required[fallback.inputName]
if (!fallbackSpec || !isComboInputSpec(fallbackSpec)) return
// Synthesize the missing media-type flag so useImageUploadWidget picks the
// right accept filter (image/* vs video/*) for the loader's media kind.
required.upload = createUploadInput(fallback.inputName, fallbackSpec, {
[fallback.flag]: true
})
}
})