Use v2 input spec for combo widget (#2878)

This commit is contained in:
Chenlei Hu
2025-03-05 13:12:51 -05:00
committed by GitHub
parent 8a479979b1
commit 35e6cabfe7
7 changed files with 75 additions and 122 deletions

View File

@@ -1,70 +1,76 @@
import type { LGraphNode } from '@comfyorg/litegraph' import type { LGraphNode } from '@comfyorg/litegraph'
import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets' import type { IComboWidget } from '@comfyorg/litegraph/dist/types/widgets'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { import {
ComboInputSpec,
type InputSpec, type InputSpec,
getComboSpecComboOptions,
isComboInputSpec isComboInputSpec
} from '@/schemas/nodeDefSchema' } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { addValueControlWidgets } from '@/scripts/widgets' import {
import type { ComfyWidgetConstructor } from '@/scripts/widgets' type ComfyWidgetConstructorV2,
import { useWidgetStore } from '@/stores/widgetStore' addValueControlWidgets
} from '@/scripts/widgets'
import { useRemoteWidget } from './useRemoteWidget' import { useRemoteWidget } from './useRemoteWidget'
const getDefaultValue = (inputSpec: ComboInputSpec) => {
if (inputSpec.default) return inputSpec.default
if (inputSpec.options?.length) return inputSpec.options[0]
if (inputSpec.remote) return 'Loading...'
return undefined
}
export const useComboWidget = () => { export const useComboWidget = () => {
const widgetConstructor: ComfyWidgetConstructor = ( const widgetConstructor: ComfyWidgetConstructorV2 = (
node: LGraphNode, node: LGraphNode,
inputName: string, inputSpec: InputSpec
inputData: InputSpec
) => { ) => {
if (!isComboInputSpec(inputData)) { if (!isComboInputSpec(inputSpec)) {
throw new Error(`Invalid input data: ${inputData}`) throw new Error(`Invalid input data: ${inputSpec}`)
} }
const widgetStore = useWidgetStore() const comboOptions = inputSpec.options ?? []
const inputOptions = inputData[1] ?? {} const defaultValue = getDefaultValue(inputSpec)
const comboOptions = getComboSpecComboOptions(inputData)
const defaultValue = widgetStore.getDefaultValue(inputData) const widget = node.addWidget(
'combo',
const res = { inputSpec.name,
widget: node.addWidget('combo', inputName, defaultValue, () => {}, { defaultValue,
() => {},
{
values: comboOptions values: comboOptions
}) as IComboWidget }
} ) as IComboWidget
if (inputOptions.remote) { if (inputSpec.remote) {
const remoteWidget = useRemoteWidget({ const remoteWidget = useRemoteWidget({
inputData, remoteConfig: inputSpec.remote,
defaultValue, defaultValue,
node, node,
widget: res.widget widget
}) })
if (inputOptions.remote.refresh_button) remoteWidget.addRefreshButton() if (inputSpec.remote.refresh_button) remoteWidget.addRefreshButton()
const origOptions = res.widget.options const origOptions = widget.options
res.widget.options = new Proxy( widget.options = new Proxy(origOptions as Record<string | symbol, any>, {
origOptions as Record<string | symbol, any>, get(target, prop: string | symbol) {
{ if (prop !== 'values') return target[prop]
get(target, prop: string | symbol) { return remoteWidget.getValue()
if (prop !== 'values') return target[prop]
return remoteWidget.getValue()
}
} }
) })
} }
if (inputOptions.control_after_generate) { if (inputSpec.control_after_generate) {
res.widget.linkedWidgets = addValueControlWidgets( widget.linkedWidgets = addValueControlWidgets(
node, node,
res.widget, widget,
undefined, undefined,
undefined, undefined,
inputData transformInputSpecV2ToV1(inputSpec)
) )
} }
return res return widget
} }
return widgetConstructor return widgetConstructor

View File

@@ -2,7 +2,7 @@ import { LGraphNode } from '@comfyorg/litegraph'
import { IWidget } from '@comfyorg/litegraph' import { IWidget } from '@comfyorg/litegraph'
import axios from 'axios' import axios from 'axios'
import type { InputSpec, RemoteWidgetConfig } from '@/schemas/nodeDefSchema' import type { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
const MAX_RETRIES = 5 const MAX_RETRIES = 5
const TIMEOUT = 4096 const TIMEOUT = 4096
@@ -67,16 +67,15 @@ const fetchData = async (
export function useRemoteWidget< export function useRemoteWidget<
T extends string | number | boolean | object T extends string | number | boolean | object
>(options: { >(options: {
inputData: InputSpec remoteConfig: RemoteWidgetConfig
defaultValue: T defaultValue: T
node: LGraphNode node: LGraphNode
widget: IWidget widget: IWidget
}) { }) {
const { inputData, defaultValue, node, widget } = options const { remoteConfig, defaultValue, node, widget } = options
const config = (inputData[1]?.remote ?? {}) as RemoteWidgetConfig const { refresh = 0, max_retries = MAX_RETRIES } = remoteConfig
const { refresh = 0, max_retries = MAX_RETRIES } = config
const isPermanent = refresh <= 0 const isPermanent = refresh <= 0
const cacheKey = createCacheKey(config) const cacheKey = createCacheKey(remoteConfig)
let isLoaded = false let isLoaded = false
let refreshQueued = false let refreshQueued = false
@@ -131,7 +130,10 @@ export function useRemoteWidget<
try { try {
currentEntry.controller = new AbortController() currentEntry.controller = new AbortController()
currentEntry.fetchPromise = fetchData(config, currentEntry.controller) currentEntry.fetchPromise = fetchData(
remoteConfig,
currentEntry.controller
)
const data = await currentEntry.fetchPromise const data = await currentEntry.fetchPromise
setSuccess(currentEntry, data) setSuccess(currentEntry, data)
@@ -146,11 +148,11 @@ export function useRemoteWidget<
} }
const onRefresh = () => { const onRefresh = () => {
if (config.control_after_refresh) { if (remoteConfig.control_after_refresh) {
const data = getCachedValue() const data = getCachedValue()
if (!Array.isArray(data)) return // control_after_refresh is only supported for array values if (!Array.isArray(data)) return // control_after_refresh is only supported for array values
switch (config.control_after_refresh) { switch (remoteConfig.control_after_refresh) {
case 'first': case 'first':
widget.value = data[0] ?? defaultValue widget.value = data[0] ?? defaultValue
break break

View File

@@ -71,8 +71,8 @@ export const zComboInputOptions = zBaseInputOptions.extend({
image_folder: z.enum(['input', 'output', 'temp']).optional(), image_folder: z.enum(['input', 'output', 'temp']).optional(),
allow_batch: z.boolean().optional(), allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(), video_upload: z.boolean().optional(),
remote: zRemoteWidgetConfig.optional(), options: z.array(zComboOption).optional(),
options: z.array(zComboOption).optional() remote: zRemoteWidgetConfig.optional()
}) })
const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()]) const zIntInputSpec = z.tuple([z.literal('INT'), zIntInputOptions.optional()])

View File

@@ -293,6 +293,6 @@ export const ComfyWidgets: Record<string, ComfyWidgetConstructor> = {
BOOLEAN: transformWidgetConstructorV2ToV1(useBooleanWidget()), BOOLEAN: transformWidgetConstructorV2ToV1(useBooleanWidget()),
STRING: transformWidgetConstructorV2ToV1(useStringWidget()), STRING: transformWidgetConstructorV2ToV1(useStringWidget()),
MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()), MARKDOWN: transformWidgetConstructorV2ToV1(useMarkdownWidget()),
COMBO: useComboWidget(), COMBO: transformWidgetConstructorV2ToV1(useComboWidget()),
IMAGEUPLOAD: useImageUploadWidget() IMAGEUPLOAD: useImageUploadWidget()
} }

View File

@@ -2,11 +2,6 @@ import { defineStore } from 'pinia'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2' import type { InputSpec as InputSpecV2 } from '@/schemas/nodeDef/nodeDefSchemaV2'
import {
type ComboInputSpecV2,
type InputSpec,
isComboInputSpecV1
} from '@/schemas/nodeDefSchema'
import { ComfyWidgetConstructor, ComfyWidgets } from '@/scripts/widgets' import { ComfyWidgetConstructor, ComfyWidgets } from '@/scripts/widgets'
export const useWidgetStore = defineStore('widget', () => { export const useWidgetStore = defineStore('widget', () => {
@@ -45,42 +40,10 @@ export const useWidgetStore = defineStore('widget', () => {
} }
} }
function getDefaultValue(inputData: InputSpec) {
if (Array.isArray(inputData[0]))
return getDefaultValue(transformComboInput(inputData))
// @ts-expect-error InputSpec is not typed correctly
const widgetType = getWidgetType(inputData[0], inputData[1]?.name)
const [_, props] = inputData
if (!props) return undefined
if (props.default) return props.default
// @ts-expect-error InputSpec is not typed correctly
if (widgetType === 'COMBO' && props.options?.length) return props.options[0]
if (props.remote) return 'Loading...'
return undefined
}
const transformComboInput = (inputData: InputSpec): ComboInputSpecV2 => {
// @ts-expect-error InputSpec is not typed correctly
return isComboInputSpecV1(inputData)
? [
'COMBO',
{
options: inputData[0],
...Object(inputData[1] || {})
}
]
: inputData
}
return { return {
widgets, widgets,
getWidgetType, getWidgetType,
inputIsWidget, inputIsWidget,
registerCustomWidgets, registerCustomWidgets
getDefaultValue
} }
}) })

View File

@@ -1,14 +1,7 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useComboWidget } from '@/composables/widgets/useComboWidget' import { useComboWidget } from '@/composables/widgets/useComboWidget'
import type { InputSpec } from '@/schemas/nodeDefSchema' import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
vi.mock('@/stores/widgetStore', () => ({
useWidgetStore: () => ({
getDefaultValue: vi.fn()
})
}))
vi.mock('@/scripts/widgets', () => ({ vi.mock('@/scripts/widgets', () => ({
addValueControlWidgets: vi.fn() addValueControlWidgets: vi.fn()
@@ -16,7 +9,6 @@ vi.mock('@/scripts/widgets', () => ({
describe('useComboWidget', () => { describe('useComboWidget', () => {
beforeEach(() => { beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks() vi.clearAllMocks()
}) })
@@ -26,14 +18,12 @@ describe('useComboWidget', () => {
addWidget: vi.fn().mockReturnValue({ options: {} } as any) addWidget: vi.fn().mockReturnValue({ options: {} } as any)
} }
const inputSpec: InputSpec = ['COMBO', undefined] const inputSpec: InputSpec = {
type: 'COMBO',
name: 'inputName'
}
const widget = constructor( const widget = constructor(mockNode as any, inputSpec)
mockNode as any,
'inputName',
inputSpec,
undefined as any
)
expect(mockNode.addWidget).toHaveBeenCalledWith( expect(mockNode.addWidget).toHaveBeenCalledWith(
'combo', 'combo',
@@ -44,8 +34,6 @@ describe('useComboWidget', () => {
values: [] values: []
}) })
) )
expect(widget).toEqual({ expect(widget).toEqual({ options: {} })
widget: { options: {} }
})
}) })
}) })

View File

@@ -2,7 +2,7 @@ import axios from 'axios'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useRemoteWidget } from '@/composables/widgets/useRemoteWidget' import { useRemoteWidget } from '@/composables/widgets/useRemoteWidget'
import type { ComboInputSpecV2 } from '@/schemas/nodeDefSchema' import { RemoteWidgetConfig } from '@/schemas/nodeDefSchema'
vi.mock('axios', () => { vi.mock('axios', () => {
return { return {
@@ -29,22 +29,16 @@ vi.mock('@/stores/settingStore', () => ({
const FIRST_BACKOFF = 1000 // backoff is 1s on first retry const FIRST_BACKOFF = 1000 // backoff is 1s on first retry
const DEFAULT_VALUE = 'Loading...' const DEFAULT_VALUE = 'Loading...'
function createMockInputData(overrides = {}): ComboInputSpecV2 { function createMockConfig(overrides = {}): RemoteWidgetConfig {
return [ return {
'COMBO', route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
{ refresh: 0,
name: 'test_widget', ...overrides
remote: { }
route: `/api/test/${Date.now()}${Math.random().toString(36).substring(2, 15)}`,
refresh: 0,
...overrides
}
}
]
} }
const createMockOptions = (inputOverrides = {}) => ({ const createMockOptions = (inputOverrides = {}) => ({
inputData: createMockInputData(inputOverrides), remoteConfig: createMockConfig(inputOverrides),
defaultValue: DEFAULT_VALUE, defaultValue: DEFAULT_VALUE,
node: {} as any, node: {} as any,
widget: {} as any widget: {} as any
@@ -81,7 +75,7 @@ async function getResolvedValue(hook: ReturnType<typeof useRemoteWidget>) {
} }
describe('useRemoteWidget', () => { describe('useRemoteWidget', () => {
let mockInputData: ComboInputSpecV2 let mockConfig: RemoteWidgetConfig
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks() vi.clearAllMocks()
@@ -92,7 +86,7 @@ describe('useRemoteWidget', () => {
vi.spyOn(Map.prototype, 'set').mockClear() vi.spyOn(Map.prototype, 'set').mockClear()
vi.spyOn(Map.prototype, 'delete').mockClear() vi.spyOn(Map.prototype, 'delete').mockClear()
mockInputData = createMockInputData() mockConfig = createMockConfig()
}) })
afterEach(() => { afterEach(() => {