diff --git a/src/composables/useLoad3d.ts b/src/composables/useLoad3d.ts index 6227cf9a2fc..ac363f89098 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 a682f5af832..dda93e18b9d 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 455776744d6..6273dc42d65 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 d74f3185508..b142da44a9f 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 391518c1682..bba65e8bd60 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 de6628d775b..f5e238bb758 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 b8b72c35f8c..0a45f56c1eb 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 e4cc4878294..cd6e5027236 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 545a53b718b..4c16594e601 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 f4fe928cc5b..6c9f8f0cbfe 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 e4d5138eff8..c2a7bc02527 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()