mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-02-03 14:54:37 +00:00
Summary Fully Refactored the Load3D module to improve architecture and maintainability by consolidating functionality into a centralized composable pattern and simplifying component structure. and support VueNodes system Changes - Architecture: Introduced new useLoad3d composable to centralize 3D loading logic and state management - Component Simplification: Removed redundant components (Load3DAnimation.vue, Load3DAnimationScene.vue, PreviewManager.ts) - Support VueNodes - improve config store - remove lineart output due Animation doesnot support it, may add it back later - remove Preview screen and keep scene in fixed ratio in load3d (not affect preview3d) - improve record video feature which will already record video by same ratio as scene Need BE change https://github.com/comfyanonymous/ComfyUI/pull/10025 https://github.com/user-attachments/assets/9e038729-84a0-45ad-b0f2-11c57d7e0c9a ┆Issue is synchronized with this [Notion page](https://www.notion.so/PR-5765-refactor-refactor-load3d-2796d73d365081728297cc486e2e9052) by [Unito](https://www.unito.io)
504 lines
15 KiB
TypeScript
504 lines
15 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 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 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: any) {
|
|
if (!files?.length) return
|
|
|
|
const modelWidget = node.widgets?.find(
|
|
(w: any) => w.name === 'model_file'
|
|
) as IStringWidget
|
|
|
|
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: any) {
|
|
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
|
|
}
|
|
],
|
|
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', 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 []
|
|
|
|
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 any
|
|
const cameraState = cameraConfig?.state
|
|
|
|
const config = new Load3DConfiguration(load3d)
|
|
|
|
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 = (node.properties['Camera Config'] as any) || {
|
|
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 any)?.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 []
|
|
|
|
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)
|
|
|
|
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 any
|
|
const cameraState = cameraConfig?.state
|
|
|
|
const settings = {
|
|
loadFolder: 'output',
|
|
modelWidget: modelWidget,
|
|
cameraState: cameraState
|
|
}
|
|
|
|
config.configure(settings)
|
|
}
|
|
|
|
node.onExecuted = function (message: any) {
|
|
onExecuted?.apply(this, arguments as any)
|
|
|
|
let filePath = message.result[0]
|
|
|
|
if (!filePath) {
|
|
const msg = t('toastMessages.unableToGetModelFilePath')
|
|
console.error(msg)
|
|
useToastStore().addAlert(msg)
|
|
}
|
|
|
|
let cameraState = message.result[1]
|
|
let bgImagePath = message.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)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
}
|
|
})
|