mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-04-28 10:12:11 +00:00
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:
@@ -219,12 +219,20 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
|||||||
return modelPath
|
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(
|
return api.apiURL(
|
||||||
Load3dUtils.getResourceURL(
|
Load3dUtils.getResourceURL(
|
||||||
subfolder,
|
subfolder,
|
||||||
filename,
|
filename,
|
||||||
isPreview.value ? 'output' : 'input'
|
forcedType ?? (isPreview.value ? 'output' : 'input')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
|
||||||
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
import { useLoad3dDrag } from '@/composables/useLoad3dDrag'
|
||||||
|
import { SUPPORTED_EXTENSIONS } from '@/extensions/core/load3d/constants'
|
||||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||||
import { createMockFileList } from '@/utils/__tests__/litegraphTestUtils'
|
import { createMockFileList } from '@/utils/__tests__/litegraphTestUtils'
|
||||||
|
|
||||||
@@ -199,7 +200,7 @@ describe('useLoad3dDrag', () => {
|
|||||||
onModelDrop: mockOnModelDrop
|
onModelDrop: mockOnModelDrop
|
||||||
})
|
})
|
||||||
|
|
||||||
const extensions = ['.gltf', '.glb', '.obj', '.fbx', '.stl']
|
const extensions = [...SUPPORTED_EXTENSIONS]
|
||||||
|
|
||||||
for (const ext of extensions) {
|
for (const ext of extensions) {
|
||||||
vi.mocked(mockOnModelDrop).mockClear()
|
vi.mocked(mockOnModelDrop).mockClear()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import type {
|
|||||||
CameraState
|
CameraState
|
||||||
} from '@/extensions/core/load3d/interfaces'
|
} from '@/extensions/core/load3d/interfaces'
|
||||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||||
|
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||||
@@ -258,10 +259,7 @@ useExtensionService().registerExtension({
|
|||||||
getCustomWidgets() {
|
getCustomWidgets() {
|
||||||
return {
|
return {
|
||||||
LOAD_3D(node) {
|
LOAD_3D(node) {
|
||||||
const fileInput = createFileInput(
|
const fileInput = createFileInput(SUPPORTED_EXTENSIONS_ACCEPT, false)
|
||||||
'.gltf,.glb,.obj,.fbx,.stl,.ply,.spz,.splat,.ksplat',
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
node.properties['Resource Folder'] = ''
|
node.properties['Resource Folder'] = ''
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,5 @@ export const SUPPORTED_EXTENSIONS = new Set([
|
|||||||
'.ply',
|
'.ply',
|
||||||
'.ksplat'
|
'.ksplat'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')
|
||||||
|
|||||||
@@ -56,6 +56,16 @@ useExtensionService().registerExtension({
|
|||||||
|
|
||||||
async beforeRegisterNodeDef(nodeType, nodeData) {
|
async beforeRegisterNodeDef(nodeType, nodeData) {
|
||||||
if (isLoad3dNodeType(nodeData.name)) {
|
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,
|
// Load the 3D extensions and replay their beforeRegisterNodeDef hooks,
|
||||||
// since invokeExtensionsAsync already captured the extensions snapshot
|
// since invokeExtensionsAsync already captured the extensions snapshot
|
||||||
// before these new extensions were registered.
|
// before these new extensions were registered.
|
||||||
|
|||||||
@@ -1801,7 +1801,7 @@
|
|||||||
},
|
},
|
||||||
"openIn3DViewer": "Open in 3D Viewer",
|
"openIn3DViewer": "Open in 3D Viewer",
|
||||||
"dropToLoad": "Drop 3D model to load",
|
"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..."
|
"uploadingModel": "Uploading 3D model..."
|
||||||
},
|
},
|
||||||
"imageCrop": {
|
"imageCrop": {
|
||||||
|
|||||||
@@ -334,6 +334,24 @@ describe('WidgetSelect Value Binding', () => {
|
|||||||
expect(dropdown.props('allowUpload')).toBe(false)
|
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', () => {
|
it('keeps default select when no spec or media hints are present', () => {
|
||||||
const widget = createMockWidget('plain', {
|
const widget = createMockWidget('plain', {
|
||||||
values: ['plain', 'text']
|
values: ['plain', 'text']
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
:asset-kind="assetKind"
|
:asset-kind="assetKind"
|
||||||
:allow-upload="allowUpload"
|
:allow-upload="allowUpload"
|
||||||
:upload-folder="uploadFolder"
|
:upload-folder="uploadFolder"
|
||||||
|
:upload-subfolder="uploadSubfolder"
|
||||||
:is-asset-mode="isAssetMode"
|
:is-asset-mode="isAssetMode"
|
||||||
:default-layout-mode="defaultLayoutMode"
|
:default-layout-mode="defaultLayoutMode"
|
||||||
/>
|
/>
|
||||||
@@ -58,13 +59,15 @@ const specDescriptor = computed<{
|
|||||||
kind: AssetKind
|
kind: AssetKind
|
||||||
allowUpload: boolean
|
allowUpload: boolean
|
||||||
folder: ResultItemType | undefined
|
folder: ResultItemType | undefined
|
||||||
|
subfolder: string | undefined
|
||||||
}>(() => {
|
}>(() => {
|
||||||
const spec = comboSpec.value
|
const spec = comboSpec.value
|
||||||
if (!spec) {
|
if (!spec) {
|
||||||
return {
|
return {
|
||||||
kind: 'unknown',
|
kind: 'unknown',
|
||||||
allowUpload: false,
|
allowUpload: false,
|
||||||
folder: undefined
|
folder: undefined,
|
||||||
|
subfolder: undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +76,9 @@ const specDescriptor = computed<{
|
|||||||
animated_image_upload,
|
animated_image_upload,
|
||||||
video_upload,
|
video_upload,
|
||||||
image_folder,
|
image_folder,
|
||||||
audio_upload
|
audio_upload,
|
||||||
|
mesh_upload,
|
||||||
|
upload_subfolder
|
||||||
} = spec
|
} = spec
|
||||||
|
|
||||||
let kind: AssetKind = 'unknown'
|
let kind: AssetKind = 'unknown'
|
||||||
@@ -83,18 +88,26 @@ const specDescriptor = computed<{
|
|||||||
kind = 'image'
|
kind = 'image'
|
||||||
} else if (audio_upload) {
|
} else if (audio_upload) {
|
||||||
kind = 'audio'
|
kind = 'audio'
|
||||||
|
} else if (mesh_upload) {
|
||||||
|
kind = 'mesh'
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
|
// TODO: add support for models (checkpoints, VAE, LoRAs, etc.) -- get widgetType from spec
|
||||||
|
|
||||||
const allowUpload =
|
const allowUpload =
|
||||||
image_upload === true ||
|
image_upload === true ||
|
||||||
animated_image_upload === true ||
|
animated_image_upload === true ||
|
||||||
video_upload === true ||
|
video_upload === true ||
|
||||||
audio_upload === true
|
audio_upload === true ||
|
||||||
|
mesh_upload === true
|
||||||
|
|
||||||
|
const folder = mesh_upload ? 'input' : image_folder
|
||||||
|
|
||||||
return {
|
return {
|
||||||
kind,
|
kind,
|
||||||
allowUpload,
|
allowUpload,
|
||||||
folder: image_folder
|
folder,
|
||||||
|
subfolder: upload_subfolder
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -120,6 +133,7 @@ const allowUpload = computed(() => specDescriptor.value.allowUpload)
|
|||||||
const uploadFolder = computed<ResultItemType>(() => {
|
const uploadFolder = computed<ResultItemType>(() => {
|
||||||
return specDescriptor.value.folder ?? 'input'
|
return specDescriptor.value.folder ?? 'input'
|
||||||
})
|
})
|
||||||
|
const uploadSubfolder = computed(() => specDescriptor.value.subfolder)
|
||||||
const defaultLayoutMode = computed<LayoutMode>(() => {
|
const defaultLayoutMode = computed<LayoutMode>(() => {
|
||||||
return isAssetMode.value ? 'list' : 'grid'
|
return isAssetMode.value ? 'list' : 'grid'
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { computed, provide, ref, toRef, watch } from 'vue'
|
|||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
import { useTransformCompatOverlayProps } from '@/composables/useTransformCompatOverlayProps'
|
||||||
|
import { SUPPORTED_EXTENSIONS_ACCEPT } from '@/extensions/core/load3d/constants'
|
||||||
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
import { useAssetFilterOptions } from '@/platform/assets/composables/useAssetFilterOptions'
|
||||||
import {
|
import {
|
||||||
filterItemByBaseModels,
|
filterItemByBaseModels,
|
||||||
@@ -44,6 +45,7 @@ interface Props {
|
|||||||
assetKind?: AssetKind
|
assetKind?: AssetKind
|
||||||
allowUpload?: boolean
|
allowUpload?: boolean
|
||||||
uploadFolder?: ResultItemType
|
uploadFolder?: ResultItemType
|
||||||
|
uploadSubfolder?: string
|
||||||
isAssetMode?: boolean
|
isAssetMode?: boolean
|
||||||
defaultLayoutMode?: LayoutMode
|
defaultLayoutMode?: LayoutMode
|
||||||
}
|
}
|
||||||
@@ -143,7 +145,7 @@ const inputItems = computed<FormDropdownItem[]>(() => {
|
|||||||
}))
|
}))
|
||||||
})
|
})
|
||||||
const outputItems = 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>()
|
const outputs = new Set<string>()
|
||||||
|
|
||||||
@@ -152,7 +154,8 @@ const outputItems = computed<FormDropdownItem[]>(() => {
|
|||||||
task.flatOutputs.forEach((output) => {
|
task.flatOutputs.forEach((output) => {
|
||||||
const isTargetType =
|
const isTargetType =
|
||||||
(props.assetKind === 'image' && output.mediaType === 'images') ||
|
(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) {
|
if (output.type === 'output' && isTargetType) {
|
||||||
const path = output.subfolder
|
const path = output.subfolder
|
||||||
@@ -292,6 +295,8 @@ const mediaPlaceholder = computed(() => {
|
|||||||
return t('widgets.uploadSelect.placeholderVideo')
|
return t('widgets.uploadSelect.placeholderVideo')
|
||||||
case 'audio':
|
case 'audio':
|
||||||
return t('widgets.uploadSelect.placeholderAudio')
|
return t('widgets.uploadSelect.placeholderAudio')
|
||||||
|
case 'mesh':
|
||||||
|
return t('widgets.uploadSelect.placeholderMesh')
|
||||||
case 'model':
|
case 'model':
|
||||||
return t('widgets.uploadSelect.placeholderModel')
|
return t('widgets.uploadSelect.placeholderModel')
|
||||||
case 'unknown':
|
case 'unknown':
|
||||||
@@ -316,6 +321,8 @@ const acceptTypes = computed(() => {
|
|||||||
return 'video/*'
|
return 'video/*'
|
||||||
case 'audio':
|
case 'audio':
|
||||||
return 'audio/*'
|
return 'audio/*'
|
||||||
|
case 'mesh':
|
||||||
|
return SUPPORTED_EXTENSIONS_ACCEPT
|
||||||
default:
|
default:
|
||||||
return undefined // model or unknown
|
return undefined // model or unknown
|
||||||
}
|
}
|
||||||
@@ -365,6 +372,8 @@ const uploadFile = async (
|
|||||||
const body = new FormData()
|
const body = new FormData()
|
||||||
body.append('image', file)
|
body.append('image', file)
|
||||||
if (isPasted) body.append('subfolder', 'pasted')
|
if (isPasted) body.append('subfolder', 'pasted')
|
||||||
|
else if (props.uploadSubfolder)
|
||||||
|
body.append('subfolder', props.uploadSubfolder)
|
||||||
if (formFields.type) body.append('type', formFields.type)
|
if (formFields.type) body.append('type', formFields.type)
|
||||||
|
|
||||||
const resp = await api.fetchApi('/upload/image', {
|
const resp = await api.fetchApi('/upload/image', {
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export const zComboInputOptions = zBaseInputOptions.extend({
|
|||||||
allow_batch: z.boolean().optional(),
|
allow_batch: z.boolean().optional(),
|
||||||
video_upload: z.boolean().optional(),
|
video_upload: z.boolean().optional(),
|
||||||
audio_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(),
|
animated_image_upload: z.boolean().optional(),
|
||||||
options: z.array(zComboOption).optional(),
|
options: z.array(zComboOption).optional(),
|
||||||
remote: zRemoteWidgetConfig.optional(),
|
remote: zRemoteWidgetConfig.optional(),
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
import { inject } from 'vue'
|
import { inject } from 'vue'
|
||||||
import type { InjectionKey } 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()
|
export const OnCloseKey: InjectionKey<() => void> = Symbol()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user