Files
ComfyUI_frontend/src/extensions/core/load3d.ts
Johnpaul Chiwetelu c56e8425d4 Road to No Explicit Any Part 6: Composables and Extensions (#8083)
## Summary
- Type `onExecuted` callbacks with `NodeExecutionOutput` in saveMesh.ts
and uploadAudio.ts
- Type composable parameters and return values properly
(useLoad3dViewer, useImageMenuOptions, useJobMenu, useResultGallery,
useContextMenuTranslation)
- Type `taskRef` as `TaskItemImpl` with updated test mocks
- Fix error catch and index signature patterns without `any`
- Add `NodeOutputWith<T>` generic helper for typed access to passthrough
properties on `NodeExecutionOutput`

## Test plan
- [x] `pnpm typecheck` passes
- [x] `pnpm lint` passes
- [x] Unit tests pass for affected files
- [x] Sourcegraph checks confirm no external usage of modified types

┆Issue is synchronized with this [Notion
page](https://www.notion.so/PR-8083-Road-to-No-Explicit-Any-Part-6-Composables-and-Extensions-2e96d73d3650810fb033d745bf88a22b)
by [Unito](https://www.unito.io)
2026-01-16 00:27:28 +01:00

539 lines
16 KiB
TypeScript

import { nextTick } from 'vue'
import Load3D from '@/components/load3d/Load3D.vue'
import Load3DViewerContent from '@/components/load3d/Load3dViewerContent.vue'
import { nodeToLoad3dMap, useLoad3d } from '@/composables/useLoad3d'
import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper'
import type {
CameraConfig,
CameraState
} from '@/extensions/core/load3d/interfaces'
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
import { t } from '@/i18n'
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
import type { IContextMenuValue } from '@/lib/litegraph/src/interfaces'
import type { IStringWidget } from '@/lib/litegraph/src/types/widgets'
import { useToastStore } from '@/platform/updates/common/toastStore'
import type { NodeOutputWith } from '@/schemas/apiSchema'
type Load3dPreviewOutput = NodeOutputWith<{
result?: [string?, CameraState?, string?]
}>
import type { CustomInputSpec } from '@/schemas/nodeDef/nodeDefSchemaV2'
import { api } from '@/scripts/api'
import { ComfyApp, app } from '@/scripts/app'
import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
import { useExtensionService } from '@/services/extensionService'
import { useLoad3dService } from '@/services/load3dService'
import { useDialogStore } from '@/stores/dialogStore'
import { isLoad3dNode } from '@/utils/litegraphUtil'
const inputSpecLoad3D: CustomInputSpec = {
name: 'image',
type: 'Load3D',
isPreview: false
}
const inputSpecPreview3D: CustomInputSpec = {
name: 'image',
type: 'Preview3D',
isPreview: true
}
async function handleModelUpload(files: FileList, node: LGraphNode) {
if (!files?.length) return
const modelWidget = node.widgets?.find((w) => w.name === 'model_file') as
| IStringWidget
| undefined
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
const uploadPath = await Load3dUtils.uploadFile(files[0], subfolder)
if (!uploadPath) {
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
return
}
const modelUrl = api.apiURL(
Load3dUtils.getResourceURL(
...Load3dUtils.splitFilePath(uploadPath),
'input'
)
)
useLoad3d(node).waitForLoad3d((load3d) => {
try {
load3d.loadModel(modelUrl)
} catch (error) {
useToastStore().addAlert(t('toastMessages.failedToLoadModel'))
}
})
if (uploadPath && modelWidget) {
if (!modelWidget.options?.values?.includes(uploadPath)) {
modelWidget.options?.values?.push(uploadPath)
}
modelWidget.value = uploadPath
}
} catch (error) {
console.error('Model upload failed:', error)
useToastStore().addAlert(t('toastMessages.fileUploadFailed'))
}
}
async function handleResourcesUpload(files: FileList, node: LGraphNode) {
if (!files?.length) return
try {
const resourceFolder = (node.properties['Resource Folder'] as string) || ''
const subfolder = resourceFolder.trim()
? `3d/${resourceFolder.trim()}`
: '3d'
await Load3dUtils.uploadMultipleFiles(files, subfolder)
} catch (error) {
console.error('Extra resources upload failed:', error)
useToastStore().addAlert(t('toastMessages.extraResourcesUploadFailed'))
}
}
function createFileInput(
accept: string,
multiple: boolean = false
): HTMLInputElement {
const input = document.createElement('input')
input.type = 'file'
input.accept = accept
input.multiple = multiple
input.style.display = 'none'
return input
}
useExtensionService().registerExtension({
name: 'Comfy.Load3D',
settings: [
{
id: 'Comfy.Load3D.ShowGrid',
category: ['3D', 'Scene', 'Initial Grid Visibility'],
name: 'Initial Grid Visibility',
tooltip:
'Controls whether the grid is visible by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation.',
type: 'boolean',
defaultValue: true,
experimental: true
},
{
id: 'Comfy.Load3D.BackgroundColor',
category: ['3D', 'Scene', 'Initial Background Color'],
name: 'Initial Background Color',
tooltip:
'Controls the default background color of the 3D scene. This setting determines the background appearance when a new 3D widget is created, but can be adjusted individually for each widget after creation.',
type: 'color',
defaultValue: '282828',
experimental: true
},
{
id: 'Comfy.Load3D.CameraType',
category: ['3D', 'Camera', 'Initial Camera Type'],
name: 'Initial Camera Type',
tooltip:
'Controls whether the camera is perspective or orthographic by default when a new 3D widget is created. This default can still be toggled individually for each widget after creation.',
type: 'combo',
options: ['perspective', 'orthographic'],
defaultValue: 'perspective',
experimental: true
},
{
id: 'Comfy.Load3D.LightIntensity',
category: ['3D', 'Light', 'Initial Light Intensity'],
name: 'Initial Light Intensity',
tooltip:
'Sets the default brightness level of lighting in the 3D scene. This value determines how intensely lights illuminate objects when a new 3D widget is created, but can be adjusted individually for each widget after creation.',
type: 'number',
defaultValue: 3,
experimental: true
},
{
id: 'Comfy.Load3D.LightIntensityMaximum',
category: ['3D', 'Light', 'Light Intensity Maximum'],
name: 'Light Intensity Maximum',
tooltip:
'Sets the maximum allowable light intensity value for 3D scenes. This defines the upper brightness limit that can be set when adjusting lighting in any 3D widget.',
type: 'number',
defaultValue: 10,
experimental: true
},
{
id: 'Comfy.Load3D.LightIntensityMinimum',
category: ['3D', 'Light', 'Light Intensity Minimum'],
name: 'Light Intensity Minimum',
tooltip:
'Sets the minimum allowable light intensity value for 3D scenes. This defines the lower brightness limit that can be set when adjusting lighting in any 3D widget.',
type: 'number',
defaultValue: 1,
experimental: true
},
{
id: 'Comfy.Load3D.LightAdjustmentIncrement',
category: ['3D', 'Light', 'Light Adjustment Increment'],
name: 'Light Adjustment Increment',
tooltip:
'Controls the increment size when adjusting light intensity in 3D scenes. A smaller step value allows for finer control over lighting adjustments, while a larger value results in more noticeable changes per adjustment.',
type: 'slider',
attrs: {
min: 0.1,
max: 1,
step: 0.1
},
defaultValue: 0.5,
experimental: true
},
{
id: 'Comfy.Load3D.3DViewerEnable',
category: ['3D', '3DViewer', 'Enable'],
name: 'Enable 3D Viewer (Beta)',
tooltip:
'Enables the 3D Viewer (Beta) for selected nodes. This feature allows you to visualize and interact with 3D models directly within the full size 3d viewer.',
type: 'boolean',
defaultValue: false,
experimental: true
},
{
id: 'Comfy.Load3D.PLYEngine',
category: ['3D', 'PLY', 'PLY Engine'],
name: 'PLY Engine',
tooltip:
'Select the engine for loading PLY files. "threejs" uses the native Three.js PLYLoader (best for mesh PLY files). "fastply" uses an optimized loader for ASCII point cloud PLY files. "sparkjs" uses Spark.js for 3D Gaussian Splatting PLY files.',
type: 'combo',
options: ['threejs', 'fastply', 'sparkjs'],
defaultValue: 'threejs',
experimental: true
}
],
commands: [
{
id: 'Comfy.3DViewer.Open3DViewer',
icon: 'pi pi-pencil',
label: 'Open 3D Viewer (Beta) for Selected Node',
function: () => {
const selectedNodes = app.canvas.selected_nodes
if (!selectedNodes || Object.keys(selectedNodes).length !== 1) return
const selectedNode = selectedNodes[Object.keys(selectedNodes)[0]]
if (!isLoad3dNode(selectedNode)) return
ComfyApp.copyToClipspace(selectedNode)
// @ts-expect-error clipspace_return_node is an extension property added at runtime
ComfyApp.clipspace_return_node = selectedNode
const props = { node: selectedNode }
useDialogStore().showDialog({
key: 'global-load3d-viewer',
title: t('load3d.viewer.title'),
component: Load3DViewerContent,
props: props,
dialogComponentProps: {
style: 'width: 80vw; height: 80vh;',
maximizable: true,
onClose: async () => {
await useLoad3dService().handleViewerClose(props.node)
}
}
})
}
}
],
getCustomWidgets() {
return {
LOAD_3D(node) {
const fileInput = createFileInput(
'.gltf,.glb,.obj,.fbx,.stl,.ply,.spz,.splat,.ksplat',
false
)
node.properties['Resource Folder'] = ''
fileInput.onchange = async () => {
await handleModelUpload(fileInput.files!, node)
}
node.addWidget('button', 'upload 3d model', 'upload3dmodel', () => {
fileInput.click()
})
const resourcesInput = createFileInput('*', true)
resourcesInput.onchange = async () => {
await handleResourcesUpload(resourcesInput.files!, node)
resourcesInput.value = ''
}
node.addWidget(
'button',
'upload extra resources',
'uploadExtraResources',
() => {
resourcesInput.click()
}
)
node.addWidget('button', 'clear', 'clear', () => {
useLoad3d(node).waitForLoad3d((load3d) => {
load3d.clearModel()
})
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
modelWidget.value = ''
}
})
const widget = new ComponentWidgetImpl({
node: node,
name: 'image',
component: Load3D,
inputSpec: inputSpecLoad3D,
options: {}
})
widget.type = 'load3D'
addWidget(node, widget)
return { widget }
}
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
// Only show menu items for Load3D nodes
if (node.constructor.comfyClass !== 'Load3D') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'Load3D') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 600)])
await nextTick()
useLoad3d(node).waitForLoad3d((load3d) => {
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const config = new Load3DConfiguration(load3d, node.properties)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
const width = node.widgets?.find((w) => w.name === 'width')
const height = node.widgets?.find((w) => w.name === 'height')
const sceneWidget = node.widgets?.find((w) => w.name === 'image')
if (modelWidget && width && height && sceneWidget) {
const settings = {
loadFolder: 'input',
modelWidget: modelWidget,
cameraState: cameraState,
width: width,
height: height
}
config.configure(settings)
sceneWidget.serializeValue = async () => {
const currentLoad3d = nodeToLoad3dMap.get(node)
if (!currentLoad3d) {
console.error('No load3d instance found for node')
return null
}
const cameraConfig: CameraConfig = (node.properties[
'Camera Config'
] as CameraConfig | undefined) || {
cameraType: currentLoad3d.getCurrentCameraType(),
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
}
cameraConfig.state = currentLoad3d.getCameraState()
node.properties['Camera Config'] = cameraConfig
currentLoad3d.stopRecording()
const {
scene: imageData,
mask: maskData,
normal: normalData
} = await currentLoad3d.captureScene(
width.value as number,
height.value as number
)
const [data, dataMask, dataNormal] = await Promise.all([
Load3dUtils.uploadTempImage(imageData, 'scene'),
Load3dUtils.uploadTempImage(maskData, 'scene_mask'),
Load3dUtils.uploadTempImage(normalData, 'scene_normal')
])
currentLoad3d.handleResize()
const returnVal = {
image: `threed/${data.name} [temp]`,
mask: `threed/${dataMask.name} [temp]`,
normal: `threed/${dataNormal.name} [temp]`,
camera_info:
(node.properties['Camera Config'] as CameraConfig | undefined)
?.state || null,
recording: ''
}
const recordingData = currentLoad3d.getRecordingData()
if (recordingData) {
const [recording] = await Promise.all([
Load3dUtils.uploadTempImage(recordingData, 'recording', 'mp4')
])
returnVal['recording'] = `threed/${recording.name} [temp]`
}
return returnVal
}
}
})
}
})
useExtensionService().registerExtension({
name: 'Comfy.Preview3D',
async beforeRegisterNodeDef(_nodeType, nodeData) {
if ('Preview3D' === nodeData.name) {
// @ts-expect-error InputSpec is not typed correctly
nodeData.input.required.image = ['PREVIEW_3D']
}
},
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
// Only show menu items for Preview3D nodes
if (node.constructor.comfyClass !== 'Preview3D') return []
const load3d = useLoad3dService().getLoad3d(node)
if (!load3d) return []
if (load3d.isSplatModel()) return []
return createExportMenuItems(load3d)
},
getCustomWidgets() {
return {
PREVIEW_3D(node) {
const widget = new ComponentWidgetImpl({
node,
name: inputSpecPreview3D.name,
component: Load3D,
inputSpec: inputSpecPreview3D,
options: {}
})
widget.type = 'load3D'
addWidget(node, widget)
return { widget }
}
}
},
async nodeCreated(node) {
if (node.constructor.comfyClass !== 'Preview3D') return
const [oldWidth, oldHeight] = node.size
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
await nextTick()
const onExecuted = node.onExecuted
useLoad3d(node).waitForLoad3d((load3d) => {
const config = new Load3DConfiguration(load3d, node.properties)
const modelWidget = node.widgets?.find((w) => w.name === 'model_file')
if (modelWidget) {
const lastTimeModelFile = node.properties['Last Time Model File']
if (lastTimeModelFile) {
modelWidget.value = lastTimeModelFile
const cameraConfig = node.properties['Camera Config'] as
| CameraConfig
| undefined
const cameraState = cameraConfig?.state
const settings = {
loadFolder: 'output',
modelWidget: modelWidget,
cameraState: cameraState
}
config.configure(settings)
}
node.onExecuted = function (output: Load3dPreviewOutput) {
onExecuted?.call(this, output)
const result = output.result
const filePath = result?.[0]
if (!filePath) {
const msg = t('toastMessages.unableToGetModelFilePath')
console.error(msg)
useToastStore().addAlert(msg)
}
const cameraState = result?.[1]
const bgImagePath = result?.[2]
modelWidget.value = filePath?.replaceAll('\\', '/')
node.properties['Last Time Model File'] = modelWidget.value
const settings = {
loadFolder: 'output',
modelWidget: modelWidget,
cameraState: cameraState,
bgImagePath: bgImagePath
}
config.configure(settings)
if (bgImagePath) {
load3d.setBackgroundImage(bgImagePath)
}
}
}
})
}
})