From 3294191bb6a4eac115d4b949dde1b573507827c2 Mon Sep 17 00:00:00 2001 From: Terry Jia Date: Fri, 24 Jan 2025 15:18:11 -0500 Subject: [PATCH] [3d] add preview 3d animation node --- src/assets/css/style.css | 6 +- src/extensions/core/load3d.ts | 426 ++++++++---------- .../core/load3d/Load3DConfiguration.ts | 139 ++++++ src/extensions/core/load3d/Load3dUtils.ts | 122 +++++ 4 files changed, 458 insertions(+), 235 deletions(-) create mode 100644 src/extensions/core/load3d/Load3DConfiguration.ts create mode 100644 src/extensions/core/load3d/Load3dUtils.ts diff --git a/src/assets/css/style.css b/src/assets/css/style.css index 4f4cf667f..fdc16947b 100644 --- a/src/assets/css/style.css +++ b/src/assets/css/style.css @@ -768,7 +768,8 @@ audio.comfy-audio.empty-audio-widget { /* Load3d styles */ .comfy-load-3d, .comfy-load-3d-animation, -.comfy-preview-3d { +.comfy-preview-3d, +.comfy-preview-3d-animation{ display: flex; flex-direction: column; background: transparent; @@ -779,7 +780,8 @@ audio.comfy-audio.empty-audio-widget { .comfy-load-3d canvas, .comfy-load-3d-animation canvas, -.comfy-preview-3d canvas { +.comfy-preview-3d canvas, +.comfy-preview-3d-animation canvas{ display: flex; width: 100% !important; height: 100% !important; diff --git a/src/extensions/core/load3d.ts b/src/extensions/core/load3d.ts index d3cd1f4d1..ff6240f68 100644 --- a/src/extensions/core/load3d.ts +++ b/src/extensions/core/load3d.ts @@ -2,234 +2,15 @@ import { IWidget } from '@comfyorg/litegraph' import { nextTick } from 'vue' +import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration' import Load3d from '@/extensions/core/load3d/Load3d' import Load3dAnimation from '@/extensions/core/load3d/Load3dAnimation' -import { api } from '@/scripts/api' +import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' import { app } from '@/scripts/app' import { useToastStore } from '@/stores/toastStore' -async function uploadTempImage(imageData, prefix) { - const blob = await fetch(imageData).then((r) => r.blob()) - const name = `${prefix}_${Date.now()}.png` - const file = new File([blob], name) - - const body = new FormData() - body.append('image', file) - body.append('subfolder', 'threed') - body.append('type', 'temp') - - const resp = await api.fetchApi('/upload/image', { - method: 'POST', - body - }) - - if (resp.status !== 200) { - const err = `Error uploading temp image: ${resp.status} - ${resp.statusText}` - useToastStore().addAlert(err) - throw new Error(err) - } - - return await resp.json() -} - -async function uploadFile( - load3d: Load3d, - file: File, - fileInput?: HTMLInputElement -) { - let uploadPath - - try { - const body = new FormData() - body.append('image', file) - body.append('subfolder', '3d') - - const resp = await api.fetchApi('/upload/image', { - method: 'POST', - body - }) - - if (resp.status === 200) { - const data = await resp.json() - let path = data.name - - if (data.subfolder) path = data.subfolder + '/' + path - - uploadPath = path - - const modelUrl = api.apiURL( - getResourceURL(...splitFilePath(path), 'input') - ) - await load3d.loadModel(modelUrl, file.name) - - const fileExt = file.name.split('.').pop()?.toLowerCase() - if (fileExt === 'obj' && fileInput?.files) { - try { - const mtlFile = Array.from(fileInput.files).find((f) => - f.name.toLowerCase().endsWith('.mtl') - ) - - if (mtlFile) { - const mtlFormData = new FormData() - mtlFormData.append('image', mtlFile) - mtlFormData.append('subfolder', '3d') - - await api.fetchApi('/upload/image', { - method: 'POST', - body: mtlFormData - }) - } - } catch (mtlError) { - console.warn('Failed to upload MTL file:', mtlError) - } - } - } else { - useToastStore().addAlert(resp.status + ' - ' + resp.statusText) - } - } catch (error) { - console.error('Upload error:', error) - useToastStore().addAlert( - error instanceof Error ? error.message : 'Upload failed' - ) - } - - return uploadPath -} - -function splitFilePath(path: string): [string, string] { - const folder_separator = path.lastIndexOf('/') - if (folder_separator === -1) { - return ['', path] - } - return [ - path.substring(0, folder_separator), - path.substring(folder_separator + 1) - ] -} - -function getResourceURL( - subfolder: string, - filename: string, - type: string = 'input' -): string { - const params = [ - 'filename=' + encodeURIComponent(filename), - 'type=' + type, - 'subfolder=' + subfolder, - app.getRandParam().substring(1) - ].join('&') - - return `/view?${params}` -} - const containerToLoad3D = new Map() -function configureLoad3D( - load3d: Load3d, - loadFolder: 'input' | 'output', - modelWidget: IWidget, - material: IWidget, - bgColor: IWidget, - lightIntensity: IWidget, - upDirection: IWidget, - fov: IWidget, - cameraState?: any, - postModelUpdateFunc?: (load3d: Load3d) => void -) { - const createModelUpdateHandler = () => { - let isFirstLoad = true - - return async (value: string | number | boolean | object) => { - if (!value) return - - const filename = value as string - const modelUrl = api.apiURL( - getResourceURL(...splitFilePath(filename), loadFolder) - ) - - await load3d.loadModel(modelUrl, filename) - - load3d.setMaterialMode( - material.value as 'original' | 'normal' | 'wireframe' - ) - - load3d.setUpDirection( - upDirection.value as - | 'original' - | '-x' - | '+x' - | '-y' - | '+y' - | '-z' - | '+z' - ) - - if (postModelUpdateFunc) { - postModelUpdateFunc(load3d) - } - - if (isFirstLoad && cameraState && typeof cameraState === 'object') { - try { - load3d.setCameraState(cameraState) - } catch (error) { - console.warn('Failed to restore camera state:', error) - } - isFirstLoad = false - } - } - } - - const onModelWidgetUpdate = createModelUpdateHandler() - - if (modelWidget.value) { - onModelWidgetUpdate(modelWidget.value) - } - - modelWidget.callback = onModelWidgetUpdate - - material.callback = (value: 'original' | 'normal' | 'wireframe') => { - load3d.setMaterialMode(value) - } - - load3d.setMaterialMode(material.value as 'original' | 'normal' | 'wireframe') - - load3d.setBackgroundColor(bgColor.value as string) - - bgColor.callback = (value: string) => { - load3d.setBackgroundColor(value) - } - - load3d.setLightIntensity(lightIntensity.value as number) - - lightIntensity.callback = (value: number) => { - load3d.setLightIntensity(value) - } - - upDirection.callback = ( - value: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' - ) => { - load3d.setUpDirection(value) - } - - load3d.setUpDirection( - upDirection.value as 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' - ) - - fov.callback = (value: number) => { - load3d.setFOV(value) - } - - load3d.setFOV(fov.value as number) - - const cameraType = load3d.loadNodeProperty('Camera Type', 'perspective') - - load3d.toggleCamera(cameraType) - - const showGrid = load3d.loadNodeProperty('Show Grid', true) - - load3d.toggleGrid(showGrid) -} - app.registerExtension({ name: 'Comfy.Load3D', @@ -279,7 +60,7 @@ app.registerExtension({ const modelWidget = node.widgets?.find( (w: IWidget) => w.name === 'model_file' ) - const uploadPath = await uploadFile( + const uploadPath = await Load3dUtils.uploadFile( load3d, fileInput.files[0], fileInput @@ -356,8 +137,9 @@ app.registerExtension({ let cameraState = node.properties['Camera Info'] - configureLoad3D( - load3d, + const config = new Load3DConfiguration(load3d) + + config.configure( 'input', modelWidget, material, @@ -381,8 +163,8 @@ app.registerExtension({ ) const [data, dataMask] = await Promise.all([ - uploadTempImage(imageData, 'scene'), - uploadTempImage(maskData, 'scene_mask') + Load3dUtils.uploadTempImage(imageData, 'scene'), + Load3dUtils.uploadTempImage(maskData, 'scene_mask') ]) return { @@ -444,7 +226,7 @@ app.registerExtension({ const modelWidget = node.widgets?.find( (w: IWidget) => w.name === 'model_file' ) - const uploadPath = await uploadFile( + const uploadPath = await Load3dUtils.uploadFile( load3d, fileInput.files[0], fileInput @@ -587,8 +369,9 @@ app.registerExtension({ let cameraState = node.properties['Camera Info'] - configureLoad3D( - load3d, + const config = new Load3DConfiguration(load3d) + + config.configure( 'input', modelWidget, material, @@ -627,8 +410,8 @@ app.registerExtension({ ) const [data, dataMask] = await Promise.all([ - uploadTempImage(imageData, 'scene'), - uploadTempImage(maskData, 'scene_mask') + Load3dUtils.uploadTempImage(imageData, 'scene'), + Load3dUtils.uploadTempImage(maskData, 'scene_mask') ]) return { @@ -745,8 +528,185 @@ app.registerExtension({ modelWidget.value = filePath.replaceAll('\\', '/') - configureLoad3D( - load3d, + const config = new Load3DConfiguration(load3d) + + config.configure( + 'output', + modelWidget, + material, + bgColor, + lightIntensity, + upDirection, + fov + ) + } + } +}) + +app.registerExtension({ + name: 'Comfy.Preview3DAnimation', + + async beforeRegisterNodeDef(nodeType, nodeData) { + if ( + // @ts-expect-error ComfyNode + ['Preview3DAnimation'].includes(nodeType.comfyClass) + ) { + nodeData.input.required.image = ['PREVIEW_3D_ANIMATION'] + } + }, + + getCustomWidgets(app) { + return { + PREVIEW_3D_ANIMATION(node, inputName) { + let load3dNode = app.graph._nodes.filter( + (wi) => wi.type == 'Preview3DAnimation' + ) + + const container = document.createElement('div') + container.id = `comfy-preview-3d-animation-${load3dNode.length}` + container.classList.add('comfy-preview-3d-animation') + + const load3d = new Load3dAnimation(container) + + containerToLoad3D.set(container.id, load3d) + + node.onResize = function () { + if (load3d) { + load3d.handleResize() + } + } + + const origOnRemoved = node.onRemoved + + node.onRemoved = function () { + if (load3d) { + load3d.remove() + } + + containerToLoad3D.delete(container.id) + + origOnRemoved?.apply(this, []) + } + + node.onDrawBackground = function () { + load3d.renderer.domElement.hidden = this.flags.collapsed ?? false + } + + node.addWidget( + 'button', + 'Play/Pause Animation', + 'toggle_animation', + () => { + load3d.toggleAnimation() + } + ) + + const animationSelect = node.addWidget( + 'combo', + 'animation', + '', + () => '', + { + values: [] + } + ) as IWidget + + animationSelect.callback = (value: string) => { + const names = load3d.getAnimationNames() + const index = names.indexOf(value) + + if (index !== -1) { + const wasPlaying = load3d.isAnimationPlaying + + if (wasPlaying) { + load3d.toggleAnimation(false) + } + + load3d.updateSelectedAnimation(index) + + if (wasPlaying) { + load3d.toggleAnimation(true) + } + } + } + + return { + widget: node.addDOMWidget( + inputName, + 'PREVIEW_3D_ANIMATION', + container + ) + } + } + } + }, + + async nodeCreated(node) { + if (node.constructor.comfyClass !== 'Preview3DAnimation') return + + const [oldWidth, oldHeight] = node.size + + node.setSize([Math.max(oldWidth, 300), Math.max(oldHeight, 550)]) + + await nextTick() + + const sceneWidget = node.widgets.find((w: IWidget) => w.name === 'image') + + const container = sceneWidget.element + + const load3d = containerToLoad3D.get(container.id) + + load3d.setNode(node) + + const modelWidget = node.widgets.find( + (w: IWidget) => w.name === 'model_file' + ) + + const material = node.widgets.find((w: IWidget) => w.name === 'material') + + const bgColor = node.widgets.find((w: IWidget) => w.name === 'bg_color') + + const lightIntensity = node.widgets.find( + (w: IWidget) => w.name === 'light_intensity' + ) + + const upDirection = node.widgets.find( + (w: IWidget) => w.name === 'up_direction' + ) + + const speedSelect = node.widgets.find( + (w: IWidget) => w.name === 'animation_speed' + ) + + speedSelect.callback = (value: string) => { + const load3d = containerToLoad3D.get(container.id) as Load3dAnimation + if (load3d) { + load3d.setAnimationSpeed(parseFloat(value)) + } + } + + const fov = node.widgets.find((w: IWidget) => w.name === 'fov') + + const onExecuted = node.onExecuted + + node.onExecuted = function (message: any) { + onExecuted?.apply(this, arguments) + + let filePath = message.model_file[0] + + if (!filePath) { + const msg = 'unable to get model file path.' + + console.error(msg) + + useToastStore().addAlert(msg) + } + + modelWidget.value = filePath.replaceAll('\\', '/') + + const config = new Load3DConfiguration(load3d) + + config.configure( 'output', modelWidget, material, diff --git a/src/extensions/core/load3d/Load3DConfiguration.ts b/src/extensions/core/load3d/Load3DConfiguration.ts new file mode 100644 index 000000000..35e9ea834 --- /dev/null +++ b/src/extensions/core/load3d/Load3DConfiguration.ts @@ -0,0 +1,139 @@ +import type { IWidget } from '@comfyorg/litegraph' + +import Load3d from '@/extensions/core/load3d/Load3d' +import Load3dUtils from '@/extensions/core/load3d/Load3dUtils' +import { api } from '@/scripts/api' + +class Load3DConfiguration { + constructor(private load3d: Load3d) {} + + configure( + loadFolder: 'input' | 'output', + modelWidget: IWidget, + material: IWidget, + bgColor: IWidget, + lightIntensity: IWidget, + upDirection: IWidget, + fov: IWidget, + cameraState?: any, + postModelUpdateFunc?: (load3d: Load3d) => void + ) { + this.setupModelHandling( + modelWidget, + loadFolder, + cameraState, + postModelUpdateFunc + ) + this.setupMaterial(material) + this.setupBackground(bgColor) + this.setupLighting(lightIntensity) + this.setupDirection(upDirection) + this.setupCamera(fov) + this.setupDefaultProperties() + } + + private setupModelHandling( + modelWidget: IWidget, + loadFolder: 'input' | 'output', + cameraState?: any, + postModelUpdateFunc?: (load3d: Load3d) => void + ) { + const onModelWidgetUpdate = this.createModelUpdateHandler( + loadFolder, + cameraState, + postModelUpdateFunc + ) + if (modelWidget.value) { + onModelWidgetUpdate(modelWidget.value) + } + modelWidget.callback = onModelWidgetUpdate + } + + private setupMaterial(material: IWidget) { + material.callback = (value: 'original' | 'normal' | 'wireframe') => { + this.load3d.setMaterialMode(value) + } + this.load3d.setMaterialMode( + material.value as 'original' | 'normal' | 'wireframe' + ) + } + + private setupBackground(bgColor: IWidget) { + bgColor.callback = (value: string) => { + this.load3d.setBackgroundColor(value) + } + this.load3d.setBackgroundColor(bgColor.value as string) + } + + private setupLighting(lightIntensity: IWidget) { + lightIntensity.callback = (value: number) => { + this.load3d.setLightIntensity(value) + } + this.load3d.setLightIntensity(lightIntensity.value as number) + } + + private setupDirection(upDirection: IWidget) { + upDirection.callback = ( + value: 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' + ) => { + this.load3d.setUpDirection(value) + } + this.load3d.setUpDirection( + upDirection.value as 'original' | '-x' | '+x' | '-y' | '+y' | '-z' | '+z' + ) + } + + private setupCamera(fov: IWidget) { + fov.callback = (value: number) => { + this.load3d.setFOV(value) + } + this.load3d.setFOV(fov.value as number) + } + + private setupDefaultProperties() { + const cameraType = this.load3d.loadNodeProperty( + 'Camera Type', + 'perspective' + ) + this.load3d.toggleCamera(cameraType) + + const showGrid = this.load3d.loadNodeProperty('Show Grid', true) + this.load3d.toggleGrid(showGrid) + } + + private createModelUpdateHandler( + loadFolder: 'input' | 'output', + cameraState?: any, + postModelUpdateFunc?: (load3d: Load3d) => void + ) { + let isFirstLoad = true + return async (value: string | number | boolean | object) => { + if (!value) return + + const filename = value as string + const modelUrl = api.apiURL( + Load3dUtils.getResourceURL( + ...Load3dUtils.splitFilePath(filename), + loadFolder + ) + ) + + await this.load3d.loadModel(modelUrl, filename) + + if (postModelUpdateFunc) { + postModelUpdateFunc(this.load3d) + } + + if (isFirstLoad && cameraState && typeof cameraState === 'object') { + try { + this.load3d.setCameraState(cameraState) + } catch (error) { + console.warn('Failed to restore camera state:', error) + } + isFirstLoad = false + } + } + } +} + +export default Load3DConfiguration diff --git a/src/extensions/core/load3d/Load3dUtils.ts b/src/extensions/core/load3d/Load3dUtils.ts new file mode 100644 index 000000000..ce0fdb2d8 --- /dev/null +++ b/src/extensions/core/load3d/Load3dUtils.ts @@ -0,0 +1,122 @@ +import Load3d from '@/extensions/core/load3d/Load3d' +import { api } from '@/scripts/api' +import { app } from '@/scripts/app' +import { useToastStore } from '@/stores/toastStore' + +class Load3dUtils { + static async uploadTempImage(imageData: string, prefix: string) { + const blob = await fetch(imageData).then((r) => r.blob()) + const name = `${prefix}_${Date.now()}.png` + const file = new File([blob], name) + + const body = new FormData() + body.append('image', file) + body.append('subfolder', 'threed') + body.append('type', 'temp') + + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + + if (resp.status !== 200) { + const err = `Error uploading temp image: ${resp.status} - ${resp.statusText}` + useToastStore().addAlert(err) + throw new Error(err) + } + + return await resp.json() + } + + static async uploadFile( + load3d: Load3d, + file: File, + fileInput?: HTMLInputElement + ) { + let uploadPath + + try { + const body = new FormData() + body.append('image', file) + body.append('subfolder', '3d') + + const resp = await api.fetchApi('/upload/image', { + method: 'POST', + body + }) + + if (resp.status === 200) { + const data = await resp.json() + let path = data.name + + if (data.subfolder) path = data.subfolder + '/' + path + + uploadPath = path + + const modelUrl = api.apiURL( + this.getResourceURL(...this.splitFilePath(path), 'input') + ) + await load3d.loadModel(modelUrl, file.name) + + const fileExt = file.name.split('.').pop()?.toLowerCase() + if (fileExt === 'obj' && fileInput?.files) { + try { + const mtlFile = Array.from(fileInput.files).find((f) => + f.name.toLowerCase().endsWith('.mtl') + ) + + if (mtlFile) { + const mtlFormData = new FormData() + mtlFormData.append('image', mtlFile) + mtlFormData.append('subfolder', '3d') + + await api.fetchApi('/upload/image', { + method: 'POST', + body: mtlFormData + }) + } + } catch (mtlError) { + console.warn('Failed to upload MTL file:', mtlError) + } + } + } else { + useToastStore().addAlert(resp.status + ' - ' + resp.statusText) + } + } catch (error) { + console.error('Upload error:', error) + useToastStore().addAlert( + error instanceof Error ? error.message : 'Upload failed' + ) + } + + return uploadPath + } + + static splitFilePath(path: string): [string, string] { + const folder_separator = path.lastIndexOf('/') + if (folder_separator === -1) { + return ['', path] + } + return [ + path.substring(0, folder_separator), + path.substring(folder_separator + 1) + ] + } + + static getResourceURL( + subfolder: string, + filename: string, + type: string = 'input' + ): string { + const params = [ + 'filename=' + encodeURIComponent(filename), + 'type=' + type, + 'subfolder=' + subfolder, + app.getRandParam().substring(1) + ].join('&') + + return `/view?${params}` + } +} + +export default Load3dUtils