mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-06-01 20:07:37 +08:00
CORE-217: Add IMAGE/MASK outputs to Preview3D and SaveGLB
This commit is contained in:
parent
03e511862e
commit
5bb76c13b1
116
app/preview3d_bridge.py
Normal file
116
app/preview3d_bridge.py
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
"""Round-trip render bridge for Preview3D / SaveGLB IMAGE+MASK outputs.
|
||||||
|
|
||||||
|
Flow: a node's execute() calls bridge.request_render(), which sends a
|
||||||
|
websocket "preview3d.render_request" event to the client and awaits a
|
||||||
|
Future. The client renders the file with a fixed camera at fixed
|
||||||
|
resolution, uploads the PNGs to /temp/, and POSTs {render_id, image,
|
||||||
|
mask} to /3d/render_response, which resolves the Future. The node then
|
||||||
|
loads image/mask via the standard LoadImage path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import concurrent.futures
|
||||||
|
import uuid
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from aiohttp import web
|
||||||
|
|
||||||
|
|
||||||
|
_RENDER_TIMEOUT_SEC = 60
|
||||||
|
|
||||||
|
|
||||||
|
class Preview3DBridge:
|
||||||
|
"""Server-side half of the Preview3D / SaveGLB render round-trip.
|
||||||
|
|
||||||
|
Owns the pending Future registry and the POST /3d/render_response
|
||||||
|
route. Instantiated once on PromptServer and surfaced as
|
||||||
|
PromptServer.instance.preview3d_bridge.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self._pending: dict[str, concurrent.futures.Future] = {}
|
||||||
|
|
||||||
|
def add_routes(self, routes: web.RouteTableDef) -> None:
|
||||||
|
@routes.post("/3d/render_response")
|
||||||
|
async def render_response(request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
except Exception:
|
||||||
|
return web.Response(status=400, text="invalid json")
|
||||||
|
render_id = data.get("render_id")
|
||||||
|
if not render_id or render_id not in self._pending:
|
||||||
|
return web.Response(status=404, text="unknown render_id")
|
||||||
|
future = self._pending.pop(render_id)
|
||||||
|
if future.done():
|
||||||
|
return web.Response(status=409, text="already resolved")
|
||||||
|
if "error" in data:
|
||||||
|
future.set_exception(RuntimeError(str(data["error"])))
|
||||||
|
else:
|
||||||
|
future.set_result({
|
||||||
|
"image": data.get("image"),
|
||||||
|
"mask": data.get("mask"),
|
||||||
|
})
|
||||||
|
return web.json_response({"ok": True})
|
||||||
|
|
||||||
|
async def request_render(
|
||||||
|
self,
|
||||||
|
node_id: str,
|
||||||
|
file_path: str,
|
||||||
|
file_type: str = "output",
|
||||||
|
camera_info: dict | None = None,
|
||||||
|
timeout: float = _RENDER_TIMEOUT_SEC,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Send a render request and await the client's response.
|
||||||
|
|
||||||
|
node_id should be the executor-provided cls.hidden.unique_id of
|
||||||
|
the requesting Preview3D / SaveGLB node — the client uses it to
|
||||||
|
find the matching viewer instance.
|
||||||
|
"""
|
||||||
|
from server import PromptServer
|
||||||
|
server = PromptServer.instance
|
||||||
|
if server is None:
|
||||||
|
raise RuntimeError("PromptServer is not initialized")
|
||||||
|
|
||||||
|
render_id = uuid.uuid4().hex
|
||||||
|
future: concurrent.futures.Future = concurrent.futures.Future()
|
||||||
|
self._pending[render_id] = future
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"render_id": render_id,
|
||||||
|
"node_id": node_id,
|
||||||
|
"file_path": file_path,
|
||||||
|
"type": file_type,
|
||||||
|
"camera_info": camera_info,
|
||||||
|
}
|
||||||
|
server.send_sync("preview3d.render_request", payload, server.client_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return await asyncio.wait_for(
|
||||||
|
asyncio.wrap_future(future), timeout=timeout
|
||||||
|
)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self._pending.pop(render_id, None)
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Preview3D render bridge: client did not respond in {timeout}s "
|
||||||
|
f"(node_id={node_id}, file={file_path}). Is the workflow open "
|
||||||
|
"in a browser tab?"
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._pending.pop(render_id, None)
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def load_image_and_mask(image_ref: str, mask_ref: str):
|
||||||
|
import nodes
|
||||||
|
loader = nodes.LoadImage()
|
||||||
|
image_tensor, _ = loader.load_image(image=image_ref)
|
||||||
|
_, mask_tensor = loader.load_image(image=mask_ref)
|
||||||
|
return image_tensor, mask_tensor
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"Preview3DBridge",
|
||||||
|
"load_image_and_mask",
|
||||||
|
]
|
||||||
@ -6,6 +6,9 @@ import uuid
|
|||||||
from typing_extensions import override
|
from typing_extensions import override
|
||||||
from comfy_api.latest import IO, UI, ComfyExtension, InputImpl, Types
|
from comfy_api.latest import IO, UI, ComfyExtension, InputImpl, Types
|
||||||
|
|
||||||
|
from app.preview3d_bridge import load_image_and_mask
|
||||||
|
from server import PromptServer
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@ -101,11 +104,15 @@ class Preview3D(IO.ComfyNode):
|
|||||||
IO.Load3DCamera.Input("camera_info", optional=True, advanced=True),
|
IO.Load3DCamera.Input("camera_info", optional=True, advanced=True),
|
||||||
IO.Image.Input("bg_image", optional=True, advanced=True),
|
IO.Image.Input("bg_image", optional=True, advanced=True),
|
||||||
],
|
],
|
||||||
outputs=[],
|
outputs=[
|
||||||
|
IO.Image.Output(display_name="image"),
|
||||||
|
IO.Mask.Output(display_name="mask"),
|
||||||
|
],
|
||||||
|
hidden=[IO.Hidden.unique_id],
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, model_file: str | Types.File3D, **kwargs) -> IO.NodeOutput:
|
async def execute(cls, model_file: str | Types.File3D, **kwargs) -> IO.NodeOutput:
|
||||||
if isinstance(model_file, Types.File3D):
|
if isinstance(model_file, Types.File3D):
|
||||||
filename = f"preview3d_{uuid.uuid4().hex}.{model_file.format}"
|
filename = f"preview3d_{uuid.uuid4().hex}.{model_file.format}"
|
||||||
model_file.save_to(os.path.join(folder_paths.get_output_directory(), filename))
|
model_file.save_to(os.path.join(folder_paths.get_output_directory(), filename))
|
||||||
@ -113,7 +120,20 @@ class Preview3D(IO.ComfyNode):
|
|||||||
filename = model_file
|
filename = model_file
|
||||||
camera_info = kwargs.get("camera_info", None)
|
camera_info = kwargs.get("camera_info", None)
|
||||||
bg_image = kwargs.get("bg_image", None)
|
bg_image = kwargs.get("bg_image", None)
|
||||||
return IO.NodeOutput(ui=UI.PreviewUI3D(filename, camera_info, bg_image=bg_image))
|
|
||||||
|
rendered = await PromptServer.instance.preview3d_bridge.request_render(
|
||||||
|
node_id=cls.hidden.unique_id,
|
||||||
|
file_path=filename,
|
||||||
|
file_type="output",
|
||||||
|
camera_info=camera_info,
|
||||||
|
)
|
||||||
|
image_tensor, mask_tensor = load_image_and_mask(rendered["image"], rendered["mask"])
|
||||||
|
|
||||||
|
return IO.NodeOutput(
|
||||||
|
image_tensor,
|
||||||
|
mask_tensor,
|
||||||
|
ui=UI.PreviewUI3D(filename, camera_info, bg_image=bg_image),
|
||||||
|
)
|
||||||
|
|
||||||
process = execute # TODO: remove
|
process = execute # TODO: remove
|
||||||
|
|
||||||
|
|||||||
@ -14,6 +14,8 @@ from typing_extensions import override
|
|||||||
import folder_paths
|
import folder_paths
|
||||||
from comfy.cli_args import args
|
from comfy.cli_args import args
|
||||||
from comfy_api.latest import ComfyExtension, IO, Types
|
from comfy_api.latest import ComfyExtension, IO, Types
|
||||||
|
from app.preview3d_bridge import load_image_and_mask
|
||||||
|
from server import PromptServer
|
||||||
|
|
||||||
|
|
||||||
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None):
|
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None):
|
||||||
@ -329,12 +331,17 @@ class SaveGLB(IO.ComfyNode):
|
|||||||
tooltip="Mesh or 3D file to save",
|
tooltip="Mesh or 3D file to save",
|
||||||
),
|
),
|
||||||
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
|
IO.String.Input("filename_prefix", default="3d/ComfyUI"),
|
||||||
|
IO.Load3DCamera.Input("camera_info", optional=True, advanced=True),
|
||||||
],
|
],
|
||||||
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo]
|
outputs=[
|
||||||
|
IO.Image.Output(display_name="image"),
|
||||||
|
IO.Mask.Output(display_name="mask"),
|
||||||
|
],
|
||||||
|
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo, IO.Hidden.unique_id]
|
||||||
)
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def execute(cls, mesh: Types.MESH | Types.File3D, filename_prefix: str) -> IO.NodeOutput:
|
async def execute(cls, mesh: Types.MESH | Types.File3D, filename_prefix: str, camera_info=None) -> IO.NodeOutput:
|
||||||
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, folder_paths.get_output_directory())
|
full_output_folder, filename, counter, subfolder, filename_prefix = folder_paths.get_save_image_path(filename_prefix, folder_paths.get_output_directory())
|
||||||
results = []
|
results = []
|
||||||
|
|
||||||
@ -383,7 +390,24 @@ class SaveGLB(IO.ComfyNode):
|
|||||||
"type": "output"
|
"type": "output"
|
||||||
})
|
})
|
||||||
counter += 1
|
counter += 1
|
||||||
return IO.NodeOutput(ui={"3d": results})
|
|
||||||
|
if not results:
|
||||||
|
raise ValueError("SaveGLB: no mesh was written; cannot render image output")
|
||||||
|
|
||||||
|
first = results[0]
|
||||||
|
rel_path = (first["subfolder"] + "/" + first["filename"]) if first["subfolder"] else first["filename"]
|
||||||
|
rendered = await PromptServer.instance.preview3d_bridge.request_render(
|
||||||
|
node_id=cls.hidden.unique_id,
|
||||||
|
file_path=rel_path,
|
||||||
|
file_type="output",
|
||||||
|
camera_info=camera_info,
|
||||||
|
)
|
||||||
|
image_tensor, mask_tensor = load_image_and_mask(rendered["image"], rendered["mask"])
|
||||||
|
|
||||||
|
ui_out: dict = {"3d": results}
|
||||||
|
if camera_info is not None:
|
||||||
|
ui_out["camera_info"] = [camera_info]
|
||||||
|
return IO.NodeOutput(image_tensor, mask_tensor, ui=ui_out)
|
||||||
|
|
||||||
|
|
||||||
class Save3DExtension(ComfyExtension):
|
class Save3DExtension(ComfyExtension):
|
||||||
|
|||||||
@ -44,6 +44,7 @@ from app.model_manager import ModelFileManager
|
|||||||
from app.custom_node_manager import CustomNodeManager
|
from app.custom_node_manager import CustomNodeManager
|
||||||
from app.subgraph_manager import SubgraphManager
|
from app.subgraph_manager import SubgraphManager
|
||||||
from app.node_replace_manager import NodeReplaceManager
|
from app.node_replace_manager import NodeReplaceManager
|
||||||
|
from app.preview3d_bridge import Preview3DBridge
|
||||||
from typing import Optional, Union
|
from typing import Optional, Union
|
||||||
from api_server.routes.internal.internal_routes import InternalRoutes
|
from api_server.routes.internal.internal_routes import InternalRoutes
|
||||||
from protocol import BinaryEventTypes
|
from protocol import BinaryEventTypes
|
||||||
@ -209,6 +210,7 @@ class PromptServer():
|
|||||||
self.custom_node_manager = CustomNodeManager()
|
self.custom_node_manager = CustomNodeManager()
|
||||||
self.subgraph_manager = SubgraphManager()
|
self.subgraph_manager = SubgraphManager()
|
||||||
self.node_replace_manager = NodeReplaceManager()
|
self.node_replace_manager = NodeReplaceManager()
|
||||||
|
self.preview3d_bridge = Preview3DBridge()
|
||||||
self.internal_routes = InternalRoutes(self)
|
self.internal_routes = InternalRoutes(self)
|
||||||
self.supports = ["custom_nodes_from_web"]
|
self.supports = ["custom_nodes_from_web"]
|
||||||
self.prompt_queue = execution.PromptQueue(self)
|
self.prompt_queue = execution.PromptQueue(self)
|
||||||
@ -1050,6 +1052,7 @@ class PromptServer():
|
|||||||
self.custom_node_manager.add_routes(self.routes, self.app, nodes.LOADED_MODULE_DIRS.items())
|
self.custom_node_manager.add_routes(self.routes, self.app, nodes.LOADED_MODULE_DIRS.items())
|
||||||
self.subgraph_manager.add_routes(self.routes, nodes.LOADED_MODULE_DIRS.items())
|
self.subgraph_manager.add_routes(self.routes, nodes.LOADED_MODULE_DIRS.items())
|
||||||
self.node_replace_manager.add_routes(self.routes)
|
self.node_replace_manager.add_routes(self.routes)
|
||||||
|
self.preview3d_bridge.add_routes(self.routes)
|
||||||
self.app.add_subapp('/internal', self.internal_routes.get_app())
|
self.app.add_subapp('/internal', self.internal_routes.get_app())
|
||||||
|
|
||||||
# Prefix every route with /api for easier matching for delegation.
|
# Prefix every route with /api for easier matching for delegation.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user