mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-01-26 19:09:52 +00:00
feat(ComboWidget): add ability to have mapped inputs (#6585)
## Summary 1. Add a `getOptionLabel` option to `ComboWidget` so users of it can map of custom labels to widget values. (e.g., `"My Photo" -> "my_photo_1235.png"`). 2. Utilize this ability in Cloud environment to map user uploaded filenames to their corresponding input asset. 3. Copious unit tests to make sure I didn't (AFAIK) break anything during the refactoring portion of development. 4. Bonus: Scope model browser to only show in cloud distributions until it's released elsewhere; should prevent some undesired UI behavior if a user accidentally enables the assetAPI. ## Review Focus Widget code: please double check the work there. ## Screenshots (if applicable) https://github.com/user-attachments/assets/a94b3203-c87f-4285-b692-479996859a5a ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-6585-Feat-input-mapping-2a26d73d365081faa667e49892c8d45a) by [Unito](https://www.unito.io)
This commit is contained in:
@@ -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']
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
} 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),
|
||||
|
||||
@@ -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 } = {}
|
||||
): 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}`
|
||||
|
||||
@@ -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'
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
const addMultiSelectWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec
|
||||
@@ -55,87 +72,168 @@ const addMultiSelectWidget = (
|
||||
return widget
|
||||
}
|
||||
|
||||
const createAssetBrowserWidget = (
|
||||
node: LGraphNode,
|
||||
inputSpec: ComboInputSpec,
|
||||
defaultValue: string | undefined
|
||||
): IBaseWidget => {
|
||||
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 (!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
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
||||
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 settingStore = useSettingStore()
|
||||
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
const isEligible = assetService.isAssetBrowserEligible(
|
||||
node.comfyClass,
|
||||
inputSpec.name
|
||||
)
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
|
||||
if (isUsingAssetAPI && isEligible) {
|
||||
const currentValue = getDefaultValue(inputSpec)
|
||||
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 (!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 (isCloud) {
|
||||
const settingStore = useSettingStore()
|
||||
const isUsingAssetAPI = settingStore.get('Comfy.Assets.UseAssetAPI')
|
||||
const isEligible = assetService.isAssetBrowserEligible(
|
||||
node.comfyClass,
|
||||
inputSpec.name
|
||||
)
|
||||
|
||||
return widget
|
||||
if (isUsingAssetAPI && isEligible) {
|
||||
return createAssetBrowserWidget(node, inputSpec, defaultValue)
|
||||
}
|
||||
|
||||
if (NODE_MEDIA_TYPE_MAP[node.comfyClass ?? '']) {
|
||||
return createInputMappingWidget(node, inputSpec, defaultValue)
|
||||
}
|
||||
}
|
||||
|
||||
// Create normal combo widget
|
||||
const defaultValue = getDefaultValue(inputSpec)
|
||||
const comboOptions = inputSpec.options ?? []
|
||||
// Standard combo widget
|
||||
const widget = node.addWidget(
|
||||
'combo',
|
||||
inputSpec.name,
|
||||
defaultValue,
|
||||
() => {},
|
||||
{
|
||||
values: comboOptions
|
||||
values: inputSpec.options ?? []
|
||||
}
|
||||
)
|
||||
|
||||
@@ -143,6 +241,7 @@ const addComboWidget = (
|
||||
if (!isComboWidget(widget)) {
|
||||
throw new Error(`Expected combo widget but received ${widget.type}`)
|
||||
}
|
||||
|
||||
const remoteWidget = useRemoteWidget({
|
||||
remoteConfig: inputSpec.remote,
|
||||
defaultValue,
|
||||
@@ -166,6 +265,7 @@ const addComboWidget = (
|
||||
if (!isComboWidget(widget)) {
|
||||
throw new Error(`Expected combo widget but received ${widget.type}`)
|
||||
}
|
||||
|
||||
widget.linkedWidgets = addValueControlWidgets(
|
||||
node,
|
||||
widget,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useAsyncState } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import {
|
||||
mapInputFileToAssetItem,
|
||||
@@ -12,6 +13,8 @@ import { api } from '@/scripts/api'
|
||||
|
||||
import { TaskItemImpl } from './queueStore'
|
||||
|
||||
const INPUT_LIMIT = 100
|
||||
|
||||
/**
|
||||
* Fetch input files from the internal API (OSS version)
|
||||
*/
|
||||
@@ -36,7 +39,9 @@ async function fetchInputFilesFromAPI(): Promise<AssetItem[]> {
|
||||
* Fetch input files from cloud service
|
||||
*/
|
||||
async function fetchInputFilesFromCloud(): Promise<AssetItem[]> {
|
||||
return await assetService.getAssetsByTag('input', false)
|
||||
return await assetService.getAssetsByTag('input', false, {
|
||||
limit: INPUT_LIMIT
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -117,6 +122,30 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* Map of asset hash filename to asset item for O(1) lookup
|
||||
* Cloud assets use asset_hash for the hash-based filename
|
||||
*/
|
||||
const inputAssetsByFilename = computed(() => {
|
||||
const map = new Map<string, AssetItem>()
|
||||
for (const asset of inputAssets.value) {
|
||||
// Use asset_hash as the key (hash-based filename)
|
||||
if (asset.asset_hash) {
|
||||
map.set(asset.asset_hash, asset)
|
||||
}
|
||||
}
|
||||
return map
|
||||
})
|
||||
|
||||
/**
|
||||
* Get human-readable name for input asset filename
|
||||
* @param filename Hash-based filename (e.g., "72e786ff...efb7.png")
|
||||
* @returns Human-readable asset name or original filename if not found
|
||||
*/
|
||||
function getInputName(filename: string): string {
|
||||
return inputAssetsByFilename.value.get(filename)?.name ?? filename
|
||||
}
|
||||
|
||||
return {
|
||||
// States
|
||||
inputAssets,
|
||||
@@ -128,6 +157,10 @@ export const useAssetsStore = defineStore('assets', () => {
|
||||
|
||||
// Actions
|
||||
updateInputs,
|
||||
updateHistory
|
||||
updateHistory,
|
||||
|
||||
// Input mapping helpers
|
||||
inputAssetsByFilename,
|
||||
getInputName
|
||||
}
|
||||
})
|
||||
|
||||
1088
tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts
Normal file
1088
tests-ui/tests/lib/litegraph/src/widgets/ComboWidget.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -4,13 +4,61 @@ import { LGraphNode } from '@/lib/litegraph/src/litegraph'
|
||||
import type { IBaseWidget } from '@/lib/litegraph/src/types/widgets'
|
||||
import { useAssetBrowserDialog } from '@/platform/assets/composables/useAssetBrowserDialog'
|
||||
import { assetService } from '@/platform/assets/services/assetService'
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useComboWidget } from '@/renderer/extensions/vueNodes/widgets/composables/useComboWidget'
|
||||
import type { InputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
|
||||
|
||||
// Mock factory using actual type
|
||||
function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-asset-id',
|
||||
name: 'test-image.png',
|
||||
asset_hash: 'hash123',
|
||||
size: 1024,
|
||||
mime_type: 'image/png',
|
||||
tags: ['input'],
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
last_access_time: new Date().toISOString(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
// Use vi.hoisted() to ensure mock state is initialized before mocks
|
||||
const mockDistributionState = vi.hoisted(() => ({ isCloud: false }))
|
||||
const mockUpdateInputs = vi.hoisted(() => vi.fn(() => Promise.resolve()))
|
||||
const mockGetInputName = vi.hoisted(() => vi.fn((hash: string) => hash))
|
||||
const mockAssetsStoreState = vi.hoisted(() => {
|
||||
const inputAssets: AssetItem[] = []
|
||||
return {
|
||||
inputAssets,
|
||||
inputLoading: false
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/scripts/widgets', () => ({
|
||||
addValueControlWidgets: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/distribution/types', () => ({
|
||||
get isCloud() {
|
||||
return mockDistributionState.isCloud
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/assetsStore', () => ({
|
||||
useAssetsStore: vi.fn(() => ({
|
||||
get inputAssets() {
|
||||
return mockAssetsStoreState.inputAssets
|
||||
},
|
||||
get inputLoading() {
|
||||
return mockAssetsStoreState.inputLoading
|
||||
},
|
||||
updateInputs: mockUpdateInputs,
|
||||
getInputName: mockGetInputName
|
||||
}))
|
||||
}))
|
||||
|
||||
const mockSettingStoreGet = vi.fn(() => false)
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: vi.fn(() => ({
|
||||
@@ -42,7 +90,7 @@ vi.mock('@/platform/assets/composables/useAssetBrowserDialog', () => {
|
||||
// Test factory functions
|
||||
function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
const mockCallback = vi.fn()
|
||||
return {
|
||||
const widget: IBaseWidget = {
|
||||
type: 'combo',
|
||||
options: {},
|
||||
name: 'testWidget',
|
||||
@@ -50,7 +98,8 @@ function createMockWidget(overrides: Partial<IBaseWidget> = {}): IBaseWidget {
|
||||
callback: mockCallback,
|
||||
y: 0,
|
||||
...overrides
|
||||
} as IBaseWidget
|
||||
}
|
||||
return widget
|
||||
}
|
||||
|
||||
function createMockNode(comfyClass = 'TestNode'): LGraphNode {
|
||||
@@ -73,11 +122,12 @@ function createMockNode(comfyClass = 'TestNode'): LGraphNode {
|
||||
}
|
||||
|
||||
function createMockInputSpec(overrides: Partial<InputSpec> = {}): InputSpec {
|
||||
return {
|
||||
const inputSpec: InputSpec = {
|
||||
type: 'COMBO',
|
||||
name: 'testInput',
|
||||
...overrides
|
||||
} as InputSpec
|
||||
}
|
||||
return inputSpec
|
||||
}
|
||||
|
||||
describe('useComboWidget', () => {
|
||||
@@ -86,6 +136,10 @@ describe('useComboWidget', () => {
|
||||
mockSettingStoreGet.mockReturnValue(false)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(false)
|
||||
vi.mocked(useAssetBrowserDialog).mockClear()
|
||||
mockDistributionState.isCloud = false
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
mockUpdateInputs.mockClear()
|
||||
})
|
||||
|
||||
it('should handle undefined spec', () => {
|
||||
@@ -110,6 +164,7 @@ describe('useComboWidget', () => {
|
||||
})
|
||||
|
||||
it('should create normal combo widget when asset API is disabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(false) // Asset API disabled
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true) // Widget is eligible
|
||||
|
||||
@@ -137,6 +192,7 @@ describe('useComboWidget', () => {
|
||||
})
|
||||
|
||||
it('should create asset browser widget when API enabled', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
@@ -169,36 +225,8 @@ describe('useComboWidget', () => {
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create asset browser widget with options when API enabled', () => {
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'asset',
|
||||
name: 'ckpt_name',
|
||||
value: 'model1.safetensors'
|
||||
})
|
||||
const mockNode = createMockNode('CheckpointLoaderSimple')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'ckpt_name',
|
||||
options: ['model1.safetensors', 'model2.safetensors']
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'asset',
|
||||
'ckpt_name',
|
||||
'model1.safetensors',
|
||||
expect.any(Function)
|
||||
)
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should use asset browser widget even when inputSpec has a default value but no options', () => {
|
||||
it('should create asset browser widget when default value provided without options', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
@@ -229,6 +257,7 @@ describe('useComboWidget', () => {
|
||||
})
|
||||
|
||||
it('should show Select model when asset widget has undefined current value', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockSettingStoreGet.mockReturnValue(true)
|
||||
vi.mocked(assetService.isAssetBrowserEligible).mockReturnValue(true)
|
||||
|
||||
@@ -256,4 +285,203 @@ describe('useComboWidget', () => {
|
||||
expect(mockSettingStoreGet).toHaveBeenCalledWith('Comfy.Assets.UseAssetAPI')
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
describe('cloud input asset mapping', () => {
|
||||
const HASH_FILENAME =
|
||||
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
|
||||
const HASH_FILENAME_2 =
|
||||
'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
|
||||
|
||||
it.each([
|
||||
{ nodeClass: 'LoadImage', inputName: 'image' },
|
||||
{ nodeClass: 'LoadVideo', inputName: 'video' },
|
||||
{ nodeClass: 'LoadAudio', inputName: 'audio' }
|
||||
])(
|
||||
'should create combo widget with getOptionLabel for $nodeClass in cloud',
|
||||
({ nodeClass, inputName }) => {
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'combo',
|
||||
name: inputName,
|
||||
value: HASH_FILENAME
|
||||
})
|
||||
const mockNode = createMockNode(nodeClass)
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: inputName,
|
||||
options: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
inputName,
|
||||
HASH_FILENAME,
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
values: [], // Empty initially, populated dynamically by Proxy
|
||||
getOptionLabel: expect.any(Function)
|
||||
})
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
}
|
||||
)
|
||||
|
||||
it("should format option labels using store's getInputName function", () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockGetInputName.mockReturnValue('Beautiful Sunset.png')
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({
|
||||
type: 'combo',
|
||||
name: 'image',
|
||||
value: HASH_FILENAME
|
||||
})
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
|
||||
// Extract the injected getOptionLabel function with type narrowing
|
||||
const addWidgetCall = vi.mocked(mockNode.addWidget).mock.calls[0]
|
||||
const options = addWidgetCall[4]
|
||||
|
||||
if (typeof options !== 'object' || !options) {
|
||||
throw new Error('Expected options to be an object')
|
||||
}
|
||||
|
||||
if (!('getOptionLabel' in options)) {
|
||||
throw new Error('Expected options to have getOptionLabel property')
|
||||
}
|
||||
|
||||
if (typeof options.getOptionLabel !== 'function') {
|
||||
throw new Error('Expected getOptionLabel to be a function')
|
||||
}
|
||||
|
||||
// Test that the injected function calls getInputName
|
||||
const result = options.getOptionLabel(HASH_FILENAME)
|
||||
expect(mockGetInputName).toHaveBeenCalledWith(HASH_FILENAME)
|
||||
expect(result).toBe('Beautiful Sunset.png')
|
||||
})
|
||||
|
||||
it('should create normal combo widget for non-input nodes in cloud', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode('SomeOtherNode')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'option',
|
||||
options: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'option',
|
||||
HASH_FILENAME,
|
||||
expect.any(Function),
|
||||
{ values: [HASH_FILENAME, HASH_FILENAME_2] }
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should create normal combo widget for LoadImage in OSS', () => {
|
||||
mockDistributionState.isCloud = false
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget()
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
})
|
||||
|
||||
const widget = constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockNode.addWidget).toHaveBeenCalledWith(
|
||||
'combo',
|
||||
'image',
|
||||
HASH_FILENAME,
|
||||
expect.any(Function),
|
||||
{
|
||||
values: [HASH_FILENAME, HASH_FILENAME_2]
|
||||
}
|
||||
)
|
||||
expect(widget).toBe(mockWidget)
|
||||
})
|
||||
|
||||
it('should trigger lazy load for cloud input nodes', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({ type: 'combo' })
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockUpdateInputs).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should not trigger lazy load if assets already loading', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockAssetsStoreState.inputAssets = []
|
||||
mockAssetsStoreState.inputLoading = true
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({ type: 'combo' })
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockUpdateInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not trigger lazy load if assets already loaded', () => {
|
||||
mockDistributionState.isCloud = true
|
||||
mockAssetsStoreState.inputAssets = [
|
||||
createMockAssetItem({
|
||||
id: 'asset-123',
|
||||
name: 'image1.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
})
|
||||
]
|
||||
mockAssetsStoreState.inputLoading = false
|
||||
|
||||
const constructor = useComboWidget()
|
||||
const mockWidget = createMockWidget({ type: 'combo' })
|
||||
const mockNode = createMockNode('LoadImage')
|
||||
vi.mocked(mockNode.addWidget).mockReturnValue(mockWidget)
|
||||
const inputSpec = createMockInputSpec({
|
||||
name: 'image',
|
||||
options: [HASH_FILENAME]
|
||||
})
|
||||
|
||||
constructor(mockNode, inputSpec)
|
||||
|
||||
expect(mockUpdateInputs).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -376,5 +376,48 @@ describe('assetService', () => {
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
it('should accept custom limit via options', async () => {
|
||||
const testAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('input', false, {
|
||||
limit: 100
|
||||
})
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=input&limit=100&include_public=false'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
it('should accept custom offset via options', async () => {
|
||||
const testAssets = [MOCK_ASSETS.loras]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('models', true, {
|
||||
offset: 50
|
||||
})
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=models&limit=500&include_public=true&offset=50'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
|
||||
it('should accept both limit and offset via options', async () => {
|
||||
const testAssets = [MOCK_ASSETS.checkpoints]
|
||||
mockApiResponse(testAssets)
|
||||
|
||||
const result = await assetService.getAssetsByTag('input', false, {
|
||||
limit: 100,
|
||||
offset: 25
|
||||
})
|
||||
|
||||
expect(api.fetchApi).toHaveBeenCalledWith(
|
||||
'/assets?include_tags=input&limit=100&include_public=false&offset=25'
|
||||
)
|
||||
expect(result).toEqual(testAssets)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
157
tests-ui/tests/store/assetsStore.test.ts
Normal file
157
tests-ui/tests/store/assetsStore.test.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { AssetItem } from '@/platform/assets/schemas/assetSchema'
|
||||
import { useAssetsStore } from '@/stores/assetsStore'
|
||||
|
||||
const HASH_FILENAME =
|
||||
'72e786ff2a44d682c4294db0b7098e569832bc394efc6dad644e6ec85a78efb7.png'
|
||||
const HASH_FILENAME_2 =
|
||||
'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456.jpg'
|
||||
|
||||
function createMockAssetItem(overrides: Partial<AssetItem> = {}): AssetItem {
|
||||
return {
|
||||
id: 'test-id',
|
||||
name: 'test.png',
|
||||
asset_hash: 'test-hash',
|
||||
size: 1024,
|
||||
tags: [],
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('assetsStore', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
})
|
||||
|
||||
describe('input asset mapping helpers', () => {
|
||||
it('should return name for valid asset_hash', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: 'Mountain Vista.jpg',
|
||||
asset_hash: HASH_FILENAME_2
|
||||
})
|
||||
]
|
||||
|
||||
expect(store.getInputName(HASH_FILENAME)).toBe('Beautiful Sunset.png')
|
||||
expect(store.getInputName(HASH_FILENAME_2)).toBe('Mountain Vista.jpg')
|
||||
})
|
||||
|
||||
it('should return original hash when no matching asset found', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
})
|
||||
]
|
||||
|
||||
const unknownHash =
|
||||
'fffffffffffffffffffffffffffuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu.png'
|
||||
expect(store.getInputName(unknownHash)).toBe(unknownHash)
|
||||
})
|
||||
|
||||
it('should return hash as-is when no assets loaded', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
store.inputAssets = []
|
||||
|
||||
expect(store.getInputName(HASH_FILENAME)).toBe(HASH_FILENAME)
|
||||
})
|
||||
|
||||
it('should ignore assets without asset_hash', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: 'No Hash Asset.jpg',
|
||||
asset_hash: null
|
||||
})
|
||||
]
|
||||
|
||||
// Should find first asset
|
||||
expect(store.getInputName(HASH_FILENAME)).toBe('Beautiful Sunset.png')
|
||||
// Map should only contain one entry
|
||||
expect(store.inputAssetsByFilename.size).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('inputAssetsByFilename computed', () => {
|
||||
it('should create map keyed by asset_hash', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
id: 'asset-123',
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
}),
|
||||
createMockAssetItem({
|
||||
id: 'asset-456',
|
||||
name: 'Mountain Vista.jpg',
|
||||
asset_hash: HASH_FILENAME_2
|
||||
})
|
||||
]
|
||||
|
||||
const map = store.inputAssetsByFilename
|
||||
|
||||
expect(map.size).toBe(2)
|
||||
expect(map.get(HASH_FILENAME)).toMatchObject({
|
||||
id: 'asset-123',
|
||||
name: 'Beautiful Sunset.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
})
|
||||
expect(map.get(HASH_FILENAME_2)).toMatchObject({
|
||||
id: 'asset-456',
|
||||
name: 'Mountain Vista.jpg',
|
||||
asset_hash: HASH_FILENAME_2
|
||||
})
|
||||
})
|
||||
|
||||
it('should exclude assets with null/undefined hash from map', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
store.inputAssets = [
|
||||
createMockAssetItem({
|
||||
name: 'Has Hash.png',
|
||||
asset_hash: HASH_FILENAME
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: 'Null Hash.jpg',
|
||||
asset_hash: null
|
||||
}),
|
||||
createMockAssetItem({
|
||||
name: 'Undefined Hash.jpg',
|
||||
asset_hash: undefined
|
||||
})
|
||||
]
|
||||
|
||||
const map = store.inputAssetsByFilename
|
||||
|
||||
// Only asset with valid asset_hash should be in map
|
||||
expect(map.size).toBe(1)
|
||||
expect(map.has(HASH_FILENAME)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return empty map when no assets loaded', () => {
|
||||
const store = useAssetsStore()
|
||||
|
||||
store.inputAssets = []
|
||||
|
||||
expect(store.inputAssetsByFilename.size).toBe(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user