Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 10 additions & 2 deletions src/composables/useLoad3d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]')) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love that we have a magic suffix...
(Not introduced in this PR, just noting)

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) {
Expand Down
3 changes: 2 additions & 1 deletion src/composables/useLoad3dDrag.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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()
Expand Down
6 changes: 2 additions & 4 deletions src/extensions/core/load3d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'] = ''

Expand Down
2 changes: 2 additions & 0 deletions src/extensions/core/load3d/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,5 @@ export const SUPPORTED_EXTENSIONS = new Set([
'.ply',
'.ksplat'
])

export const SUPPORTED_EXTENSIONS_ACCEPT = [...SUPPORTED_EXTENSIONS].join(',')
10 changes: 10 additions & 0 deletions src/extensions/core/load3dLazy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion src/locales/en/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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']
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
:asset-kind="assetKind"
:allow-upload="allowUpload"
:upload-folder="uploadFolder"
:upload-subfolder="uploadSubfolder"
:is-asset-mode="isAssetMode"
:default-layout-mode="defaultLayoutMode"
/>
Expand Down Expand Up @@ -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
}
}

Expand All @@ -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'
Expand All @@ -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
}
})

Expand All @@ -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'
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -44,6 +45,7 @@ interface Props {
assetKind?: AssetKind
allowUpload?: boolean
uploadFolder?: ResultItemType
uploadSubfolder?: string
isAssetMode?: boolean
defaultLayoutMode?: LayoutMode
}
Expand Down Expand Up @@ -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>()

Expand All @@ -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
Expand Down Expand Up @@ -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')
Comment on lines +298 to +299
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🏁 Script executed:

rg -n 'placeholderMesh' src/locales/

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 52


🏁 Script executed:

rg -A 2 -B 2 'uploadSelect' src/locales/en/main.json

Repository: Comfy-Org/ComfyUI_frontend

Length of output: 239


Add missing i18n key widgets.uploadSelect.placeholderMesh to src/locales/en/main.json.

The key referenced in the code does not exist in the locale file. The uploadSelect section currently only contains placeholder and placeholderImage keys. Add the placeholderMesh entry to the uploadSelect object in src/locales/en/main.json.

🤖 Prompt for AI Agents
In `@src/renderer/extensions/vueNodes/widgets/components/WidgetSelectDropdown.vue`
around lines 298 - 299, Add the missing i18n key
"widgets.uploadSelect.placeholderMesh" by inserting a new entry into the English
locale's uploadSelect object (same level as "placeholder" and
"placeholderImage"), supply an appropriate string value for the mesh
placeholder, and ensure the locale JSON remains valid (no trailing commas) so
the t('widgets.uploadSelect.placeholderMesh') lookup in WidgetSelectDropdown.vue
resolves.

case 'model':
return t('widgets.uploadSelect.placeholderModel')
case 'unknown':
Expand All @@ -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
}
Expand Down Expand Up @@ -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', {
Expand Down
2 changes: 2 additions & 0 deletions src/schemas/nodeDefSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
8 changes: 7 additions & 1 deletion src/types/widgetTypes.ts
Original file line number Diff line number Diff line change
@@ -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()

Expand Down