Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/lib/litegraph/src/types/widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ export interface IWidgetOptions<TValues = unknown[]> {
canvasOnly?: boolean

values?: TValues
/** Optional function to format values for display (e.g., hash → human-readable name) */
getOptionLabel?: (value?: string | null) => string
callback?: IWidget['callback']
}

Expand Down
38 changes: 37 additions & 1 deletion src/lib/litegraph/src/widgets/ComboWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,18 @@ export class ComboWidget

override get _displayValue() {
if (this.computedDisabled) return ''

if (this.options.getOptionLabel) {
try {
return this.options.getOptionLabel(
this.value ? String(this.value) : null
)
} catch (e) {
console.error('Failed to map value:', e)
return this.value ? String(this.value) : ''
}
}

const { values: rawValues } = this.options
if (rawValues) {
const values = typeof rawValues === 'function' ? rawValues() : rawValues
Expand Down Expand Up @@ -131,7 +143,31 @@ export class ComboWidget
const values = this.getValues(node)
const values_list = toArray(values)

// Handle center click - show dropdown menu
// Use addItem to solve duplicate filename issues
if (this.options.getOptionLabel) {
const menuOptions = {
scale: Math.max(1, canvas.ds.scale),
event: e,
className: 'dark',
callback: (value: string) => {
this.setValue(value, { e, node, canvas })
}
}
const menu = new LiteGraph.ContextMenu([], menuOptions)

for (const value of values_list) {
try {
const label = this.options.getOptionLabel(String(value))
menu.addItem(label, value, menuOptions)
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: one of the downsides of this approach is we don't get the fuzzy search filter because that requires setting the array directly.

Talked it over with @AustinMroz and we do think this is the easiest way to map duplicate labels to the correct value

} catch (err) {
console.error('Failed to map value:', err)
menu.addItem(String(value), value, menuOptions)
}
}
return
}

// Show dropdown menu when user clicks on widget label
const text_values = values != values_list ? Object.values(values) : values
new LiteGraph.ContextMenu(text_values, {
scale: Math.max(1, canvas.ds.scale),
Expand Down
17 changes: 14 additions & 3 deletions src/platform/assets/services/assetService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,20 +194,31 @@ function createAssetService() {
/**
* Gets assets filtered by a specific tag
*
* @param tag - The tag to filter by (e.g., 'models')
* @param tag - The tag to filter by (e.g., 'models', 'input')
* @param includePublic - Whether to include public assets (default: true)
* @param options - Pagination options
* @param options.limit - Maximum number of assets to return (default: 500)
* @param options.offset - Number of assets to skip (default: 0)
* @returns Promise<AssetItem[]> - Full asset objects filtered by tag, excluding missing assets
*/
async function getAssetsByTag(
tag: string,
includePublic: boolean = true
includePublic: boolean = true,
{
limit = DEFAULT_LIMIT,
offset = 0
}: { limit?: number; offset?: number } = {}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're not using pagination yet but, at least we're setup to start doing it.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hrmmmm...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i know yagni... but i think sooner is sooner than we think 😂

if you really want it removed, i can remove it.

): Promise<AssetItem[]> {
const queryParams = new URLSearchParams({
include_tags: tag,
limit: DEFAULT_LIMIT.toString(),
limit: limit.toString(),
include_public: includePublic ? 'true' : 'false'
})

if (offset > 0) {
queryParams.set('offset', offset.toString())
}

const data = await handleAssetRequest(
`${ASSETS_ENDPOINT}?${queryParams.toString()}`,
`assets for tag ${tag}`
Expand Down
226 changes: 163 additions & 63 deletions src/renderer/extensions/vueNodes/widgets/composables/useComboWidget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
assetItemSchema
} from '@/platform/assets/schemas/assetSchema'
import { assetService } from '@/platform/assets/services/assetService'
import { isCloud } from '@/platform/distribution/types'
import { useSettingStore } from '@/platform/settings/settingStore'
import { transformInputSpecV2ToV1 } from '@/schemas/nodeDef/migration'
import { isComboInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
Expand All @@ -22,6 +23,8 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import type { BaseDOMWidget } from '@/scripts/domWidget'
import { addValueControlWidgets } from '@/scripts/widgets'
import type { ComfyWidgetConstructorV2 } from '@/scripts/widgets'
import { useAssetsStore } from '@/stores/assetsStore'
import { getMediaTypeFromFilename } from '@/utils/formatUtil'

import { useRemoteWidget } from './useRemoteWidget'

Expand All @@ -32,6 +35,20 @@ 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'
}

// 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'
}
Comment on lines +39 to +50
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Option:

Suggested change
const NODE_MEDIA_TYPE_MAP: Record<string, 'image' | 'video' | 'audio'> = {
LoadImage: 'image',
LoadVideo: 'video',
LoadAudio: 'audio'
}
// 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 NODE_MEDIA_TYPE_MAP = {
LoadImage: 'image',
LoadVideo: 'video',
LoadAudio: 'audio'
} as const
// Map node types to placeholder i18n keys
const NODE_PLACEHOLDER_MAP = {
LoadImage: 'widgets.uploadSelect.placeholderImage',
LoadVideo: 'widgets.uploadSelect.placeholderVideo',
LoadAudio: 'widgets.uploadSelect.placeholderAudio'
} as const

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will try this again but it cause some typecheck failure fun for me when i did it locally


const addMultiSelectWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
Expand All @@ -55,94 +72,176 @@ const addMultiSelectWidget = (
return widget
}

const addComboWidget = (
const createAssetBrowserWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
inputSpec: ComboInputSpec,
defaultValue: string | undefined
): IBaseWidget => {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
inputSpec.name
)
const currentValue = defaultValue
const displayLabel = currentValue ?? t('widgets.selectModel')
const assetBrowserDialog = useAssetBrowserDialog()

const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
async function (this: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)

if (isUsingAssetAPI && isEligible) {
const currentValue = getDefaultValue(inputSpec)
const displayLabel = currentValue ?? t('widgets.selectModel')
if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}

const assetBrowserDialog = useAssetBrowserDialog()
const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)

const widget = node.addWidget(
'asset',
inputSpec.name,
displayLabel,
async function (this: IBaseWidget) {
if (!isAssetWidget(widget)) {
throw new Error(`Expected asset widget but received ${widget.type}`)
}
await assetBrowserDialog.show({
nodeType: node.comfyClass || '',
inputName: inputSpec.name,
currentValue: widget.value,
onAssetSelected: (asset) => {
const validatedAsset = assetItemSchema.safeParse(asset)

if (!validatedAsset.success) {
console.error(
'Invalid asset item:',
validatedAsset.error.errors,
'Received:',
asset
)
return
}

const filename = validatedAsset.data.user_metadata?.filename
const validatedFilename = assetFilenameSchema.safeParse(filename)

if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}

const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
if (!validatedFilename.success) {
console.error(
'Invalid asset filename:',
validatedFilename.error.errors,
'for asset:',
validatedAsset.data.id
)
return
}
})

const oldValue = widget.value
this.value = validatedFilename.data
node.onWidgetChanged?.(
widget.name,
validatedFilename.data,
oldValue,
widget
)
}
})
}
)

return widget
}

const createInputMappingWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec,
defaultValue: string | undefined
): IBaseWidget => {
const assetsStore = useAssetsStore()

const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue ?? '',
() => {},
{
values: [],
getOptionLabel: (value?: string | null) => {
if (!value) {
const placeholderKey =
NODE_PLACEHOLDER_MAP[node.comfyClass ?? ''] ??
'widgets.uploadSelect.placeholder'
return t(placeholderKey)
}
return assetsStore.getInputName(value)
}
)
}
)

return widget
if (assetsStore.inputAssets.length === 0 && !assetsStore.inputLoading) {
void assetsStore.updateInputs().then(() => {
// edge for users using nodes with 0 prior inputs
// force canvas refresh the first time they add an asset
// so they see filenames instead of hashes.
node.setDirtyCanvas(true, false)
})
}

// Create normal combo widget
const origOptions = widget.options
widget.options = new Proxy(origOptions, {
get(target, prop) {
if (prop !== 'values') {
return target[prop as keyof typeof target]
}
return assetsStore.inputAssets
.filter(
(asset) =>
getMediaTypeFromFilename(asset.name) ===
NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']
)
.map((asset) => asset.asset_hash)
.filter((hash): hash is string => !!hash)
}
})

if (inputSpec.control_after_generate) {
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}
widget.linkedWidgets = addValueControlWidgets(
node,
widget,
undefined,
undefined,
transformInputSpecV2ToV1(inputSpec)
)
}

return widget
}

const addComboWidget = (
node: LGraphNode,
inputSpec: ComboInputSpec
): IBaseWidget => {
const defaultValue = getDefaultValue(inputSpec)
const comboOptions = inputSpec.options ?? []

if (isCloud) {
const settingStore = useSettingStore()
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
const isEligible = assetService.isAssetBrowserEligible(
node.comfyClass,
inputSpec.name
)

if (isUsingAssetAPI && isEligible) {
return createAssetBrowserWidget(node, inputSpec, defaultValue)
}

if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
return createInputMappingWidget(node, inputSpec, defaultValue)
}
}

// Standard combo widget
const widget = node.addWidget(
'combo',
inputSpec.name,
defaultValue,
() => {},
{
values: comboOptions
values: inputSpec.options ?? []
}
)

if (inputSpec.remote) {
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}

const remoteWidget = useRemoteWidget({
remoteConfig: inputSpec.remote,
defaultValue,
Expand All @@ -166,6 +265,7 @@ const addComboWidget = (
if (!isComboWidget(widget)) {
throw new Error(`Expected combo widget but received ${widget.type}`)
}

widget.linkedWidgets = addValueControlWidgets(
node,
widget,
Expand Down
Loading