Feat/3d dropdown (#8765)

## Summary
Add mesh_upload and upload_subfolder to combo input schema so
WidgetSelect detects mesh uploads generically instead of hardcoding node
type checks. Inject these flags in load3dLazy.ts so they are available
before THREE.js loads.

Also unify SUPPORTED_EXTENSIONS_ACCEPT across load3d and dropdown, pass
uploadSubfolder prop through to WidgetSelectDropdown for correct upload
path, and update error message to list all supported extensions.

replacement for https://github.com/Comfy-Org/ComfyUI_frontend/pull/7975

(We should include thumbnail but not yet, will do it later)

## Screenshots (if applicable)


https://github.com/user-attachments/assets/2cb4b1da-af4f-439b-9786-3ac780c2480d

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8765-Feat-3d-dropdown-3036d73d365081d8a10ee19d3ed7d295)
by [Unito](https://www.unito.io)

---------

Co-authored-by: Kelly Yang <124ykl@gmail.com>
This commit is contained in:
Terry Jia
2026-02-10 15:36:57 -05:00
committed by GitHub
parent 581452d312
commit 9ecbb3af27
11 changed files with 83 additions and 15 deletions

View File

@@ -219,12 +219,20 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
return modelPath
}
const [subfolder, filename] = Load3dUtils.splitFilePath(modelPath)
let cleanPath = modelPath.trim()
let forcedType: 'output' | 'input' | undefined
if (cleanPath.endsWith('[output]')) {
cleanPath = cleanPath.replace(/\s*\[output\]$/, '').trim()
forcedType = 'output'
}
const [subfolder, filename] = Load3dUtils.splitFilePath(cleanPath)
return api.apiURL(
Load3dUtils.getResourceURL(
subfolder,
filename,
isPreview.value ? 'output' : 'input'
forcedType ?? (isPreview.value ? 'output' : 'input')
)
)
} catch (error) {

View File

@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/constants'
import { useToastStore } from '@/platform/updates/common/toastStore'
import { createMockFileList } from '@/utils/__tests__/litegraphTestUtils'
@@ -199,7 +200,7 @@ describe('useLoad3dDrag', () => {
onModelDrop: mockOnModelDrop
})
const extensions = ['.gltf', '.glb', '.obj', '.fbx', '.stl']
const extensions = [...SUPPORTED_EXTENSIONS]
for (const ext of extensions) {
vi.mocked(mockOnModelDrop).mockClear()

View File

@@ -9,6 +9,7 @@ import type {
CameraState
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
@@ -258,10 +259,7 @@ useExtensionService().registerExtension({
getCustomWidgets() {
return {
LOAD_3D(node) {
const fileInput = createFileInput(
'.gltf,.glb,.obj,.fbx,.stl,.ply,.spz,.splat,.ksplat',
false
)
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
node.properties['Resource Folder'] = ''

View File

@@ -14,3 +14,5 @@ export const SUPPORTED_EXTENSIONS = new Set([
'.ply',
'.ksplat'
])
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')

View File

@@ -56,6 +56,16 @@ useExtensionService().registerExtension({
async beforeRegisterNodeDef(nodeType, nodeData) {
if (isLoad3dNodeType(nodeData.name)) {
// Inject mesh_upload spec flags so WidgetSelect.vue can detect
// Load3D's model_file as a mesh upload widget without hardcoding.
if (nodeData.name === 'Load3D') {
const modelFile = nodeData.input?.required?.model_file
if (modelFile?.[1]) {
modelFile[1].mesh_upload = true
modelFile[1].upload_subfolder = '3d'
}
}
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
// since invokeExtensionsAsync already captured the extensions snapshot
// before these new extensions were registered.

View File

@@ -1801,7 +1801,7 @@
},
"openIn3DViewer": "Open in 3D Viewer",
"dropToLoad": "Drop 3D model to load",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl)",
"unsupportedFileType": "Unsupported file type (supports .gltf, .glb, .obj, .fbx, .stl, .ply, .spz, .splat, .ksplat)",
"uploadingModel": "Uploading 3D model..."
},
"imageCrop": {

View File

@@ -334,6 +334,24 @@ describe('WidgetSelect Value Binding', () => {
expect(dropdown.props('allowUpload')).toBe(false)
})
it('uses dropdown variant for mesh uploads via spec', () => {
const spec: ComboInputSpec = {
type: 'COMBO',
name: 'model_file',
mesh_upload: true,
upload_subfolder: '3d'
}
const widget = createMockWidget('model.glb', {}, undefined, spec)
const wrapper = mountComponent(widget, 'model.glb')
const dropdown = wrapper.findComponent(WidgetSelectDropdown)
expect(dropdown.exists()).toBe(true)
expect(dropdown.props('assetKind')).toBe('mesh')
expect(dropdown.props('allowUpload')).toBe(true)
expect(dropdown.props('uploadFolder')).toBe('input')
expect(dropdown.props('uploadSubfolder')).toBe('3d')
})
it('keeps default select when no spec or media hints are present', () => {
const widget = createMockWidget('plain', {
values: ['plain', 'text']

View File

@@ -7,6 +7,7 @@
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
:upload-subfolder="uploadSubfolder"
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
/>
@@ -58,13 +59,15 @@ const specDescriptor = computed<{
kind: AssetKind
allowUpload: boolean
folder: ResultItemType | undefined
subfolder: string | undefined
}>(() => {
const spec = comboSpec.value
if (!spec) {
return {
kind: 'unknown',
allowUpload: false,
folder: undefined
folder: undefined,
subfolder: undefined
}
}
@@ -73,7 +76,9 @@ const specDescriptor = computed<{
animated_image_upload,
video_upload,
image_folder,
audio_upload
audio_upload,
mesh_upload,
upload_subfolder
} = spec
let kind: AssetKind = 'unknown'
@@ -83,18 +88,26 @@ const specDescriptor = computed<{
kind = 'image'
} else if (audio_upload) {
kind = 'audio'
} else if (mesh_upload) {
kind = 'mesh'
}
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
const allowUpload =
image_upload === true ||
animated_image_upload === true ||
video_upload === true ||
audio_upload === true
audio_upload === true ||
mesh_upload === true
const folder = mesh_upload ? 'input' : image_folder
return {
kind,
allowUpload,
folder: image_folder
folder,
subfolder: upload_subfolder
}
})
@@ -120,6 +133,7 @@ const allowUpload = computed(() => specDescriptor.value.allowUpload)
const uploadFolder = computed<ResultItemType>(() => {
return specDescriptor.value.folder ?? 'input'
})
const uploadSubfolder = computed(() => specDescriptor.value.subfolder)
const defaultLayoutMode = computed<LayoutMode>(() => {
return isAssetMode.value ? 'list' : 'grid'
})

View File

@@ -4,6 +4,7 @@ import { computed, provide, ref, toRef, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
import {
filterItemByBaseModels,
@@ -44,6 +45,7 @@ interface Props {
assetKind?: AssetKind
allowUpload?: boolean
uploadFolder?: ResultItemType
uploadSubfolder?: string
isAssetMode?: boolean
defaultLayoutMode?: LayoutMode
}
@@ -143,7 +145,7 @@ const inputItems = computed<FormDropdownItem[]>(() => {
}))
})
const outputItems = computed<FormDropdownItem[]>(() => {
if (!['image', 'video'].includes(props.assetKind ?? '')) return []
if (!['image', 'video', 'mesh'].includes(props.assetKind ?? '')) return []
const outputs = new Set<string>()
@@ -152,7 +154,8 @@ const outputItems = computed<FormDropdownItem[]>(() => {
task.flatOutputs.forEach((output) => {
const isTargetType =
(props.assetKind === 'image' && output.mediaType === 'images') ||
(props.assetKind === 'video' && output.mediaType === 'video')
(props.assetKind === 'video' && output.mediaType === 'video') ||
(props.assetKind === 'mesh' && output.is3D)
if (output.type === 'output' && isTargetType) {
const path = output.subfolder
@@ -292,6 +295,8 @@ const mediaPlaceholder = computed(() => {
return t('widgets.uploadSelect.placeholderVideo')
case 'audio':
return t('widgets.uploadSelect.placeholderAudio')
case 'mesh':
return t('widgets.uploadSelect.placeholderMesh')
case 'model':
return t('widgets.uploadSelect.placeholderModel')
case 'unknown':
@@ -316,6 +321,8 @@ const acceptTypes = computed(() => {
return 'video/*'
case 'audio':
return 'audio/*'
case 'mesh':
return SUPPORTED_EXTENSIONS_ACCEPT
default:
return undefined // model or unknown
}
@@ -365,6 +372,8 @@ const uploadFile = async (
const body = new FormData()
body.append('image', file)
if (isPasted) body.append('subfolder', 'pasted')
else if (props.uploadSubfolder)
body.append('subfolder', props.uploadSubfolder)
if (formFields.type) body.append('type', formFields.type)
const resp = await api.fetchApi('/upload/image', {

View File

@@ -85,6 +85,8 @@ export const zComboInputOptions = zBaseInputOptions.extend({
allow_batch: z.boolean().optional(),
video_upload: z.boolean().optional(),
audio_upload: z.boolean().optional(),
mesh_upload: z.boolean().optional(),
upload_subfolder: z.string().optional(),
animated_image_upload: z.boolean().optional(),
options: z.array(zComboOption).optional(),
remote: zRemoteWidgetConfig.optional(),

View File

@@ -1,7 +1,13 @@
import { inject } from 'vue'
import type { InjectionKey } from 'vue'
export type AssetKind = 'image' | 'video' | 'audio' | 'model' | 'unknown'
export type AssetKind =
| 'image'
| 'video'
| 'audio'
| 'model'
| 'mesh'
| 'unknown'
export const OnCloseKey: InjectionKey<() => void> = Symbol()