Compare commits

...

30 Commits

Author SHA1 Message Date
B. Bergeron
d2910852cd
Merge fa71050a07 into 3be0175166 2026-02-04 00:23:01 +01:00
comfyanonymous
3be0175166 ComfyUI v0.12.1 2026-02-03 15:01:46 -05:00
comfyanonymous
b8315e66cb
Fix tiled vae for ace step 1.5 (#12253) 2026-02-03 14:40:45 -05:00
comfyanonymous
ab1050bec3
Support ace step 1.5 base model loras. (#12252) 2026-02-03 13:54:23 -05:00
Alexander Piskun
fb23935c11
feat(comfy_api): add basic 3D Model file types (#12129)
* feat(comfy_api): add basic 3D Model file types

* update Tripo nodes to use File3DGLB

* update Rodin3D nodes to use File3DGLB

* address PR review feedback:

- Rename File3D parameter 'path' to 'source'
- Convert File3D.data property to get_data()
- Make .glb extension check case-insensitive in nodes_rodin.py
- Restrict SaveGLB node to only accept File3DGLB

* Fixed a bug in the Meshy Rig and Animation nodes

* Fix backward compatability
2026-02-03 10:31:46 -08:00
comfyanonymous
85fc35e8fa
Fix mac issue. (#12250)
Some checks are pending
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
2026-02-03 12:19:39 -05:00
comfyanonymous
223364743c
llama: cast logits as a comfy-weight (#12248)
This is using a different layers weight with .to(). Change it to use
the ops caster if the original layer is a comfy weight so that it picks
up dynamic_vram and async_offload functionality in full.

Co-authored-by: Rattus <rattus128@gmail.com>
2026-02-03 11:31:36 -05:00
comfyanonymous
affe881354
Fix some issues with mac. (#12247) 2026-02-03 11:07:04 -05:00
comfyanonymous
f5030e26fd
Add progress bar to ace step. (#12242)
Some checks failed
Python Linting / Run Ruff (push) Waiting to run
Python Linting / Run Pylint (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.10, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.11, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-stable (12.1, , linux, 3.12, [self-hosted Linux], stable) (push) Waiting to run
Full Comfy CI Workflow Runs / test-unix-nightly (12.1, , linux, 3.11, [self-hosted Linux], nightly) (push) Waiting to run
Execution Tests / test (macos-latest) (push) Waiting to run
Execution Tests / test (ubuntu-latest) (push) Waiting to run
Execution Tests / test (windows-latest) (push) Waiting to run
Test server launches without errors / test (push) Waiting to run
Unit Tests / test (macos-latest) (push) Waiting to run
Unit Tests / test (ubuntu-latest) (push) Waiting to run
Unit Tests / test (windows-2022) (push) Waiting to run
Build package / Build Test (3.10) (push) Has been cancelled
Build package / Build Test (3.11) (push) Has been cancelled
Build package / Build Test (3.12) (push) Has been cancelled
Build package / Build Test (3.13) (push) Has been cancelled
Build package / Build Test (3.14) (push) Has been cancelled
2026-02-03 04:09:30 -05:00
B. Bergeron
fa71050a07
Split data volume 2026-01-10 19:03:47 -05:00
B. Bergeron
7795a4e86c
Improve documentation regarding pip install step 2026-01-10 18:55:37 -05:00
B. Bergeron
c4c388ffc8
Improve documentation regarding numerical ownership 2026-01-10 18:55:37 -05:00
B. Bergeron
c804c0c12e
Always update Python dependencies + don't hide pip logs 2026-01-10 18:55:37 -05:00
B. Bergeron
6c9110564b
Pin base image version to 3.12.12-trixie + document version choice 2026-01-10 16:58:21 -05:00
B. Bergeron
b4dcbdfac7
Remove unused "AS" docker statement 2026-01-10 14:02:52 -05:00
B. Bergeron
2c859e9558
Remove superfluous interface binding 2026-01-10 14:02:52 -05:00
B. Bergeron
174d91c9ed
Improved documentation 2026-01-10 14:02:52 -05:00
B. Bergeron
e1cf4f7420
Don't rebuild whole image when APT_EXTRA_PACKAGES changes 2026-01-10 14:02:51 -05:00
B. Bergeron
357f89a4bf
Fix permission issue on legacy builds 2026-01-10 14:02:51 -05:00
B. Bergeron
477f330415
Use stable apt-get CLI interface instead of apt 2026-01-10 14:02:51 -05:00
B. Bergeron
36e19df686
Use recommended compose file name for Docker Compose 2026-01-10 14:02:51 -05:00
B. Bergeron
aba97d6ada
Add @bbergeron0 to CODEOWNER 2026-01-10 14:02:51 -05:00
B. Bergeron
7419345b76
Remove superfluous command-separators 2026-01-10 14:02:51 -05:00
B. Bergeron
41b4c3ea73
Force LF eol for entrypoint.sh 2026-01-10 14:02:51 -05:00
B. Bergeron
5b27c661c6
Update ownership of /comfyui to comfyui user 2026-01-10 14:02:51 -05:00
B. Bergeron
6572cbb61d
Inform user that installation might take a while 2026-01-10 14:02:50 -05:00
B. Bergeron
4f12985e45
Install extra system dependencies at build-time 2026-01-10 14:02:50 -05:00
B. Bergeron
847e3cc3a2
Persist models installed by model managers 2026-01-10 14:02:50 -05:00
B. Bergeron
e7ebda4b61
Add instructions for Docker installation 2026-01-10 14:02:48 -05:00
B. Bergeron
eeee0f5b1b
Add local Docker support 2026-01-10 14:00:21 -05:00
26 changed files with 720 additions and 171 deletions

31
.dockerignore Normal file
View File

@ -0,0 +1,31 @@
# This file should remain in sync with .gitignore. If you need to make changes,
# please add a comment explaining why. For items that must be removed, comment
# them out instead of deleting them.
__pycache__/
*.py[cod]
/output/
/input/
# This file prevents the image from building and would be overwritten by the
# /data volume in any case.
#!/input/example.png
/models/
/temp/
/custom_nodes/
!custom_nodes/example_node.py.example
extra_model_paths.yaml
/.vs
.vscode/
.idea/
venv/
.venv/
/web/extensions/*
!/web/extensions/logging.js.example
!/web/extensions/core/
/tests-ui/data/object_info.json
/user/
*.log
web_custom_versions/
.DS_Store
openapi.yaml
filtered-openapi.yaml
uv.lock

3
.gitattributes vendored
View File

@ -1,3 +1,6 @@
/web/assets/** linguist-generated /web/assets/** linguist-generated
/web/** linguist-vendored /web/** linguist-vendored
comfy_api_nodes/apis/__init__.py linguist-generated comfy_api_nodes/apis/__init__.py linguist-generated
# Force LF eol for Docker entrypoint (fix "exec: no such file or directory"
# error with CRLF checkouts)
entrypoint.sh text eol=lf

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
# If you modify this file, remember to update .dockerignore as well.
__pycache__/ __pycache__/
*.py[cod] *.py[cod]
/output/ /output/

85
Dockerfile Normal file
View File

@ -0,0 +1,85 @@
# Docker buildfile for the ComfyUI image, with support for hardware
# acceleration, file ownership synchronization, custom nodes, and custom node
# managers.
# While Python 3.13 is well supported by ComfyUI, some older custom node packs
# may not work correctly with this version, which is why we're staying on Python
# 3.12 for now.
#
# Users are free to try different base Python image tags (e.g., 3.13, alpine,
# *-slim), but for maintainability, only one base version is officially
# supported at a time.
FROM python:3.12.12-trixie
# Install cmake, which is an indirect installation dependencies
RUN apt-get update && apt-get install -y --no-install-recommends cmake
# Create a regular user whose UID and GID will match the host user's at runtime.
# Also create a home directory for this user (-m), as some common Python tools
# (such as uv) interact with the users home directory.
RUN useradd -m comfyui
# Install ComfyUI under /comfyui and set folder ownership to the comfyui user.
# With the legacy Docker builder (DOCKER_BUILDKIT=0), WORKDIR always creates missing
# directories as root (even if a different USER is active). To ensure the comfyui user
# can write inside, ownership must be fixed manually.
WORKDIR /comfyui
RUN chown comfyui:comfyui .
# Install ComfyUI as ComfyUI
USER comfyui
# Set up a Python virtual environment and configure it as the default Python.
#
# Reasons for using a virtual environment:
# - Some custom nodes use third-party tools like uv, which do not support
# user-level installations.
# - Custom node managers may install or update dependencies as the regular user,
# so a global installation is not an option.
# This leaves virtual environments as the only viable choice.
RUN python -m venv .venv
ENV PATH="/comfyui/.venv/bin:$PATH"
# Install ComfyUI's Python dependencies. Although dependency keeping is also
# performed at startup, building ComfyUI's base dependencies into the image
# significantly speeds up each containers' first run.
#
# Since this step takes a long time to complete, it's performed early to take
# advantage of Docker's build cache, thereby accelerating subsequent builds.
COPY requirements.txt manager_requirements.txt ./
RUN pip install --no-cache-dir --disable-pip-version-check \
-r requirements.txt
# Install ComfyUI
COPY . .
# Purely declarative: inform Docker and image users that this image is designed
# to listen on port 8188 for the web GUI.
EXPOSE 8188
# Declare persistent volumes. We assign one volume per data directory to match
# ComfyUIs natural file layout and to let users selectively choose which
# directories they want to mount.
VOLUME /comfyui/.venv
VOLUME /comfyui/custom_nodes
VOLUME /comfyui/input
VOLUME /comfyui/models
VOLUME /comfyui/output
VOLUME /comfyui/temp
VOLUME /comfyui/user
VOLUME /home/comfyui
# Switch back to root to run the entrypoint and to install additional system
# dependencies
USER root
# Configure entrypoint
RUN chmod +x entrypoint.sh
ENTRYPOINT [ "./entrypoint.sh" ]
CMD [ "python", "./main.py" ]
# Install additional system dependencies
ARG APT_EXTRA_PACKAGES
RUN apt-get install -y --no-install-recommends $APT_EXTRA_PACKAGES \
&& apt-get clean \
&& rm -rf /var/lib/apt/lists/*

View File

@ -46,6 +46,12 @@ ComfyUI lets you design and execute advanced stable diffusion pipelines using a
- Get the latest commits and completely portable. - Get the latest commits and completely portable.
- Available on Windows. - Available on Windows.
#### [Docker Install](#running-with-docker)
- Run ComfyUI inside an isolated Docker container
- Most secure way to run ComfyUI and custom node packs
- Requires Docker and Docker Compose
- Supports NVIDIA GPUs (Not tested on other hardware.)
#### [Manual Install](#manual-install-windows-linux) #### [Manual Install](#manual-install-windows-linux)
Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend). Supports all operating systems and GPU types (NVIDIA, AMD, Intel, Apple Silicon, Ascend).
@ -350,6 +356,28 @@ For models compatible with Iluvatar Extension for PyTorch. Here's a step-by-step
| `--enable-manager-legacy-ui` | Use the legacy manager UI instead of the new UI (requires `--enable-manager`) | | `--enable-manager-legacy-ui` | Use the legacy manager UI instead of the new UI (requires `--enable-manager`) |
| `--disable-manager-ui` | Disable the manager UI and endpoints while keeping background features like security checks and scheduled installation completion (requires `--enable-manager`) | | `--disable-manager-ui` | Disable the manager UI and endpoints while keeping background features like security checks and scheduled installation completion (requires `--enable-manager`) |
## Running with Docker
Start by installing Docker, Docker Compose, and the NVIDIA Container Toolkit on
your host. Next, edit `compose.yaml` and update the `UID` and `GID` variables to
match your user. Additional fields are documented in the file for further
customization.
Once ready, build and run the image locally:
```shell
# (Re)build the Docker image. Run this before the first start, after updating
# ComfyUI, or after changing any build arguments in `compose.yaml`.
docker compose build
# Start ComfyUI. This reuses the most recently built image.
docker compose up
```
To stop and remove the container along with its volumes, run:
```shell
docker compose down -v
```
# Running # Running

View File

@ -332,6 +332,12 @@ def model_lora_keys_unet(model, key_map={}):
key_map["{}".format(key_lora)] = k key_map["{}".format(key_lora)] = k
key_map["transformer.{}".format(key_lora)] = k key_map["transformer.{}".format(key_lora)] = k
if isinstance(model, comfy.model_base.ACEStep15):
for k in sdk:
if k.startswith("diffusion_model.decoder.") and k.endswith(".weight"):
key_lora = k[len("diffusion_model.decoder."):-len(".weight")]
key_map["base_model.model.{}".format(key_lora)] = k # Official base model loras
return key_map return key_map

View File

@ -554,6 +554,8 @@ class VAE:
elif "decoder.layers.1.layers.0.beta" in sd: elif "decoder.layers.1.layers.0.beta" in sd:
config = {} config = {}
param_key = None param_key = None
self.upscale_ratio = 2048
self.downscale_ratio = 2048
if "decoder.layers.2.layers.1.weight_v" in sd: if "decoder.layers.2.layers.1.weight_v" in sd:
param_key = "decoder.layers.2.layers.1.weight_v" param_key = "decoder.layers.2.layers.1.weight_v"
if "decoder.layers.2.layers.1.parametrizations.weight.original1" in sd: if "decoder.layers.2.layers.1.parametrizations.weight.original1" in sd:
@ -562,6 +564,8 @@ class VAE:
if sd[param_key].shape[-1] == 12: if sd[param_key].shape[-1] == 12:
config["strides"] = [2, 4, 4, 6, 10] config["strides"] = [2, 4, 4, 6, 10]
self.audio_sample_rate = 48000 self.audio_sample_rate = 48000
self.upscale_ratio = 1920
self.downscale_ratio = 1920
self.first_stage_model = AudioOobleckVAE(**config) self.first_stage_model = AudioOobleckVAE(**config)
self.memory_used_encode = lambda shape, dtype: (1000 * shape[2]) * model_management.dtype_size(dtype) self.memory_used_encode = lambda shape, dtype: (1000 * shape[2]) * model_management.dtype_size(dtype)
@ -569,8 +573,6 @@ class VAE:
self.latent_channels = 64 self.latent_channels = 64
self.output_channels = 2 self.output_channels = 2
self.pad_channel_value = "replicate" self.pad_channel_value = "replicate"
self.upscale_ratio = 2048
self.downscale_ratio = 2048
self.latent_dim = 1 self.latent_dim = 1
self.process_output = lambda audio: audio self.process_output = lambda audio: audio
self.process_input = lambda audio: audio self.process_input = lambda audio: audio
@ -870,7 +872,7 @@ class VAE:
/ 3.0) / 3.0)
return output return output
def decode_tiled_1d(self, samples, tile_x=128, overlap=32): def decode_tiled_1d(self, samples, tile_x=256, overlap=32):
if samples.ndim == 3: if samples.ndim == 3:
decode_fn = lambda a: self.first_stage_model.decode(a.to(self.vae_dtype).to(self.device)).float() decode_fn = lambda a: self.first_stage_model.decode(a.to(self.vae_dtype).to(self.device)).float()
else: else:

View File

@ -3,6 +3,7 @@ import comfy.text_encoders.llama
from comfy import sd1_clip from comfy import sd1_clip
import torch import torch
import math import math
import comfy.utils
def sample_manual_loop_no_classes( def sample_manual_loop_no_classes(
@ -42,6 +43,8 @@ def sample_manual_loop_no_classes(
for x in range(model_config.num_hidden_layers): for x in range(model_config.num_hidden_layers):
past_key_values.append((torch.empty([embeds.shape[0], model_config.num_key_value_heads, embeds.shape[1] + min_tokens, model_config.head_dim], device=device, dtype=execution_dtype), torch.empty([embeds.shape[0], model_config.num_key_value_heads, embeds.shape[1] + min_tokens, model_config.head_dim], device=device, dtype=execution_dtype), 0)) past_key_values.append((torch.empty([embeds.shape[0], model_config.num_key_value_heads, embeds.shape[1] + min_tokens, model_config.head_dim], device=device, dtype=execution_dtype), torch.empty([embeds.shape[0], model_config.num_key_value_heads, embeds.shape[1] + min_tokens, model_config.head_dim], device=device, dtype=execution_dtype), 0))
progress_bar = comfy.utils.ProgressBar(max_new_tokens)
for step in range(max_new_tokens): for step in range(max_new_tokens):
outputs = model.transformer(None, attention_mask, embeds=embeds.to(execution_dtype), num_tokens=num_tokens, intermediate_output=None, dtype=execution_dtype, embeds_info=embeds_info, past_key_values=past_key_values) outputs = model.transformer(None, attention_mask, embeds=embeds.to(execution_dtype), num_tokens=num_tokens, intermediate_output=None, dtype=execution_dtype, embeds_info=embeds_info, past_key_values=past_key_values)
next_token_logits = model.transformer.logits(outputs[0])[:, -1] next_token_logits = model.transformer.logits(outputs[0])[:, -1]
@ -54,8 +57,9 @@ def sample_manual_loop_no_classes(
if eos_token_id is not None and eos_token_id < audio_start_id and min_tokens < step: if eos_token_id is not None and eos_token_id < audio_start_id and min_tokens < step:
eos_score = cfg_logits[:, eos_token_id].clone() eos_score = cfg_logits[:, eos_token_id].clone()
remove_logit_value = torch.finfo(cfg_logits.dtype).min
# Only generate audio tokens # Only generate audio tokens
cfg_logits[:, :audio_start_id] = float('-inf') cfg_logits[:, :audio_start_id] = remove_logit_value
if eos_token_id is not None and eos_token_id < audio_start_id and min_tokens < step: if eos_token_id is not None and eos_token_id < audio_start_id and min_tokens < step:
cfg_logits[:, eos_token_id] = eos_score cfg_logits[:, eos_token_id] = eos_score
@ -63,7 +67,7 @@ def sample_manual_loop_no_classes(
if top_k is not None and top_k > 0: if top_k is not None and top_k > 0:
top_k_vals, _ = torch.topk(cfg_logits, top_k) top_k_vals, _ = torch.topk(cfg_logits, top_k)
min_val = top_k_vals[..., -1, None] min_val = top_k_vals[..., -1, None]
cfg_logits[cfg_logits < min_val] = float('-inf') cfg_logits[cfg_logits < min_val] = remove_logit_value
if top_p is not None and top_p < 1.0: if top_p is not None and top_p < 1.0:
sorted_logits, sorted_indices = torch.sort(cfg_logits, descending=True) sorted_logits, sorted_indices = torch.sort(cfg_logits, descending=True)
@ -72,7 +76,7 @@ def sample_manual_loop_no_classes(
sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone() sorted_indices_to_remove[..., 1:] = sorted_indices_to_remove[..., :-1].clone()
sorted_indices_to_remove[..., 0] = 0 sorted_indices_to_remove[..., 0] = 0
indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove) indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove)
cfg_logits[indices_to_remove] = float('-inf') cfg_logits[indices_to_remove] = remove_logit_value
if temperature > 0: if temperature > 0:
cfg_logits = cfg_logits / temperature cfg_logits = cfg_logits / temperature
@ -90,6 +94,7 @@ def sample_manual_loop_no_classes(
attention_mask = torch.cat([attention_mask, torch.ones((2, 1), device=device, dtype=attention_mask.dtype)], dim=1) attention_mask = torch.cat([attention_mask, torch.ones((2, 1), device=device, dtype=attention_mask.dtype)], dim=1)
output_audio_codes.append(token - audio_start_id) output_audio_codes.append(token - audio_start_id)
progress_bar.update_absolute(step)
return output_audio_codes return output_audio_codes

View File

@ -6,6 +6,7 @@ import math
from comfy.ldm.modules.attention import optimized_attention_for_device from comfy.ldm.modules.attention import optimized_attention_for_device
import comfy.model_management import comfy.model_management
import comfy.ops
import comfy.ldm.common_dit import comfy.ldm.common_dit
import comfy.clip_model import comfy.clip_model
@ -627,10 +628,10 @@ class Llama2_(nn.Module):
mask = None mask = None
if attention_mask is not None: if attention_mask is not None:
mask = 1.0 - attention_mask.to(x.dtype).reshape((attention_mask.shape[0], 1, -1, attention_mask.shape[-1])).expand(attention_mask.shape[0], 1, seq_len, attention_mask.shape[-1]) mask = 1.0 - attention_mask.to(x.dtype).reshape((attention_mask.shape[0], 1, -1, attention_mask.shape[-1])).expand(attention_mask.shape[0], 1, seq_len, attention_mask.shape[-1])
mask = mask.masked_fill(mask.to(torch.bool), float("-inf")) mask = mask.masked_fill(mask.to(torch.bool), torch.finfo(x.dtype).min)
if seq_len > 1: if seq_len > 1:
causal_mask = torch.empty(past_len + seq_len, past_len + seq_len, dtype=x.dtype, device=x.device).fill_(float("-inf")).triu_(1) causal_mask = torch.empty(past_len + seq_len, past_len + seq_len, dtype=x.dtype, device=x.device).fill_(torch.finfo(x.dtype).min).triu_(1)
if mask is not None: if mask is not None:
mask += causal_mask mask += causal_mask
else: else:
@ -794,7 +795,19 @@ class Qwen3_2B_ACE15_lm(BaseLlama, torch.nn.Module):
self.dtype = dtype self.dtype = dtype
def logits(self, x): def logits(self, x):
return torch.nn.functional.linear(x[:, -1:], self.model.embed_tokens.weight.to(x), None) input = x[:, -1:]
module = self.model.embed_tokens
offload_stream = None
if module.comfy_cast_weights:
weight, _, offload_stream = comfy.ops.cast_bias_weight(module, input, offloadable=True)
else:
weight = self.model.embed_tokens.weight.to(x)
x = torch.nn.functional.linear(input, weight, None)
comfy.ops.uncast_bias_weight(module, weight, None, offload_stream)
return x
class Qwen3_4B(BaseLlama, torch.nn.Module): class Qwen3_4B(BaseLlama, torch.nn.Module):
def __init__(self, config_dict, dtype, device, operations): def __init__(self, config_dict, dtype, device, operations):

View File

@ -7,7 +7,7 @@ from comfy_api.internal.singleton import ProxiedSingleton
from comfy_api.internal.async_to_sync import create_sync_class from comfy_api.internal.async_to_sync import create_sync_class
from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput from ._input import ImageInput, AudioInput, MaskInput, LatentInput, VideoInput
from ._input_impl import VideoFromFile, VideoFromComponents from ._input_impl import VideoFromFile, VideoFromComponents
from ._util import VideoCodec, VideoContainer, VideoComponents, MESH, VOXEL from ._util import VideoCodec, VideoContainer, VideoComponents, MESH, VOXEL, File3D
from . import _io_public as io from . import _io_public as io
from . import _ui_public as ui from . import _ui_public as ui
from comfy_execution.utils import get_executing_context from comfy_execution.utils import get_executing_context
@ -105,6 +105,7 @@ class Types:
VideoComponents = VideoComponents VideoComponents = VideoComponents
MESH = MESH MESH = MESH
VOXEL = VOXEL VOXEL = VOXEL
File3D = File3D
ComfyAPI = ComfyAPI_latest ComfyAPI = ComfyAPI_latest

View File

@ -27,7 +27,7 @@ if TYPE_CHECKING:
from comfy_api.internal import (_ComfyNodeInternal, _NodeOutputInternal, classproperty, copy_class, first_real_override, is_class, from comfy_api.internal import (_ComfyNodeInternal, _NodeOutputInternal, classproperty, copy_class, first_real_override, is_class,
prune_dict, shallow_clone_class) prune_dict, shallow_clone_class)
from comfy_execution.graph_utils import ExecutionBlocker from comfy_execution.graph_utils import ExecutionBlocker
from ._util import MESH, VOXEL, SVG as _SVG from ._util import MESH, VOXEL, SVG as _SVG, File3D
class FolderType(str, Enum): class FolderType(str, Enum):
@ -667,6 +667,49 @@ class Voxel(ComfyTypeIO):
class Mesh(ComfyTypeIO): class Mesh(ComfyTypeIO):
Type = MESH Type = MESH
@comfytype(io_type="FILE_3D")
class File3DAny(ComfyTypeIO):
"""General 3D file type - accepts any supported 3D format."""
Type = File3D
@comfytype(io_type="FILE_3D_GLB")
class File3DGLB(ComfyTypeIO):
"""GLB format 3D file - binary glTF, best for web and cross-platform."""
Type = File3D
@comfytype(io_type="FILE_3D_GLTF")
class File3DGLTF(ComfyTypeIO):
"""GLTF format 3D file - JSON-based glTF with external resources."""
Type = File3D
@comfytype(io_type="FILE_3D_FBX")
class File3DFBX(ComfyTypeIO):
"""FBX format 3D file - best for game engines and animation."""
Type = File3D
@comfytype(io_type="FILE_3D_OBJ")
class File3DOBJ(ComfyTypeIO):
"""OBJ format 3D file - simple geometry format."""
Type = File3D
@comfytype(io_type="FILE_3D_STL")
class File3DSTL(ComfyTypeIO):
"""STL format 3D file - best for 3D printing."""
Type = File3D
@comfytype(io_type="FILE_3D_USDZ")
class File3DUSDZ(ComfyTypeIO):
"""USDZ format 3D file - Apple AR format."""
Type = File3D
@comfytype(io_type="HOOKS") @comfytype(io_type="HOOKS")
class Hooks(ComfyTypeIO): class Hooks(ComfyTypeIO):
if TYPE_CHECKING: if TYPE_CHECKING:
@ -2037,6 +2080,13 @@ __all__ = [
"LossMap", "LossMap",
"Voxel", "Voxel",
"Mesh", "Mesh",
"File3DAny",
"File3DGLB",
"File3DGLTF",
"File3DFBX",
"File3DOBJ",
"File3DSTL",
"File3DUSDZ",
"Hooks", "Hooks",
"HookKeyframes", "HookKeyframes",
"TimestepsRange", "TimestepsRange",

View File

@ -1,5 +1,5 @@
from .video_types import VideoContainer, VideoCodec, VideoComponents from .video_types import VideoContainer, VideoCodec, VideoComponents
from .geometry_types import VOXEL, MESH from .geometry_types import VOXEL, MESH, File3D
from .image_types import SVG from .image_types import SVG
__all__ = [ __all__ = [
@ -9,5 +9,6 @@ __all__ = [
"VideoComponents", "VideoComponents",
"VOXEL", "VOXEL",
"MESH", "MESH",
"File3D",
"SVG", "SVG",
] ]

View File

@ -1,3 +1,8 @@
import shutil
from io import BytesIO
from pathlib import Path
from typing import IO
import torch import torch
@ -10,3 +15,75 @@ class MESH:
def __init__(self, vertices: torch.Tensor, faces: torch.Tensor): def __init__(self, vertices: torch.Tensor, faces: torch.Tensor):
self.vertices = vertices self.vertices = vertices
self.faces = faces self.faces = faces
class File3D:
"""Class representing a 3D file from a file path or binary stream.
Supports both disk-backed (file path) and memory-backed (BytesIO) storage.
"""
def __init__(self, source: str | IO[bytes], file_format: str = ""):
self._source = source
self._format = file_format or self._infer_format()
def _infer_format(self) -> str:
if isinstance(self._source, str):
return Path(self._source).suffix.lstrip(".").lower()
return ""
@property
def format(self) -> str:
return self._format
@format.setter
def format(self, value: str) -> None:
self._format = value.lstrip(".").lower() if value else ""
@property
def is_disk_backed(self) -> bool:
return isinstance(self._source, str)
def get_source(self) -> str | IO[bytes]:
if isinstance(self._source, str):
return self._source
if hasattr(self._source, "seek"):
self._source.seek(0)
return self._source
def get_data(self) -> BytesIO:
if isinstance(self._source, str):
with open(self._source, "rb") as f:
result = BytesIO(f.read())
return result
if hasattr(self._source, "seek"):
self._source.seek(0)
if isinstance(self._source, BytesIO):
return self._source
return BytesIO(self._source.read())
def save_to(self, path: str) -> str:
dest = Path(path)
dest.parent.mkdir(parents=True, exist_ok=True)
if isinstance(self._source, str):
if Path(self._source).resolve() != dest.resolve():
shutil.copy2(self._source, dest)
else:
if hasattr(self._source, "seek"):
self._source.seek(0)
with open(dest, "wb") as f:
f.write(self._source.read())
return str(dest)
def get_bytes(self) -> bytes:
if isinstance(self._source, str):
return Path(self._source).read_bytes()
if hasattr(self._source, "seek"):
self._source.seek(0)
return self._source.read()
def __repr__(self) -> str:
if isinstance(self._source, str):
return f"File3D(source={self._source!r}, format={self._format!r})"
return f"File3D(<stream>, format={self._format!r})"

View File

@ -109,14 +109,19 @@ class MeshyTextureRequest(BaseModel):
class MeshyModelsUrls(BaseModel): class MeshyModelsUrls(BaseModel):
glb: str = Field("") glb: str = Field("")
fbx: str = Field("")
usdz: str = Field("")
obj: str = Field("")
class MeshyRiggedModelsUrls(BaseModel): class MeshyRiggedModelsUrls(BaseModel):
rigged_character_glb_url: str = Field("") rigged_character_glb_url: str = Field("")
rigged_character_fbx_url: str = Field("")
class MeshyAnimatedModelsUrls(BaseModel): class MeshyAnimatedModelsUrls(BaseModel):
animation_glb_url: str = Field("") animation_glb_url: str = Field("")
animation_fbx_url: str = Field("")
class MeshyResultTextureUrls(BaseModel): class MeshyResultTextureUrls(BaseModel):

View File

@ -1,5 +1,3 @@
import os
from typing_extensions import override from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input from comfy_api.latest import IO, ComfyExtension, Input
@ -14,7 +12,7 @@ from comfy_api_nodes.apis.hunyuan3d import (
) )
from comfy_api_nodes.util import ( from comfy_api_nodes.util import (
ApiEndpoint, ApiEndpoint,
download_url_to_bytesio, download_url_to_file_3d,
downscale_image_tensor_by_max_side, downscale_image_tensor_by_max_side,
poll_op, poll_op,
sync_op, sync_op,
@ -22,14 +20,13 @@ from comfy_api_nodes.util import (
validate_image_dimensions, validate_image_dimensions,
validate_string, validate_string,
) )
from folder_paths import get_output_directory
def get_glb_obj_from_response(response_objs: list[ResultFile3D]) -> ResultFile3D: def get_file_from_response(response_objs: list[ResultFile3D], file_type: str) -> ResultFile3D | None:
for i in response_objs: for i in response_objs:
if i.Type.lower() == "glb": if i.Type.lower() == file_type.lower():
return i return i
raise ValueError("No GLB file found in response. Please report this to the developers.") return None
class TencentTextToModelNode(IO.ComfyNode): class TencentTextToModelNode(IO.ComfyNode):
@ -74,7 +71,9 @@ class TencentTextToModelNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DOBJ.Output(display_name="OBJ"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -124,19 +123,20 @@ class TencentTextToModelNode(IO.ComfyNode):
) )
if response.Error: if response.Error:
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}") raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
task_id = response.JobId
result = await poll_op( result = await poll_op(
cls, cls,
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"), ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"),
data=To3DProTaskQueryRequest(JobId=response.JobId), data=To3DProTaskQueryRequest(JobId=task_id),
response_model=To3DProTaskResultResponse, response_model=To3DProTaskResultResponse,
status_extractor=lambda r: r.Status, status_extractor=lambda r: r.Status,
) )
model_file = f"hunyuan_model_{response.JobId}.glb" glb_result = get_file_from_response(result.ResultFile3Ds, "glb")
await download_url_to_bytesio( obj_result = get_file_from_response(result.ResultFile3Ds, "obj")
get_glb_obj_from_response(result.ResultFile3Ds).Url, file_glb = await download_url_to_file_3d(glb_result.Url, "glb", task_id=task_id) if glb_result else None
os.path.join(get_output_directory(), model_file), return IO.NodeOutput(
file_glb, file_glb, await download_url_to_file_3d(obj_result.Url, "obj", task_id=task_id) if obj_result else None
) )
return IO.NodeOutput(model_file)
class TencentImageToModelNode(IO.ComfyNode): class TencentImageToModelNode(IO.ComfyNode):
@ -184,7 +184,9 @@ class TencentImageToModelNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DOBJ.Output(display_name="OBJ"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -269,19 +271,20 @@ class TencentImageToModelNode(IO.ComfyNode):
) )
if response.Error: if response.Error:
raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}") raise ValueError(f"Task creation failed with code {response.Error.Code}: {response.Error.Message}")
task_id = response.JobId
result = await poll_op( result = await poll_op(
cls, cls,
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"), ApiEndpoint(path="/proxy/tencent/hunyuan/3d-pro/query", method="POST"),
data=To3DProTaskQueryRequest(JobId=response.JobId), data=To3DProTaskQueryRequest(JobId=task_id),
response_model=To3DProTaskResultResponse, response_model=To3DProTaskResultResponse,
status_extractor=lambda r: r.Status, status_extractor=lambda r: r.Status,
) )
model_file = f"hunyuan_model_{response.JobId}.glb" glb_result = get_file_from_response(result.ResultFile3Ds, "glb")
await download_url_to_bytesio( obj_result = get_file_from_response(result.ResultFile3Ds, "obj")
get_glb_obj_from_response(result.ResultFile3Ds).Url, file_glb = await download_url_to_file_3d(glb_result.Url, "glb", task_id=task_id) if glb_result else None
os.path.join(get_output_directory(), model_file), return IO.NodeOutput(
file_glb, file_glb, await download_url_to_file_3d(obj_result.Url, "obj", task_id=task_id) if obj_result else None
) )
return IO.NodeOutput(model_file)
class TencentHunyuan3DExtension(ComfyExtension): class TencentHunyuan3DExtension(ComfyExtension):

View File

@ -1,5 +1,3 @@
import os
from typing_extensions import override from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, Input from comfy_api.latest import IO, ComfyExtension, Input
@ -20,13 +18,12 @@ from comfy_api_nodes.apis.meshy import (
) )
from comfy_api_nodes.util import ( from comfy_api_nodes.util import (
ApiEndpoint, ApiEndpoint,
download_url_to_bytesio, download_url_to_file_3d,
poll_op, poll_op,
sync_op, sync_op,
upload_images_to_comfyapi, upload_images_to_comfyapi,
validate_string, validate_string,
) )
from folder_paths import get_output_directory
class MeshyTextToModelNode(IO.ComfyNode): class MeshyTextToModelNode(IO.ComfyNode):
@ -79,8 +76,10 @@ class MeshyTextToModelNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DFBX.Output(display_name="FBX"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -122,16 +121,20 @@ class MeshyTextToModelNode(IO.ComfyNode):
seed=seed, seed=seed,
), ),
) )
task_id = response.result
result = await poll_op( result = await poll_op(
cls, cls,
ApiEndpoint(path=f"/proxy/meshy/openapi/v2/text-to-3d/{response.result}"), ApiEndpoint(path=f"/proxy/meshy/openapi/v2/text-to-3d/{task_id}"),
response_model=MeshyModelResult, response_model=MeshyModelResult,
status_extractor=lambda r: r.status, status_extractor=lambda r: r.status,
progress_extractor=lambda r: r.progress, progress_extractor=lambda r: r.progress,
) )
model_file = f"meshy_model_{response.result}.glb" return IO.NodeOutput(
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) f"{task_id}.glb",
return IO.NodeOutput(model_file, response.result) task_id,
await download_url_to_file_3d(result.model_urls.glb, "glb", task_id=task_id),
await download_url_to_file_3d(result.model_urls.fbx, "fbx", task_id=task_id),
)
class MeshyRefineNode(IO.ComfyNode): class MeshyRefineNode(IO.ComfyNode):
@ -167,8 +170,10 @@ class MeshyRefineNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DFBX.Output(display_name="FBX"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -210,16 +215,20 @@ class MeshyRefineNode(IO.ComfyNode):
ai_model=model, ai_model=model,
), ),
) )
task_id = response.result
result = await poll_op( result = await poll_op(
cls, cls,
ApiEndpoint(path=f"/proxy/meshy/openapi/v2/text-to-3d/{response.result}"), ApiEndpoint(path=f"/proxy/meshy/openapi/v2/text-to-3d/{task_id}"),
response_model=MeshyModelResult, response_model=MeshyModelResult,
status_extractor=lambda r: r.status, status_extractor=lambda r: r.status,
progress_extractor=lambda r: r.progress, progress_extractor=lambda r: r.progress,
) )
model_file = f"meshy_model_{response.result}.glb" return IO.NodeOutput(
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) f"{task_id}.glb",
return IO.NodeOutput(model_file, response.result) task_id,
await download_url_to_file_3d(result.model_urls.glb, "glb", task_id=task_id),
await download_url_to_file_3d(result.model_urls.fbx, "fbx", task_id=task_id),
)
class MeshyImageToModelNode(IO.ComfyNode): class MeshyImageToModelNode(IO.ComfyNode):
@ -303,8 +312,10 @@ class MeshyImageToModelNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DFBX.Output(display_name="FBX"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -368,16 +379,20 @@ class MeshyImageToModelNode(IO.ComfyNode):
seed=seed, seed=seed,
), ),
) )
task_id = response.result
result = await poll_op( result = await poll_op(
cls, cls,
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/image-to-3d/{response.result}"), ApiEndpoint(path=f"/proxy/meshy/openapi/v1/image-to-3d/{task_id}"),
response_model=MeshyModelResult, response_model=MeshyModelResult,
status_extractor=lambda r: r.status, status_extractor=lambda r: r.status,
progress_extractor=lambda r: r.progress, progress_extractor=lambda r: r.progress,
) )
model_file = f"meshy_model_{response.result}.glb" return IO.NodeOutput(
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) f"{task_id}.glb",
return IO.NodeOutput(model_file, response.result) task_id,
await download_url_to_file_3d(result.model_urls.glb, "glb", task_id=task_id),
await download_url_to_file_3d(result.model_urls.fbx, "fbx", task_id=task_id),
)
class MeshyMultiImageToModelNode(IO.ComfyNode): class MeshyMultiImageToModelNode(IO.ComfyNode):
@ -464,8 +479,10 @@ class MeshyMultiImageToModelNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"), IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DFBX.Output(display_name="FBX"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -531,16 +548,20 @@ class MeshyMultiImageToModelNode(IO.ComfyNode):
seed=seed, seed=seed,
), ),
) )
task_id = response.result
result = await poll_op( result = await poll_op(
cls, cls,
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/multi-image-to-3d/{response.result}"), ApiEndpoint(path=f"/proxy/meshy/openapi/v1/multi-image-to-3d/{task_id}"),
response_model=MeshyModelResult, response_model=MeshyModelResult,
status_extractor=lambda r: r.status, status_extractor=lambda r: r.status,
progress_extractor=lambda r: r.progress, progress_extractor=lambda r: r.progress,
) )
model_file = f"meshy_model_{response.result}.glb" return IO.NodeOutput(
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) f"{task_id}.glb",
return IO.NodeOutput(model_file, response.result) task_id,
await download_url_to_file_3d(result.model_urls.glb, "glb", task_id=task_id),
await download_url_to_file_3d(result.model_urls.fbx, "fbx", task_id=task_id),
)
class MeshyRigModelNode(IO.ComfyNode): class MeshyRigModelNode(IO.ComfyNode):
@ -571,8 +592,10 @@ class MeshyRigModelNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MESHY_RIGGED_TASK_ID").Output(display_name="rig_task_id"), IO.Custom("MESHY_RIGGED_TASK_ID").Output(display_name="rig_task_id"),
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DFBX.Output(display_name="FBX"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -606,18 +629,20 @@ class MeshyRigModelNode(IO.ComfyNode):
texture_image_url=texture_image_url, texture_image_url=texture_image_url,
), ),
) )
task_id = response.result
result = await poll_op( result = await poll_op(
cls, cls,
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/rigging/{response.result}"), ApiEndpoint(path=f"/proxy/meshy/openapi/v1/rigging/{task_id}"),
response_model=MeshyRiggedResult, response_model=MeshyRiggedResult,
status_extractor=lambda r: r.status, status_extractor=lambda r: r.status,
progress_extractor=lambda r: r.progress, progress_extractor=lambda r: r.progress,
) )
model_file = f"meshy_model_{response.result}.glb" return IO.NodeOutput(
await download_url_to_bytesio( f"{task_id}.glb",
result.result.rigged_character_glb_url, os.path.join(get_output_directory(), model_file) task_id,
await download_url_to_file_3d(result.result.rigged_character_glb_url, "glb", task_id=task_id),
await download_url_to_file_3d(result.result.rigged_character_fbx_url, "fbx", task_id=task_id),
) )
return IO.NodeOutput(model_file, response.result)
class MeshyAnimateModelNode(IO.ComfyNode): class MeshyAnimateModelNode(IO.ComfyNode):
@ -640,7 +665,9 @@ class MeshyAnimateModelNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DFBX.Output(display_name="FBX"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -669,16 +696,19 @@ class MeshyAnimateModelNode(IO.ComfyNode):
action_id=action_id, action_id=action_id,
), ),
) )
task_id = response.result
result = await poll_op( result = await poll_op(
cls, cls,
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/animations/{response.result}"), ApiEndpoint(path=f"/proxy/meshy/openapi/v1/animations/{task_id}"),
response_model=MeshyAnimationResult, response_model=MeshyAnimationResult,
status_extractor=lambda r: r.status, status_extractor=lambda r: r.status,
progress_extractor=lambda r: r.progress, progress_extractor=lambda r: r.progress,
) )
model_file = f"meshy_model_{response.result}.glb" return IO.NodeOutput(
await download_url_to_bytesio(result.result.animation_glb_url, os.path.join(get_output_directory(), model_file)) f"{task_id}.glb",
return IO.NodeOutput(model_file, response.result) await download_url_to_file_3d(result.result.animation_glb_url, "glb", task_id=task_id),
await download_url_to_file_3d(result.result.animation_fbx_url, "fbx", task_id=task_id),
)
class MeshyTextureNode(IO.ComfyNode): class MeshyTextureNode(IO.ComfyNode):
@ -715,8 +745,10 @@ class MeshyTextureNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MODEL_TASK_ID").Output(display_name="meshy_task_id"), IO.Custom("MODEL_TASK_ID").Output(display_name="meshy_task_id"),
IO.File3DGLB.Output(display_name="GLB"),
IO.File3DFBX.Output(display_name="FBX"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -760,16 +792,20 @@ class MeshyTextureNode(IO.ComfyNode):
image_style_url=image_style_url, image_style_url=image_style_url,
), ),
) )
task_id = response.result
result = await poll_op( result = await poll_op(
cls, cls,
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/retexture/{response.result}"), ApiEndpoint(path=f"/proxy/meshy/openapi/v1/retexture/{task_id}"),
response_model=MeshyModelResult, response_model=MeshyModelResult,
status_extractor=lambda r: r.status, status_extractor=lambda r: r.status,
progress_extractor=lambda r: r.progress, progress_extractor=lambda r: r.progress,
) )
model_file = f"meshy_model_{response.result}.glb" return IO.NodeOutput(
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file)) f"{task_id}.glb",
return IO.NodeOutput(model_file, response.result) task_id,
await download_url_to_file_3d(result.model_urls.glb, "glb", task_id=task_id),
await download_url_to_file_3d(result.model_urls.fbx, "fbx", task_id=task_id),
)
class MeshyExtension(ComfyExtension): class MeshyExtension(ComfyExtension):

View File

@ -10,7 +10,6 @@ import folder_paths as comfy_paths
import os import os
import logging import logging
import math import math
from typing import Optional
from io import BytesIO from io import BytesIO
from typing_extensions import override from typing_extensions import override
from PIL import Image from PIL import Image
@ -28,8 +27,9 @@ from comfy_api_nodes.util import (
poll_op, poll_op,
ApiEndpoint, ApiEndpoint,
download_url_to_bytesio, download_url_to_bytesio,
download_url_to_file_3d,
) )
from comfy_api.latest import ComfyExtension, IO from comfy_api.latest import ComfyExtension, IO, Types
COMMON_PARAMETERS = [ COMMON_PARAMETERS = [
@ -177,7 +177,7 @@ def check_rodin_status(response: Rodin3DCheckStatusResponse) -> str:
return "DONE" return "DONE"
return "Generating" return "Generating"
def extract_progress(response: Rodin3DCheckStatusResponse) -> Optional[int]: def extract_progress(response: Rodin3DCheckStatusResponse) -> int | None:
if not response.jobs: if not response.jobs:
return None return None
completed_count = sum(1 for job in response.jobs if job.status == JobStatus.Done) completed_count = sum(1 for job in response.jobs if job.status == JobStatus.Done)
@ -207,17 +207,25 @@ async def get_rodin_download_list(uuid: str, cls: type[IO.ComfyNode]) -> Rodin3D
) )
async def download_files(url_list, task_uuid: str): async def download_files(url_list, task_uuid: str) -> tuple[str | None, Types.File3D | None]:
result_folder_name = f"Rodin3D_{task_uuid}" result_folder_name = f"Rodin3D_{task_uuid}"
save_path = os.path.join(comfy_paths.get_output_directory(), result_folder_name) save_path = os.path.join(comfy_paths.get_output_directory(), result_folder_name)
os.makedirs(save_path, exist_ok=True) os.makedirs(save_path, exist_ok=True)
model_file_path = None model_file_path = None
file_3d = None
for i in url_list.list: for i in url_list.list:
file_path = os.path.join(save_path, i.name) file_path = os.path.join(save_path, i.name)
if file_path.endswith(".glb"): if i.name.lower().endswith(".glb"):
model_file_path = os.path.join(result_folder_name, i.name) model_file_path = os.path.join(result_folder_name, i.name)
await download_url_to_bytesio(i.url, file_path) file_3d = await download_url_to_file_3d(i.url, "glb")
return model_file_path # Save to disk for backward compatibility
with open(file_path, "wb") as f:
f.write(file_3d.get_bytes())
else:
await download_url_to_bytesio(i.url, file_path)
return model_file_path, file_3d
class Rodin3D_Regular(IO.ComfyNode): class Rodin3D_Regular(IO.ComfyNode):
@ -234,7 +242,10 @@ class Rodin3D_Regular(IO.ComfyNode):
IO.Image.Input("Images"), IO.Image.Input("Images"),
*COMMON_PARAMETERS, *COMMON_PARAMETERS,
], ],
outputs=[IO.String.Output(display_name="3D Model Path")], outputs=[
IO.String.Output(display_name="3D Model Path"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org, IO.Hidden.api_key_comfy_org,
@ -271,9 +282,9 @@ class Rodin3D_Regular(IO.ComfyNode):
) )
await poll_for_task_status(subscription_key, cls) await poll_for_task_status(subscription_key, cls)
download_list = await get_rodin_download_list(task_uuid, cls) download_list = await get_rodin_download_list(task_uuid, cls)
model = await download_files(download_list, task_uuid) model_path, file_3d = await download_files(download_list, task_uuid)
return IO.NodeOutput(model) return IO.NodeOutput(model_path, file_3d)
class Rodin3D_Detail(IO.ComfyNode): class Rodin3D_Detail(IO.ComfyNode):
@ -290,7 +301,10 @@ class Rodin3D_Detail(IO.ComfyNode):
IO.Image.Input("Images"), IO.Image.Input("Images"),
*COMMON_PARAMETERS, *COMMON_PARAMETERS,
], ],
outputs=[IO.String.Output(display_name="3D Model Path")], outputs=[
IO.String.Output(display_name="3D Model Path"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org, IO.Hidden.api_key_comfy_org,
@ -327,9 +341,9 @@ class Rodin3D_Detail(IO.ComfyNode):
) )
await poll_for_task_status(subscription_key, cls) await poll_for_task_status(subscription_key, cls)
download_list = await get_rodin_download_list(task_uuid, cls) download_list = await get_rodin_download_list(task_uuid, cls)
model = await download_files(download_list, task_uuid) model_path, file_3d = await download_files(download_list, task_uuid)
return IO.NodeOutput(model) return IO.NodeOutput(model_path, file_3d)
class Rodin3D_Smooth(IO.ComfyNode): class Rodin3D_Smooth(IO.ComfyNode):
@ -346,7 +360,10 @@ class Rodin3D_Smooth(IO.ComfyNode):
IO.Image.Input("Images"), IO.Image.Input("Images"),
*COMMON_PARAMETERS, *COMMON_PARAMETERS,
], ],
outputs=[IO.String.Output(display_name="3D Model Path")], outputs=[
IO.String.Output(display_name="3D Model Path"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org, IO.Hidden.api_key_comfy_org,
@ -382,9 +399,9 @@ class Rodin3D_Smooth(IO.ComfyNode):
) )
await poll_for_task_status(subscription_key, cls) await poll_for_task_status(subscription_key, cls)
download_list = await get_rodin_download_list(task_uuid, cls) download_list = await get_rodin_download_list(task_uuid, cls)
model = await download_files(download_list, task_uuid) model_path, file_3d = await download_files(download_list, task_uuid)
return IO.NodeOutput(model) return IO.NodeOutput(model_path, file_3d)
class Rodin3D_Sketch(IO.ComfyNode): class Rodin3D_Sketch(IO.ComfyNode):
@ -408,7 +425,10 @@ class Rodin3D_Sketch(IO.ComfyNode):
optional=True, optional=True,
), ),
], ],
outputs=[IO.String.Output(display_name="3D Model Path")], outputs=[
IO.String.Output(display_name="3D Model Path"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org, IO.Hidden.api_key_comfy_org,
@ -441,9 +461,9 @@ class Rodin3D_Sketch(IO.ComfyNode):
) )
await poll_for_task_status(subscription_key, cls) await poll_for_task_status(subscription_key, cls)
download_list = await get_rodin_download_list(task_uuid, cls) download_list = await get_rodin_download_list(task_uuid, cls)
model = await download_files(download_list, task_uuid) model_path, file_3d = await download_files(download_list, task_uuid)
return IO.NodeOutput(model) return IO.NodeOutput(model_path, file_3d)
class Rodin3D_Gen2(IO.ComfyNode): class Rodin3D_Gen2(IO.ComfyNode):
@ -475,7 +495,10 @@ class Rodin3D_Gen2(IO.ComfyNode):
), ),
IO.Boolean.Input("TAPose", default=False), IO.Boolean.Input("TAPose", default=False),
], ],
outputs=[IO.String.Output(display_name="3D Model Path")], outputs=[
IO.String.Output(display_name="3D Model Path"), # for backward compatibility only
IO.File3DGLB.Output(display_name="GLB"),
],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
IO.Hidden.api_key_comfy_org, IO.Hidden.api_key_comfy_org,
@ -511,9 +534,9 @@ class Rodin3D_Gen2(IO.ComfyNode):
) )
await poll_for_task_status(subscription_key, cls) await poll_for_task_status(subscription_key, cls)
download_list = await get_rodin_download_list(task_uuid, cls) download_list = await get_rodin_download_list(task_uuid, cls)
model = await download_files(download_list, task_uuid) model_path, file_3d = await download_files(download_list, task_uuid)
return IO.NodeOutput(model) return IO.NodeOutput(model_path, file_3d)
class Rodin3DExtension(ComfyExtension): class Rodin3DExtension(ComfyExtension):

View File

@ -1,10 +1,6 @@
import os
from typing import Optional
import torch
from typing_extensions import override from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension from comfy_api.latest import IO, ComfyExtension, Input
from comfy_api_nodes.apis.tripo import ( from comfy_api_nodes.apis.tripo import (
TripoAnimateRetargetRequest, TripoAnimateRetargetRequest,
TripoAnimateRigRequest, TripoAnimateRigRequest,
@ -26,12 +22,11 @@ from comfy_api_nodes.apis.tripo import (
) )
from comfy_api_nodes.util import ( from comfy_api_nodes.util import (
ApiEndpoint, ApiEndpoint,
download_url_as_bytesio, download_url_to_file_3d,
poll_op, poll_op,
sync_op, sync_op,
upload_images_to_comfyapi, upload_images_to_comfyapi,
) )
from folder_paths import get_output_directory
def get_model_url_from_response(response: TripoTaskResponse) -> str: def get_model_url_from_response(response: TripoTaskResponse) -> str:
@ -45,7 +40,7 @@ def get_model_url_from_response(response: TripoTaskResponse) -> str:
async def poll_until_finished( async def poll_until_finished(
node_cls: type[IO.ComfyNode], node_cls: type[IO.ComfyNode],
response: TripoTaskResponse, response: TripoTaskResponse,
average_duration: Optional[int] = None, average_duration: int | None = None,
) -> IO.NodeOutput: ) -> IO.NodeOutput:
"""Polls the Tripo API endpoint until the task reaches a terminal state, then returns the response.""" """Polls the Tripo API endpoint until the task reaches a terminal state, then returns the response."""
if response.code != 0: if response.code != 0:
@ -69,12 +64,8 @@ async def poll_until_finished(
) )
if response_poll.data.status == TripoTaskStatus.SUCCESS: if response_poll.data.status == TripoTaskStatus.SUCCESS:
url = get_model_url_from_response(response_poll) url = get_model_url_from_response(response_poll)
bytesio = await download_url_as_bytesio(url) file_glb = await download_url_to_file_3d(url, "glb", task_id=task_id)
# Save the downloaded model file return IO.NodeOutput(f"{task_id}.glb", task_id, file_glb)
model_file = f"tripo_model_{task_id}.glb"
with open(os.path.join(get_output_directory(), model_file), "wb") as f:
f.write(bytesio.getvalue())
return IO.NodeOutput(model_file, task_id)
raise RuntimeError(f"Failed to generate mesh: {response_poll}") raise RuntimeError(f"Failed to generate mesh: {response_poll}")
@ -107,8 +98,9 @@ class TripoTextToModelNode(IO.ComfyNode):
IO.Combo.Input("geometry_quality", default="standard", options=["standard", "detailed"], optional=True), IO.Combo.Input("geometry_quality", default="standard", options=["standard", "detailed"], optional=True),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"),
IO.File3DGLB.Output(display_name="GLB"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -155,18 +147,18 @@ class TripoTextToModelNode(IO.ComfyNode):
async def execute( async def execute(
cls, cls,
prompt: str, prompt: str,
negative_prompt: Optional[str] = None, negative_prompt: str | None = None,
model_version=None, model_version=None,
style: Optional[str] = None, style: str | None = None,
texture: Optional[bool] = None, texture: bool | None = None,
pbr: Optional[bool] = None, pbr: bool | None = None,
image_seed: Optional[int] = None, image_seed: int | None = None,
model_seed: Optional[int] = None, model_seed: int | None = None,
texture_seed: Optional[int] = None, texture_seed: int | None = None,
texture_quality: Optional[str] = None, texture_quality: str | None = None,
geometry_quality: Optional[str] = None, geometry_quality: str | None = None,
face_limit: Optional[int] = None, face_limit: int | None = None,
quad: Optional[bool] = None, quad: bool | None = None,
) -> IO.NodeOutput: ) -> IO.NodeOutput:
style_enum = None if style == "None" else style style_enum = None if style == "None" else style
if not prompt: if not prompt:
@ -232,8 +224,9 @@ class TripoImageToModelNode(IO.ComfyNode):
IO.Combo.Input("geometry_quality", default="standard", options=["standard", "detailed"], optional=True), IO.Combo.Input("geometry_quality", default="standard", options=["standard", "detailed"], optional=True),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"),
IO.File3DGLB.Output(display_name="GLB"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -279,19 +272,19 @@ class TripoImageToModelNode(IO.ComfyNode):
@classmethod @classmethod
async def execute( async def execute(
cls, cls,
image: torch.Tensor, image: Input.Image,
model_version: Optional[str] = None, model_version: str | None = None,
style: Optional[str] = None, style: str | None = None,
texture: Optional[bool] = None, texture: bool | None = None,
pbr: Optional[bool] = None, pbr: bool | None = None,
model_seed: Optional[int] = None, model_seed: int | None = None,
orientation=None, orientation=None,
texture_seed: Optional[int] = None, texture_seed: int | None = None,
texture_quality: Optional[str] = None, texture_quality: str | None = None,
geometry_quality: Optional[str] = None, geometry_quality: str | None = None,
texture_alignment: Optional[str] = None, texture_alignment: str | None = None,
face_limit: Optional[int] = None, face_limit: int | None = None,
quad: Optional[bool] = None, quad: bool | None = None,
) -> IO.NodeOutput: ) -> IO.NodeOutput:
style_enum = None if style == "None" else style style_enum = None if style == "None" else style
if image is None: if image is None:
@ -368,8 +361,9 @@ class TripoMultiviewToModelNode(IO.ComfyNode):
IO.Combo.Input("geometry_quality", default="standard", options=["standard", "detailed"], optional=True), IO.Combo.Input("geometry_quality", default="standard", options=["standard", "detailed"], optional=True),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"),
IO.File3DGLB.Output(display_name="GLB"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -411,21 +405,21 @@ class TripoMultiviewToModelNode(IO.ComfyNode):
@classmethod @classmethod
async def execute( async def execute(
cls, cls,
image: torch.Tensor, image: Input.Image,
image_left: Optional[torch.Tensor] = None, image_left: Input.Image | None = None,
image_back: Optional[torch.Tensor] = None, image_back: Input.Image | None = None,
image_right: Optional[torch.Tensor] = None, image_right: Input.Image | None = None,
model_version: Optional[str] = None, model_version: str | None = None,
orientation: Optional[str] = None, orientation: str | None = None,
texture: Optional[bool] = None, texture: bool | None = None,
pbr: Optional[bool] = None, pbr: bool | None = None,
model_seed: Optional[int] = None, model_seed: int | None = None,
texture_seed: Optional[int] = None, texture_seed: int | None = None,
texture_quality: Optional[str] = None, texture_quality: str | None = None,
geometry_quality: Optional[str] = None, geometry_quality: str | None = None,
texture_alignment: Optional[str] = None, texture_alignment: str | None = None,
face_limit: Optional[int] = None, face_limit: int | None = None,
quad: Optional[bool] = None, quad: bool | None = None,
) -> IO.NodeOutput: ) -> IO.NodeOutput:
if image is None: if image is None:
raise RuntimeError("front image for multiview is required") raise RuntimeError("front image for multiview is required")
@ -487,8 +481,9 @@ class TripoTextureNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"),
IO.File3DGLB.Output(display_name="GLB"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -512,11 +507,11 @@ class TripoTextureNode(IO.ComfyNode):
async def execute( async def execute(
cls, cls,
model_task_id, model_task_id,
texture: Optional[bool] = None, texture: bool | None = None,
pbr: Optional[bool] = None, pbr: bool | None = None,
texture_seed: Optional[int] = None, texture_seed: int | None = None,
texture_quality: Optional[str] = None, texture_quality: str | None = None,
texture_alignment: Optional[str] = None, texture_alignment: str | None = None,
) -> IO.NodeOutput: ) -> IO.NodeOutput:
response = await sync_op( response = await sync_op(
cls, cls,
@ -547,8 +542,9 @@ class TripoRefineNode(IO.ComfyNode):
IO.Custom("MODEL_TASK_ID").Input("model_task_id", tooltip="Must be a v1.4 Tripo model"), IO.Custom("MODEL_TASK_ID").Input("model_task_id", tooltip="Must be a v1.4 Tripo model"),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"), IO.Custom("MODEL_TASK_ID").Output(display_name="model task_id"),
IO.File3DGLB.Output(display_name="GLB"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -583,8 +579,9 @@ class TripoRigNode(IO.ComfyNode):
category="api node/3d/Tripo", category="api node/3d/Tripo",
inputs=[IO.Custom("MODEL_TASK_ID").Input("original_model_task_id")], inputs=[IO.Custom("MODEL_TASK_ID").Input("original_model_task_id")],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("RIG_TASK_ID").Output(display_name="rig task_id"), IO.Custom("RIG_TASK_ID").Output(display_name="rig task_id"),
IO.File3DGLB.Output(display_name="GLB"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,
@ -642,8 +639,9 @@ class TripoRetargetNode(IO.ComfyNode):
), ),
], ],
outputs=[ outputs=[
IO.String.Output(display_name="model_file"), IO.String.Output(display_name="model_file"), # for backward compatibility only
IO.Custom("RETARGET_TASK_ID").Output(display_name="retarget task_id"), IO.Custom("RETARGET_TASK_ID").Output(display_name="retarget task_id"),
IO.File3DGLB.Output(display_name="GLB"),
], ],
hidden=[ hidden=[
IO.Hidden.auth_token_comfy_org, IO.Hidden.auth_token_comfy_org,

View File

@ -28,6 +28,7 @@ from .conversions import (
from .download_helpers import ( from .download_helpers import (
download_url_as_bytesio, download_url_as_bytesio,
download_url_to_bytesio, download_url_to_bytesio,
download_url_to_file_3d,
download_url_to_image_tensor, download_url_to_image_tensor,
download_url_to_video_output, download_url_to_video_output,
) )
@ -69,6 +70,7 @@ __all__ = [
# Download helpers # Download helpers
"download_url_as_bytesio", "download_url_as_bytesio",
"download_url_to_bytesio", "download_url_to_bytesio",
"download_url_to_file_3d",
"download_url_to_image_tensor", "download_url_to_image_tensor",
"download_url_to_video_output", "download_url_to_video_output",
# Conversions # Conversions

View File

@ -11,7 +11,8 @@ import torch
from aiohttp.client_exceptions import ClientError, ContentTypeError from aiohttp.client_exceptions import ClientError, ContentTypeError
from comfy_api.latest import IO as COMFY_IO from comfy_api.latest import IO as COMFY_IO
from comfy_api.latest import InputImpl from comfy_api.latest import InputImpl, Types
from folder_paths import get_output_directory
from . import request_logger from . import request_logger
from ._helpers import ( from ._helpers import (
@ -261,3 +262,38 @@ def _generate_operation_id(method: str, url: str, attempt: int) -> str:
except Exception: except Exception:
slug = "download" slug = "download"
return f"{method}_{slug}_try{attempt}_{uuid.uuid4().hex[:8]}" return f"{method}_{slug}_try{attempt}_{uuid.uuid4().hex[:8]}"
async def download_url_to_file_3d(
url: str,
file_format: str,
*,
task_id: str | None = None,
timeout: float | None = None,
max_retries: int = 5,
cls: type[COMFY_IO.ComfyNode] = None,
) -> Types.File3D:
"""Downloads a 3D model file from a URL into memory as BytesIO.
If task_id is provided, also writes the file to disk in the output directory
for backward compatibility with the old save-to-disk behavior.
"""
file_format = file_format.lstrip(".").lower()
data = BytesIO()
await download_url_to_bytesio(
url,
data,
timeout=timeout,
max_retries=max_retries,
cls=cls,
)
if task_id is not None:
# This is only for backward compatability with current behavior when every 3D node is output node
# All new API nodes should not use "task_id" and instead users should use "SaveGLB" node to save results
output_dir = Path(get_output_directory())
output_path = output_dir / f"{task_id}.{file_format}"
output_path.write_bytes(data.getvalue())
data.seek(0)
return Types.File3D(source=data, file_format=file_format)

View File

@ -622,14 +622,20 @@ class SaveGLB(IO.ComfyNode):
category="3d", category="3d",
is_output_node=True, is_output_node=True,
inputs=[ inputs=[
IO.Mesh.Input("mesh"), IO.MultiType.Input(
IO.Mesh.Input("mesh"),
types=[
IO.File3DGLB,
],
tooltip="Mesh or GLB file to save",
),
IO.String.Input("filename_prefix", default="mesh/ComfyUI"), IO.String.Input("filename_prefix", default="mesh/ComfyUI"),
], ],
hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo] hidden=[IO.Hidden.prompt, IO.Hidden.extra_pnginfo]
) )
@classmethod @classmethod
def execute(cls, mesh, filename_prefix) -> IO.NodeOutput: def execute(cls, mesh: Types.MESH | Types.File3D, filename_prefix: str) -> 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 = []
@ -641,15 +647,26 @@ class SaveGLB(IO.ComfyNode):
for x in cls.hidden.extra_pnginfo: for x in cls.hidden.extra_pnginfo:
metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x]) metadata[x] = json.dumps(cls.hidden.extra_pnginfo[x])
for i in range(mesh.vertices.shape[0]): if isinstance(mesh, Types.File3D):
# Handle File3D input - save BytesIO data to output folder
f = f"{filename}_{counter:05}_.glb" f = f"{filename}_{counter:05}_.glb"
save_glb(mesh.vertices[i], mesh.faces[i], os.path.join(full_output_folder, f), metadata) mesh.save_to(os.path.join(full_output_folder, f))
results.append({ results.append({
"filename": f, "filename": f,
"subfolder": subfolder, "subfolder": subfolder,
"type": "output" "type": "output"
}) })
counter += 1 else:
# Handle Mesh input - save vertices and faces as GLB
for i in range(mesh.vertices.shape[0]):
f = f"{filename}_{counter:05}_.glb"
save_glb(mesh.vertices[i], mesh.faces[i], os.path.join(full_output_folder, f), metadata)
results.append({
"filename": f,
"subfolder": subfolder,
"type": "output"
})
counter += 1
return IO.NodeOutput(ui={"3d": results}) return IO.NodeOutput(ui={"3d": results})

View File

@ -1,9 +1,10 @@
import nodes import nodes
import folder_paths import folder_paths
import os import os
import uuid
from typing_extensions import override from typing_extensions import override
from comfy_api.latest import IO, ComfyExtension, InputImpl, UI from comfy_api.latest import IO, UI, ComfyExtension, InputImpl, Types
from pathlib import Path from pathlib import Path
@ -81,7 +82,19 @@ class Preview3D(IO.ComfyNode):
is_experimental=True, is_experimental=True,
is_output_node=True, is_output_node=True,
inputs=[ inputs=[
IO.String.Input("model_file", default="", multiline=False), IO.MultiType.Input(
IO.String.Input("model_file", default="", multiline=False),
types=[
IO.File3DGLB,
IO.File3DGLTF,
IO.File3DFBX,
IO.File3DOBJ,
IO.File3DSTL,
IO.File3DUSDZ,
IO.File3DAny,
],
tooltip="3D model file or path string",
),
IO.Load3DCamera.Input("camera_info", optional=True), IO.Load3DCamera.Input("camera_info", optional=True),
IO.Image.Input("bg_image", optional=True), IO.Image.Input("bg_image", optional=True),
], ],
@ -89,10 +102,15 @@ class Preview3D(IO.ComfyNode):
) )
@classmethod @classmethod
def execute(cls, model_file, **kwargs) -> IO.NodeOutput: def execute(cls, model_file: str | Types.File3D, **kwargs) -> IO.NodeOutput:
if isinstance(model_file, Types.File3D):
filename = f"preview3d_{uuid.uuid4().hex}.{model_file.format}"
model_file.save_to(os.path.join(folder_paths.get_output_directory(), filename))
else:
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(model_file, camera_info, bg_image=bg_image)) return IO.NodeOutput(ui=UI.PreviewUI3D(filename, camera_info, bg_image=bg_image))
process = execute # TODO: remove process = execute # TODO: remove

View File

@ -1,3 +1,3 @@
# This file is automatically generated by the build process when version is # This file is automatically generated by the build process when version is
# updated in pyproject.toml. # updated in pyproject.toml.
__version__ = "0.12.0" __version__ = "0.12.1"

46
compose.yaml Normal file
View File

@ -0,0 +1,46 @@
# Docker Compose file to run ComfyUI locally using Docker.
services:
comfyui:
container_name: comfyui
build:
context: .
args:
# Declare additional system dependencies for custom nodes
APT_EXTRA_PACKAGES:
ports:
- 8188:8188
# Optional: enable GPU access for hardware acceleration.
deploy:
resources:
reservations:
devices:
- capabilities: [gpu]
volumes:
- ./custom_nodes:/comfyui/custom_nodes
- ./models:/comfyui/models
# (Optional) Mount host ComfyUI data directories
#
#- ./input:/comfyui/input
#- ./output:/comfyui/output
#- ./temp:/comfyui/temp
#- ./user:/comfyui/user
environment:
# Overwrite the container user's UID and GID to match the host's. This
# allows files created by ComfyUI to be mounted on the host without
# permission issues.
UID: 1000
GID: 1000
# Declare additional Python packages to install. Useful when a custom node
# pack does not properly specify all its dependencies or relies on
# optional dependencies.
PIP_EXTRA_PACKAGES:
# Optional: Override the default command. In this case, configure ComfyUI to
# listen on all network interfaces (which is required when not using
# `network_mode=host`.)
command: python ./main.py --listen 0.0.0.0

62
entrypoint.sh Executable file
View File

@ -0,0 +1,62 @@
#!/bin/sh
# Entrypoint script for the ComfyUI Docker image.
set -e
user="comfyui"
user_group="$user"
# Allow users to specify a UID and GID matching their own, so files created
# inside the container retain the same numeric ownership when mounted on the
# host.
if [ -n "$UID" ] && [ -n "$GID" ]; then
echo "[entrypoint] Setting user UID and GID..."
usermod -u "$UID" "$user" > /dev/null
groupmod -g "$GID" "$user_group"
else
echo "[entrypoint] Missing UID or GID environment variables; keeping default values."
fi
# Changing a user's UID and GID revokes that user's access to files owned by the
# original UID/GID. To preserve access to runtime data, the ownership of those
# directories must be updated recursively so that their numeric owner matches
# the user's new UID and GID.
echo "[entrypoint] Changing directory ownership..."
chown -R "$user:$user_group" \
/comfyui \
/home/comfyui
# To use CUDA and other NVIDIA features, regular users must belong to the group
# that owns the /dev/nvidia* device files -- typically the video group.
#
# Known issue: Because these device files are mounted from the host system,
# there's no guarantee that the device's group ID will match the intended group
# inside the container. For example, the video group might be mapped to GID 27
# on the host, which corresponds to the sudo group in the python:3.12 image.
# This shouldn't cause major problems, and given the lack of a universal
# standard for system GIDs, there isn't much we can realistically change to
# address this issue.
echo "[entrypoint] Adding user to GPU device groups..."
for dev in /dev/nvidia*; do
group=$(ls -ld "$dev" | awk '{print $4}')
usermod -aG "$group" "$user"
done
# Install or update the Python dependencies defined by ComfyUI (or any installed
# custom node) and also install any user-defined dependencies specified in
# PIP_EXTRA_PACKAGES.
echo "[entrypoint] Updating Python dependencies..."
su -c "
pip install \\
--no-cache-dir \\
--disable-pip-version-check \\
-r requirements.txt \\
$(find custom_nodes -mindepth 2 -maxdepth 2 -type f -name requirements.txt -printf "-r '%p' ") \\
$PIP_EXTRA_PACKAGES
" comfyui \
|| echo "[entrypoint] Failed to install dependencies, starting anyway" >&2
# Run command as comfyui
echo "[entrypoint] Running command"
exec su -c "$*" comfyui

View File

@ -1,6 +1,6 @@
[project] [project]
name = "ComfyUI" name = "ComfyUI"
version = "0.12.0" version = "0.12.1"
readme = "README.md" readme = "README.md"
license = { file = "LICENSE" } license = { file = "LICENSE" }
requires-python = ">=3.10" requires-python = ">=3.10"