From 9ecbb3af271d37e71e3e2cd3c4b3c58f1ed77f94 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Tue, 10 Feb 2026 15:36:57 -0500 Subject: [PATCH] Feat/3d dropdown (#8765) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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> --- src/composables/useLoad3d.ts | 12 ++++++++-- src/composables/useLoad3dDrag.test.ts | 3 ++- src/extensions/core/load3d.ts | 6 ++--- src/extensions/core/load3d/constants.ts | 2 ++ src/extensions/core/load3dLazy.ts | 10 +++++++++ src/locales/en/main.json | 2 +- .../widgets/components/WidgetSelect.test.ts | 18 +++++++++++++++ .../widgets/components/WidgetSelect.vue | 22 +++++++++++++++---- .../components/WidgetSelectDropdown.vue | 13 +++++++++-- src/schemas/nodeDefSchema.ts | 2 ++ src/types/widgetTypes.ts | 8 ++++++- 11 files changed, 83 insertions(+), 15 deletions(-) diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index 6227cf9a2f..ac363f8909 100644 --- a/src/composables/useLoad3d.ts +++ b/src/composables/useLoad3d.ts @@ -219,12 +219,20 @@ export const useLoad3d = (nodeOrRef: MaybeRef) => { 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) { diff --git a/src/composables/useLoad3dDrag.test.ts b/src/composables/useLoad3dDrag.test.ts index a682f5af83..dda93e18b9 100644 --- a/src/composables/useLoad3dDrag.test.ts +++ b/src/composables/useLoad3dDrag.test.ts @@ -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() diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index 455776744d..6273dc42d6 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -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'] = '' diff --git a/src/extensions/core/load3d/constants.ts b/src/extensions/core/load3d/constants.ts index d74f318550..b142da44a9 100644 --- a/src/extensions/core/load3d/constants.ts +++ b/src/extensions/core/load3d/constants.ts @@ -14,3 +14,5 @@ export const SUPPORTED_EXTENSIONS = new Set([ '.ply', '.ksplat' ]) + +export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',') diff --git a/src/extensions/core/load3dLazy.ts b/src/extensions/core/load3dLazy.ts index 391518c168..bba65e8bd6 100644 --- a/src/extensions/core/load3dLazy.ts +++ b/src/extensions/core/load3dLazy.ts @@ -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. diff --git a/src/locales/en/main.json b/src/locales/en/main.json index de6628d775..f5e238bb75 100644 --- a/src/locales/en/main.json +++ b/src/locales/en/main.json @@ -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": { diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts index b8b72c35f8..0a45f56c1e 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.test.ts @@ -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'] diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue index e4cc487829..cd6e502723 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelect.vue @@ -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(() => { return specDescriptor.value.folder ?? 'input' }) +const uploadSubfolder = computed(() => specDescriptor.value.subfolder) const defaultLayoutMode = computed(() => { return isAssetMode.value ? 'list' : 'grid' }) diff --git a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue index 545a53b718..4c16594e60 100644 --- a/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue +++ b/src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue @@ -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(() => { })) }) const outputItems = computed(() => { - if (!['image', 'video'].includes(props.assetKind ?? '')) return [] + if (!['image', 'video', 'mesh'].includes(props.assetKind ?? '')) return [] const outputs = new Set() @@ -152,7 +154,8 @@ const outputItems = computed(() => { 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', { diff --git a/src/schemas/nodeDefSchema.ts b/src/schemas/nodeDefSchema.ts index f4fe928cc5..6c9f8f0cbf 100644 --- a/src/schemas/nodeDefSchema.ts +++ b/src/schemas/nodeDefSchema.ts @@ -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(), diff --git a/src/types/widgetTypes.ts b/src/types/widgetTypes.ts index e4d5138eff..c2a7bc0252 100644 --- a/src/types/widgetTypes.ts +++ b/src/types/widgetTypes.ts @@ -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()