mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-01-28 23:30:16 +08:00
Compare commits
13 Commits
5e6bad50fb
...
138f7a8fa1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
138f7a8fa1 | ||
|
|
be518db5a7 | ||
|
|
80441eb15e | ||
|
|
07f2462eae | ||
|
|
d150440466 | ||
|
|
6165c38cb5 | ||
|
|
712cca36a1 | ||
|
|
ac4d8ea9b3 | ||
|
|
c9196f355e | ||
|
|
7eb959ce93 | ||
|
|
469dd9c16a | ||
|
|
eff2b9d412 | ||
|
|
15b312de7a |
2
.github/workflows/test-launch.yml
vendored
2
.github/workflows/test-launch.yml
vendored
@ -13,7 +13,7 @@ jobs:
|
||||
- name: Checkout ComfyUI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: "comfyanonymous/ComfyUI"
|
||||
repository: "Comfy-Org/ComfyUI"
|
||||
path: "ComfyUI"
|
||||
- uses: actions/setup-python@v4
|
||||
with:
|
||||
|
||||
59
.github/workflows/update-ci-container.yml
vendored
Normal file
59
.github/workflows/update-ci-container.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
name: "CI: Update CI Container"
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'ComfyUI version (e.g., v0.7.0)'
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
update-ci-container:
|
||||
runs-on: ubuntu-latest
|
||||
# Skip pre-releases unless manually triggered
|
||||
if: github.event_name == 'workflow_dispatch' || !github.event.release.prerelease
|
||||
steps:
|
||||
- name: Get version
|
||||
id: version
|
||||
run: |
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
VERSION="${{ github.event.release.tag_name }}"
|
||||
else
|
||||
VERSION="${{ inputs.version }}"
|
||||
fi
|
||||
echo "version=$VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Checkout comfyui-ci-container
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: comfy-org/comfyui-ci-container
|
||||
token: ${{ secrets.CI_CONTAINER_PAT }}
|
||||
|
||||
- name: Check current version
|
||||
id: current
|
||||
run: |
|
||||
CURRENT=$(grep -oP 'ARG COMFYUI_VERSION=\K.*' Dockerfile || echo "unknown")
|
||||
echo "current_version=$CURRENT" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update Dockerfile
|
||||
run: |
|
||||
VERSION="${{ steps.version.outputs.version }}"
|
||||
sed -i "s/^ARG COMFYUI_VERSION=.*/ARG COMFYUI_VERSION=${VERSION}/" Dockerfile
|
||||
|
||||
- name: Create Pull Request
|
||||
id: create-pr
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
with:
|
||||
token: ${{ secrets.CI_CONTAINER_PAT }}
|
||||
branch: automation/comfyui-${{ steps.version.outputs.version }}
|
||||
title: "chore: bump ComfyUI to ${{ steps.version.outputs.version }}"
|
||||
body: |
|
||||
Updates ComfyUI version from `${{ steps.current.outputs.current_version }}` to `${{ steps.version.outputs.version }}`
|
||||
|
||||
**Triggered by:** ${{ github.event_name == 'release' && format('[Release {0}]({1})', github.event.release.tag_name, github.event.release.html_url) || 'Manual workflow dispatch' }}
|
||||
|
||||
labels: automation
|
||||
commit-message: "chore: bump ComfyUI to ${{ steps.version.outputs.version }}"
|
||||
@ -137,10 +137,44 @@ def to_blocked(input_matrix, flatten: bool = True) -> torch.Tensor:
|
||||
return rearranged.reshape(padded_rows, padded_cols)
|
||||
|
||||
|
||||
def stochastic_round_quantize_nvfp4(x, per_tensor_scale, pad_16x, seed=0):
|
||||
def stochastic_round_quantize_nvfp4_block(x, per_tensor_scale, generator):
|
||||
F4_E2M1_MAX = 6.0
|
||||
F8_E4M3_MAX = 448.0
|
||||
|
||||
orig_shape = x.shape
|
||||
|
||||
block_size = 16
|
||||
|
||||
x = x.reshape(orig_shape[0], -1, block_size)
|
||||
scaled_block_scales_fp8 = torch.clamp(((torch.amax(torch.abs(x), dim=-1)) / F4_E2M1_MAX) / per_tensor_scale.to(x.dtype), max=F8_E4M3_MAX).to(torch.float8_e4m3fn)
|
||||
x = x / (per_tensor_scale.to(x.dtype) * scaled_block_scales_fp8.to(x.dtype)).unsqueeze(-1)
|
||||
|
||||
x = x.view(orig_shape).nan_to_num()
|
||||
data_lp = stochastic_float_to_fp4_e2m1(x, generator=generator)
|
||||
return data_lp, scaled_block_scales_fp8
|
||||
|
||||
|
||||
def stochastic_round_quantize_nvfp4(x, per_tensor_scale, pad_16x, seed=0):
|
||||
def roundup(x: int, multiple: int) -> int:
|
||||
"""Round up x to the nearest multiple."""
|
||||
return ((x + multiple - 1) // multiple) * multiple
|
||||
|
||||
generator = torch.Generator(device=x.device)
|
||||
generator.manual_seed(seed)
|
||||
|
||||
# Handle padding
|
||||
if pad_16x:
|
||||
rows, cols = x.shape
|
||||
padded_rows = roundup(rows, 16)
|
||||
padded_cols = roundup(cols, 16)
|
||||
if padded_rows != rows or padded_cols != cols:
|
||||
x = torch.nn.functional.pad(x, (0, padded_cols - cols, 0, padded_rows - rows))
|
||||
|
||||
x, blocked_scaled = stochastic_round_quantize_nvfp4_block(x, per_tensor_scale, generator)
|
||||
return x, to_blocked(blocked_scaled, flatten=False)
|
||||
|
||||
|
||||
def stochastic_round_quantize_nvfp4_by_block(x, per_tensor_scale, pad_16x, seed=0, block_size=4096 * 4096):
|
||||
def roundup(x: int, multiple: int) -> int:
|
||||
"""Round up x to the nearest multiple."""
|
||||
return ((x + multiple - 1) // multiple) * multiple
|
||||
@ -158,28 +192,20 @@ def stochastic_round_quantize_nvfp4(x, per_tensor_scale, pad_16x, seed=0):
|
||||
# what we want to produce. If we pad here, we want the padded output.
|
||||
orig_shape = x.shape
|
||||
|
||||
block_size = 16
|
||||
orig_shape = list(orig_shape)
|
||||
|
||||
x = x.reshape(orig_shape[0], -1, block_size)
|
||||
max_abs = torch.amax(torch.abs(x), dim=-1)
|
||||
block_scale = max_abs / F4_E2M1_MAX
|
||||
scaled_block_scales = block_scale / per_tensor_scale.to(block_scale.dtype)
|
||||
scaled_block_scales_fp8 = torch.clamp(scaled_block_scales, max=F8_E4M3_MAX).to(torch.float8_e4m3fn)
|
||||
total_scale = per_tensor_scale.to(x.dtype) * scaled_block_scales_fp8.to(x.dtype)
|
||||
|
||||
# Handle zero blocks (from padding): avoid 0/0 NaN
|
||||
zero_scale_mask = (total_scale == 0)
|
||||
total_scale_safe = torch.where(zero_scale_mask, torch.ones_like(total_scale), total_scale)
|
||||
|
||||
x = x / total_scale_safe.unsqueeze(-1)
|
||||
output_fp4 = torch.empty(orig_shape[:-1] + [orig_shape[-1] // 2], dtype=torch.uint8, device=x.device)
|
||||
output_block = torch.empty(orig_shape[:-1] + [orig_shape[-1] // 16], dtype=torch.float8_e4m3fn, device=x.device)
|
||||
|
||||
generator = torch.Generator(device=x.device)
|
||||
generator.manual_seed(seed)
|
||||
|
||||
x = torch.where(zero_scale_mask.unsqueeze(-1), torch.zeros_like(x), x)
|
||||
num_slices = max(1, (x.numel() / block_size))
|
||||
slice_size = max(1, (round(x.shape[0] / num_slices)))
|
||||
|
||||
x = x.view(orig_shape)
|
||||
data_lp = stochastic_float_to_fp4_e2m1(x, generator=generator)
|
||||
for i in range(0, x.shape[0], slice_size):
|
||||
fp4, block = stochastic_round_quantize_nvfp4_block(x[i: i + slice_size], per_tensor_scale, generator=generator)
|
||||
output_fp4[i:i + slice_size].copy_(fp4)
|
||||
output_block[i:i + slice_size].copy_(block)
|
||||
|
||||
blocked_scales = to_blocked(scaled_block_scales_fp8, flatten=False)
|
||||
return data_lp, blocked_scales
|
||||
return output_fp4, to_blocked(output_block, flatten=False)
|
||||
|
||||
@ -104,7 +104,7 @@ class TensorCoreNVFP4Layout(_CKNvfp4Layout):
|
||||
needs_padding = padded_shape != orig_shape
|
||||
|
||||
if stochastic_rounding > 0:
|
||||
qdata, block_scale = comfy.float.stochastic_round_quantize_nvfp4(tensor, scale, pad_16x=needs_padding, seed=stochastic_rounding)
|
||||
qdata, block_scale = comfy.float.stochastic_round_quantize_nvfp4_by_block(tensor, scale, pad_16x=needs_padding, seed=stochastic_rounding)
|
||||
else:
|
||||
qdata, block_scale = ck.quantize_nvfp4(tensor, scale, pad_16x=needs_padding)
|
||||
|
||||
|
||||
@ -1042,7 +1042,7 @@ class ZImage(Lumina2):
|
||||
"shift": 3.0,
|
||||
}
|
||||
|
||||
memory_usage_factor = 2.0
|
||||
memory_usage_factor = 2.8
|
||||
|
||||
supported_inference_dtypes = [torch.bfloat16, torch.float32]
|
||||
|
||||
|
||||
@ -118,8 +118,9 @@ class LTXAVTEModel(torch.nn.Module):
|
||||
sdo = comfy.utils.state_dict_prefix_replace(sd, {"text_embedding_projection.aggregate_embed.weight": "text_embedding_projection.weight", "model.diffusion_model.video_embeddings_connector.": "video_embeddings_connector.", "model.diffusion_model.audio_embeddings_connector.": "audio_embeddings_connector."}, filter_keys=True)
|
||||
if len(sdo) == 0:
|
||||
sdo = sd
|
||||
|
||||
return self.load_state_dict(sdo, strict=False)
|
||||
missing, unexpected = self.load_state_dict(sdo, strict=False)
|
||||
missing = [k for k in missing if not k.startswith("gemma3_12b.")] # filter out keys that belong to the main gemma model
|
||||
return (missing, unexpected)
|
||||
|
||||
def memory_estimation_function(self, token_weight_pairs, device=None):
|
||||
constant = 6.0
|
||||
|
||||
@ -30,6 +30,7 @@ from torch.nn.functional import interpolate
|
||||
from einops import rearrange
|
||||
from comfy.cli_args import args
|
||||
import json
|
||||
import time
|
||||
|
||||
MMAP_TORCH_FILES = args.mmap_torch_files
|
||||
DISABLE_MMAP = args.disable_mmap
|
||||
@ -928,7 +929,9 @@ def bislerp(samples, width, height):
|
||||
return result.to(orig_dtype)
|
||||
|
||||
def lanczos(samples, width, height):
|
||||
images = [Image.fromarray(np.clip(255. * image.movedim(0, -1).cpu().numpy(), 0, 255).astype(np.uint8)) for image in samples]
|
||||
#the below API is strict and expects grayscale to be squeezed
|
||||
samples = samples.squeeze(1) if samples.shape[1] == 1 else samples.movedim(1, -1)
|
||||
images = [Image.fromarray(np.clip(255. * image.cpu().numpy(), 0, 255).astype(np.uint8)) for image in samples]
|
||||
images = [image.resize((width, height), resample=Image.Resampling.LANCZOS) for image in images]
|
||||
images = [torch.from_numpy(np.array(image).astype(np.float32) / 255.0).movedim(-1, 0) for image in images]
|
||||
result = torch.stack(images)
|
||||
@ -1097,6 +1100,10 @@ def set_progress_bar_global_hook(function):
|
||||
global PROGRESS_BAR_HOOK
|
||||
PROGRESS_BAR_HOOK = function
|
||||
|
||||
# Throttle settings for progress bar updates to reduce WebSocket flooding
|
||||
PROGRESS_THROTTLE_MIN_INTERVAL = 0.1 # 100ms minimum between updates
|
||||
PROGRESS_THROTTLE_MIN_PERCENT = 0.5 # 0.5% minimum progress change
|
||||
|
||||
class ProgressBar:
|
||||
def __init__(self, total, node_id=None):
|
||||
global PROGRESS_BAR_HOOK
|
||||
@ -1104,6 +1111,8 @@ class ProgressBar:
|
||||
self.current = 0
|
||||
self.hook = PROGRESS_BAR_HOOK
|
||||
self.node_id = node_id
|
||||
self._last_update_time = 0.0
|
||||
self._last_sent_value = -1
|
||||
|
||||
def update_absolute(self, value, total=None, preview=None):
|
||||
if total is not None:
|
||||
@ -1112,7 +1121,29 @@ class ProgressBar:
|
||||
value = self.total
|
||||
self.current = value
|
||||
if self.hook is not None:
|
||||
self.hook(self.current, self.total, preview, node_id=self.node_id)
|
||||
current_time = time.perf_counter()
|
||||
is_first = (self._last_sent_value < 0)
|
||||
is_final = (value >= self.total)
|
||||
has_preview = (preview is not None)
|
||||
|
||||
# Always send immediately for previews, first update, or final update
|
||||
if has_preview or is_first or is_final:
|
||||
self.hook(self.current, self.total, preview, node_id=self.node_id)
|
||||
self._last_update_time = current_time
|
||||
self._last_sent_value = value
|
||||
return
|
||||
|
||||
# Apply throttling for regular progress updates
|
||||
if self.total > 0:
|
||||
percent_changed = ((value - max(0, self._last_sent_value)) / self.total) * 100
|
||||
else:
|
||||
percent_changed = 100
|
||||
time_elapsed = current_time - self._last_update_time
|
||||
|
||||
if time_elapsed >= PROGRESS_THROTTLE_MIN_INTERVAL and percent_changed >= PROGRESS_THROTTLE_MIN_PERCENT:
|
||||
self.hook(self.current, self.total, preview, node_id=self.node_id)
|
||||
self._last_update_time = current_time
|
||||
self._last_sent_value = value
|
||||
|
||||
def update(self, value):
|
||||
self.update_absolute(self.current + value)
|
||||
|
||||
160
comfy_api_nodes/apis/meshy.py
Normal file
160
comfy_api_nodes/apis/meshy.py
Normal file
@ -0,0 +1,160 @@
|
||||
from typing import TypedDict
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from comfy_api.latest import Input
|
||||
|
||||
|
||||
class InputShouldRemesh(TypedDict):
|
||||
should_remesh: str
|
||||
topology: str
|
||||
target_polycount: int
|
||||
|
||||
|
||||
class InputShouldTexture(TypedDict):
|
||||
should_texture: str
|
||||
enable_pbr: bool
|
||||
texture_prompt: str
|
||||
texture_image: Input.Image | None
|
||||
|
||||
|
||||
class MeshyTaskResponse(BaseModel):
|
||||
result: str = Field(...)
|
||||
|
||||
|
||||
class MeshyTextToModelRequest(BaseModel):
|
||||
mode: str = Field("preview")
|
||||
prompt: str = Field(..., max_length=600)
|
||||
art_style: str = Field(..., description="'realistic' or 'sculpture'")
|
||||
ai_model: str = Field(...)
|
||||
topology: str | None = Field(..., description="'quad' or 'triangle'")
|
||||
target_polycount: int | None = Field(..., ge=100, le=300000)
|
||||
should_remesh: bool = Field(
|
||||
True,
|
||||
description="False returns the original mesh, ignoring topology and polycount.",
|
||||
)
|
||||
symmetry_mode: str = Field(..., description="'auto', 'off' or 'on'")
|
||||
pose_mode: str = Field(...)
|
||||
seed: int = Field(...)
|
||||
moderation: bool = Field(False)
|
||||
|
||||
|
||||
class MeshyRefineTask(BaseModel):
|
||||
mode: str = Field("refine")
|
||||
preview_task_id: str = Field(...)
|
||||
enable_pbr: bool | None = Field(...)
|
||||
texture_prompt: str | None = Field(...)
|
||||
texture_image_url: str | None = Field(...)
|
||||
ai_model: str = Field(...)
|
||||
moderation: bool = Field(False)
|
||||
|
||||
|
||||
class MeshyImageToModelRequest(BaseModel):
|
||||
image_url: str = Field(...)
|
||||
ai_model: str = Field(...)
|
||||
topology: str | None = Field(..., description="'quad' or 'triangle'")
|
||||
target_polycount: int | None = Field(..., ge=100, le=300000)
|
||||
symmetry_mode: str = Field(..., description="'auto', 'off' or 'on'")
|
||||
should_remesh: bool = Field(
|
||||
True,
|
||||
description="False returns the original mesh, ignoring topology and polycount.",
|
||||
)
|
||||
should_texture: bool = Field(...)
|
||||
enable_pbr: bool | None = Field(...)
|
||||
pose_mode: str = Field(...)
|
||||
texture_prompt: str | None = Field(None, max_length=600)
|
||||
texture_image_url: str | None = Field(None)
|
||||
seed: int = Field(...)
|
||||
moderation: bool = Field(False)
|
||||
|
||||
|
||||
class MeshyMultiImageToModelRequest(BaseModel):
|
||||
image_urls: list[str] = Field(...)
|
||||
ai_model: str = Field(...)
|
||||
topology: str | None = Field(..., description="'quad' or 'triangle'")
|
||||
target_polycount: int | None = Field(..., ge=100, le=300000)
|
||||
symmetry_mode: str = Field(..., description="'auto', 'off' or 'on'")
|
||||
should_remesh: bool = Field(
|
||||
True,
|
||||
description="False returns the original mesh, ignoring topology and polycount.",
|
||||
)
|
||||
should_texture: bool = Field(...)
|
||||
enable_pbr: bool | None = Field(...)
|
||||
pose_mode: str = Field(...)
|
||||
texture_prompt: str | None = Field(None, max_length=600)
|
||||
texture_image_url: str | None = Field(None)
|
||||
seed: int = Field(...)
|
||||
moderation: bool = Field(False)
|
||||
|
||||
|
||||
class MeshyRiggingRequest(BaseModel):
|
||||
input_task_id: str = Field(...)
|
||||
height_meters: float = Field(...)
|
||||
texture_image_url: str | None = Field(...)
|
||||
|
||||
|
||||
class MeshyAnimationRequest(BaseModel):
|
||||
rig_task_id: str = Field(...)
|
||||
action_id: int = Field(...)
|
||||
|
||||
|
||||
class MeshyTextureRequest(BaseModel):
|
||||
input_task_id: str = Field(...)
|
||||
ai_model: str = Field(...)
|
||||
enable_original_uv: bool = Field(...)
|
||||
enable_pbr: bool = Field(...)
|
||||
text_style_prompt: str | None = Field(...)
|
||||
image_style_url: str | None = Field(...)
|
||||
|
||||
|
||||
class MeshyModelsUrls(BaseModel):
|
||||
glb: str = Field("")
|
||||
|
||||
|
||||
class MeshyRiggedModelsUrls(BaseModel):
|
||||
rigged_character_glb_url: str = Field("")
|
||||
|
||||
|
||||
class MeshyAnimatedModelsUrls(BaseModel):
|
||||
animation_glb_url: str = Field("")
|
||||
|
||||
|
||||
class MeshyResultTextureUrls(BaseModel):
|
||||
base_color: str = Field(...)
|
||||
metallic: str | None = Field(None)
|
||||
normal: str | None = Field(None)
|
||||
roughness: str | None = Field(None)
|
||||
|
||||
|
||||
class MeshyTaskError(BaseModel):
|
||||
message: str | None = Field(None)
|
||||
|
||||
|
||||
class MeshyModelResult(BaseModel):
|
||||
id: str = Field(...)
|
||||
type: str = Field(...)
|
||||
model_urls: MeshyModelsUrls = Field(MeshyModelsUrls())
|
||||
thumbnail_url: str = Field(...)
|
||||
video_url: str | None = Field(None)
|
||||
status: str = Field(...)
|
||||
progress: int = Field(0)
|
||||
texture_urls: list[MeshyResultTextureUrls] | None = Field([])
|
||||
task_error: MeshyTaskError | None = Field(None)
|
||||
|
||||
|
||||
class MeshyRiggedResult(BaseModel):
|
||||
id: str = Field(...)
|
||||
type: str = Field(...)
|
||||
status: str = Field(...)
|
||||
progress: int = Field(0)
|
||||
result: MeshyRiggedModelsUrls = Field(MeshyRiggedModelsUrls())
|
||||
task_error: MeshyTaskError | None = Field(None)
|
||||
|
||||
|
||||
class MeshyAnimationResult(BaseModel):
|
||||
id: str = Field(...)
|
||||
type: str = Field(...)
|
||||
status: str = Field(...)
|
||||
progress: int = Field(0)
|
||||
result: MeshyAnimatedModelsUrls = Field(MeshyAnimatedModelsUrls())
|
||||
task_error: MeshyTaskError | None = Field(None)
|
||||
790
comfy_api_nodes/nodes_meshy.py
Normal file
790
comfy_api_nodes/nodes_meshy.py
Normal file
@ -0,0 +1,790 @@
|
||||
import os
|
||||
|
||||
from typing_extensions import override
|
||||
|
||||
from comfy_api.latest import IO, ComfyExtension, Input
|
||||
from comfy_api_nodes.apis.meshy import (
|
||||
InputShouldRemesh,
|
||||
InputShouldTexture,
|
||||
MeshyAnimationRequest,
|
||||
MeshyAnimationResult,
|
||||
MeshyImageToModelRequest,
|
||||
MeshyModelResult,
|
||||
MeshyMultiImageToModelRequest,
|
||||
MeshyRefineTask,
|
||||
MeshyRiggedResult,
|
||||
MeshyRiggingRequest,
|
||||
MeshyTaskResponse,
|
||||
MeshyTextToModelRequest,
|
||||
MeshyTextureRequest,
|
||||
)
|
||||
from comfy_api_nodes.util import (
|
||||
ApiEndpoint,
|
||||
download_url_to_bytesio,
|
||||
poll_op,
|
||||
sync_op,
|
||||
upload_images_to_comfyapi,
|
||||
validate_string,
|
||||
)
|
||||
from folder_paths import get_output_directory
|
||||
|
||||
|
||||
class MeshyTextToModelNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="MeshyTextToModelNode",
|
||||
display_name="Meshy: Text to Model",
|
||||
category="api node/3d/Meshy",
|
||||
inputs=[
|
||||
IO.Combo.Input("model", options=["latest"]),
|
||||
IO.String.Input("prompt", multiline=True, default=""),
|
||||
IO.Combo.Input("style", options=["realistic", "sculpture"]),
|
||||
IO.DynamicCombo.Input(
|
||||
"should_remesh",
|
||||
options=[
|
||||
IO.DynamicCombo.Option(
|
||||
"true",
|
||||
[
|
||||
IO.Combo.Input("topology", options=["triangle", "quad"]),
|
||||
IO.Int.Input(
|
||||
"target_polycount",
|
||||
default=300000,
|
||||
min=100,
|
||||
max=300000,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
IO.DynamicCombo.Option("false", []),
|
||||
],
|
||||
tooltip="When set to false, returns an unprocessed triangular mesh.",
|
||||
),
|
||||
IO.Combo.Input("symmetry_mode", options=["auto", "on", "off"]),
|
||||
IO.Combo.Input(
|
||||
"pose_mode",
|
||||
options=["", "A-pose", "T-pose"],
|
||||
tooltip="Specify the pose mode for the generated model.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
control_after_generate=True,
|
||||
tooltip="Seed controls whether the node should re-run; "
|
||||
"results are non-deterministic regardless of seed.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.String.Output(display_name="model_file"),
|
||||
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
is_output_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
expr="""{"type":"usd","usd":0.8}""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
model: str,
|
||||
prompt: str,
|
||||
style: str,
|
||||
should_remesh: InputShouldRemesh,
|
||||
symmetry_mode: str,
|
||||
pose_mode: str,
|
||||
seed: int,
|
||||
) -> IO.NodeOutput:
|
||||
validate_string(prompt, field_name="prompt", min_length=1, max_length=600)
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/meshy/openapi/v2/text-to-3d", method="POST"),
|
||||
response_model=MeshyTaskResponse,
|
||||
data=MeshyTextToModelRequest(
|
||||
prompt=prompt,
|
||||
art_style=style,
|
||||
ai_model=model,
|
||||
topology=should_remesh.get("topology", None),
|
||||
target_polycount=should_remesh.get("target_polycount", None),
|
||||
should_remesh=should_remesh["should_remesh"] == "true",
|
||||
symmetry_mode=symmetry_mode,
|
||||
pose_mode=pose_mode.lower(),
|
||||
seed=seed,
|
||||
),
|
||||
)
|
||||
result = await poll_op(
|
||||
cls,
|
||||
ApiEndpoint(path=f"/proxy/meshy/openapi/v2/text-to-3d/{response.result}"),
|
||||
response_model=MeshyModelResult,
|
||||
status_extractor=lambda r: r.status,
|
||||
progress_extractor=lambda r: r.progress,
|
||||
)
|
||||
model_file = f"meshy_model_{response.result}.glb"
|
||||
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file))
|
||||
return IO.NodeOutput(model_file, response.result)
|
||||
|
||||
|
||||
class MeshyRefineNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="MeshyRefineNode",
|
||||
display_name="Meshy: Refine Draft Model",
|
||||
category="api node/3d/Meshy",
|
||||
description="Refine a previously created draft model.",
|
||||
inputs=[
|
||||
IO.Combo.Input("model", options=["latest"]),
|
||||
IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"),
|
||||
IO.Boolean.Input(
|
||||
"enable_pbr",
|
||||
default=False,
|
||||
tooltip="Generate PBR Maps (metallic, roughness, normal) in addition to the base color. "
|
||||
"Note: this should be set to false when using Sculpture style, "
|
||||
"as Sculpture style generates its own set of PBR maps.",
|
||||
),
|
||||
IO.String.Input(
|
||||
"texture_prompt",
|
||||
default="",
|
||||
multiline=True,
|
||||
tooltip="Provide a text prompt to guide the texturing process. "
|
||||
"Maximum 600 characters. Cannot be used at the same time as 'texture_image'.",
|
||||
),
|
||||
IO.Image.Input(
|
||||
"texture_image",
|
||||
tooltip="Only one of 'texture_image' or 'texture_prompt' may be used at the same time.",
|
||||
optional=True,
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.String.Output(display_name="model_file"),
|
||||
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
is_output_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
expr="""{"type":"usd","usd":0.4}""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
model: str,
|
||||
meshy_task_id: str,
|
||||
enable_pbr: bool,
|
||||
texture_prompt: str,
|
||||
texture_image: Input.Image | None = None,
|
||||
) -> IO.NodeOutput:
|
||||
if texture_prompt and texture_image is not None:
|
||||
raise ValueError("texture_prompt and texture_image cannot be used at the same time")
|
||||
texture_image_url = None
|
||||
if texture_prompt:
|
||||
validate_string(texture_prompt, field_name="texture_prompt", max_length=600)
|
||||
if texture_image is not None:
|
||||
texture_image_url = (await upload_images_to_comfyapi(cls, texture_image, wait_label="Uploading texture"))[0]
|
||||
response = await sync_op(
|
||||
cls,
|
||||
endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v2/text-to-3d", method="POST"),
|
||||
response_model=MeshyTaskResponse,
|
||||
data=MeshyRefineTask(
|
||||
preview_task_id=meshy_task_id,
|
||||
enable_pbr=enable_pbr,
|
||||
texture_prompt=texture_prompt if texture_prompt else None,
|
||||
texture_image_url=texture_image_url,
|
||||
ai_model=model,
|
||||
),
|
||||
)
|
||||
result = await poll_op(
|
||||
cls,
|
||||
ApiEndpoint(path=f"/proxy/meshy/openapi/v2/text-to-3d/{response.result}"),
|
||||
response_model=MeshyModelResult,
|
||||
status_extractor=lambda r: r.status,
|
||||
progress_extractor=lambda r: r.progress,
|
||||
)
|
||||
model_file = f"meshy_model_{response.result}.glb"
|
||||
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file))
|
||||
return IO.NodeOutput(model_file, response.result)
|
||||
|
||||
|
||||
class MeshyImageToModelNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="MeshyImageToModelNode",
|
||||
display_name="Meshy: Image to Model",
|
||||
category="api node/3d/Meshy",
|
||||
inputs=[
|
||||
IO.Combo.Input("model", options=["latest"]),
|
||||
IO.Image.Input("image"),
|
||||
IO.DynamicCombo.Input(
|
||||
"should_remesh",
|
||||
options=[
|
||||
IO.DynamicCombo.Option(
|
||||
"true",
|
||||
[
|
||||
IO.Combo.Input("topology", options=["triangle", "quad"]),
|
||||
IO.Int.Input(
|
||||
"target_polycount",
|
||||
default=300000,
|
||||
min=100,
|
||||
max=300000,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
IO.DynamicCombo.Option("false", []),
|
||||
],
|
||||
tooltip="When set to false, returns an unprocessed triangular mesh.",
|
||||
),
|
||||
IO.Combo.Input("symmetry_mode", options=["auto", "on", "off"]),
|
||||
IO.DynamicCombo.Input(
|
||||
"should_texture",
|
||||
options=[
|
||||
IO.DynamicCombo.Option(
|
||||
"true",
|
||||
[
|
||||
IO.Boolean.Input(
|
||||
"enable_pbr",
|
||||
default=False,
|
||||
tooltip="Generate PBR Maps (metallic, roughness, normal) "
|
||||
"in addition to the base color.",
|
||||
),
|
||||
IO.String.Input(
|
||||
"texture_prompt",
|
||||
default="",
|
||||
multiline=True,
|
||||
tooltip="Provide a text prompt to guide the texturing process. "
|
||||
"Maximum 600 characters. Cannot be used at the same time as 'texture_image'.",
|
||||
),
|
||||
IO.Image.Input(
|
||||
"texture_image",
|
||||
tooltip="Only one of 'texture_image' or 'texture_prompt' "
|
||||
"may be used at the same time.",
|
||||
optional=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
IO.DynamicCombo.Option("false", []),
|
||||
],
|
||||
tooltip="Determines whether textures are generated. "
|
||||
"Setting it to false skips the texture phase and returns a mesh without textures.",
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"pose_mode",
|
||||
options=["", "A-pose", "T-pose"],
|
||||
tooltip="Specify the pose mode for the generated model.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
control_after_generate=True,
|
||||
tooltip="Seed controls whether the node should re-run; "
|
||||
"results are non-deterministic regardless of seed.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.String.Output(display_name="model_file"),
|
||||
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
is_output_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["should_texture"]),
|
||||
expr="""
|
||||
(
|
||||
$prices := {"true": 1.2, "false": 0.8};
|
||||
{"type":"usd","usd": $lookup($prices, widgets.should_texture)}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
model: str,
|
||||
image: Input.Image,
|
||||
should_remesh: InputShouldRemesh,
|
||||
symmetry_mode: str,
|
||||
should_texture: InputShouldTexture,
|
||||
pose_mode: str,
|
||||
seed: int,
|
||||
) -> IO.NodeOutput:
|
||||
texture = should_texture["should_texture"] == "true"
|
||||
texture_image_url = texture_prompt = None
|
||||
if texture:
|
||||
if should_texture["texture_prompt"] and should_texture["texture_image"] is not None:
|
||||
raise ValueError("texture_prompt and texture_image cannot be used at the same time")
|
||||
if should_texture["texture_prompt"]:
|
||||
validate_string(should_texture["texture_prompt"], field_name="texture_prompt", max_length=600)
|
||||
texture_prompt = should_texture["texture_prompt"]
|
||||
if should_texture["texture_image"] is not None:
|
||||
texture_image_url = (
|
||||
await upload_images_to_comfyapi(
|
||||
cls, should_texture["texture_image"], wait_label="Uploading texture"
|
||||
)
|
||||
)[0]
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/meshy/openapi/v1/image-to-3d", method="POST"),
|
||||
response_model=MeshyTaskResponse,
|
||||
data=MeshyImageToModelRequest(
|
||||
image_url=(await upload_images_to_comfyapi(cls, image, wait_label="Uploading base image"))[0],
|
||||
ai_model=model,
|
||||
topology=should_remesh.get("topology", None),
|
||||
target_polycount=should_remesh.get("target_polycount", None),
|
||||
symmetry_mode=symmetry_mode,
|
||||
should_remesh=should_remesh["should_remesh"] == "true",
|
||||
should_texture=texture,
|
||||
enable_pbr=should_texture.get("enable_pbr", None),
|
||||
pose_mode=pose_mode.lower(),
|
||||
texture_prompt=texture_prompt,
|
||||
texture_image_url=texture_image_url,
|
||||
seed=seed,
|
||||
),
|
||||
)
|
||||
result = await poll_op(
|
||||
cls,
|
||||
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/image-to-3d/{response.result}"),
|
||||
response_model=MeshyModelResult,
|
||||
status_extractor=lambda r: r.status,
|
||||
progress_extractor=lambda r: r.progress,
|
||||
)
|
||||
model_file = f"meshy_model_{response.result}.glb"
|
||||
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file))
|
||||
return IO.NodeOutput(model_file, response.result)
|
||||
|
||||
|
||||
class MeshyMultiImageToModelNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="MeshyMultiImageToModelNode",
|
||||
display_name="Meshy: Multi-Image to Model",
|
||||
category="api node/3d/Meshy",
|
||||
inputs=[
|
||||
IO.Combo.Input("model", options=["latest"]),
|
||||
IO.Autogrow.Input(
|
||||
"images",
|
||||
template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=2, max=4),
|
||||
),
|
||||
IO.DynamicCombo.Input(
|
||||
"should_remesh",
|
||||
options=[
|
||||
IO.DynamicCombo.Option(
|
||||
"true",
|
||||
[
|
||||
IO.Combo.Input("topology", options=["triangle", "quad"]),
|
||||
IO.Int.Input(
|
||||
"target_polycount",
|
||||
default=300000,
|
||||
min=100,
|
||||
max=300000,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
),
|
||||
],
|
||||
),
|
||||
IO.DynamicCombo.Option("false", []),
|
||||
],
|
||||
tooltip="When set to false, returns an unprocessed triangular mesh.",
|
||||
),
|
||||
IO.Combo.Input("symmetry_mode", options=["auto", "on", "off"]),
|
||||
IO.DynamicCombo.Input(
|
||||
"should_texture",
|
||||
options=[
|
||||
IO.DynamicCombo.Option(
|
||||
"true",
|
||||
[
|
||||
IO.Boolean.Input(
|
||||
"enable_pbr",
|
||||
default=False,
|
||||
tooltip="Generate PBR Maps (metallic, roughness, normal) "
|
||||
"in addition to the base color.",
|
||||
),
|
||||
IO.String.Input(
|
||||
"texture_prompt",
|
||||
default="",
|
||||
multiline=True,
|
||||
tooltip="Provide a text prompt to guide the texturing process. "
|
||||
"Maximum 600 characters. Cannot be used at the same time as 'texture_image'.",
|
||||
),
|
||||
IO.Image.Input(
|
||||
"texture_image",
|
||||
tooltip="Only one of 'texture_image' or 'texture_prompt' "
|
||||
"may be used at the same time.",
|
||||
optional=True,
|
||||
),
|
||||
],
|
||||
),
|
||||
IO.DynamicCombo.Option("false", []),
|
||||
],
|
||||
tooltip="Determines whether textures are generated. "
|
||||
"Setting it to false skips the texture phase and returns a mesh without textures.",
|
||||
),
|
||||
IO.Combo.Input(
|
||||
"pose_mode",
|
||||
options=["", "A-pose", "T-pose"],
|
||||
tooltip="Specify the pose mode for the generated model.",
|
||||
),
|
||||
IO.Int.Input(
|
||||
"seed",
|
||||
default=0,
|
||||
min=0,
|
||||
max=2147483647,
|
||||
display_mode=IO.NumberDisplay.number,
|
||||
control_after_generate=True,
|
||||
tooltip="Seed controls whether the node should re-run; "
|
||||
"results are non-deterministic regardless of seed.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.String.Output(display_name="model_file"),
|
||||
IO.Custom("MESHY_TASK_ID").Output(display_name="meshy_task_id"),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
is_output_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
depends_on=IO.PriceBadgeDepends(widgets=["should_texture"]),
|
||||
expr="""
|
||||
(
|
||||
$prices := {"true": 0.6, "false": 0.2};
|
||||
{"type":"usd","usd": $lookup($prices, widgets.should_texture)}
|
||||
)
|
||||
""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
model: str,
|
||||
images: IO.Autogrow.Type,
|
||||
should_remesh: InputShouldRemesh,
|
||||
symmetry_mode: str,
|
||||
should_texture: InputShouldTexture,
|
||||
pose_mode: str,
|
||||
seed: int,
|
||||
) -> IO.NodeOutput:
|
||||
texture = should_texture["should_texture"] == "true"
|
||||
texture_image_url = texture_prompt = None
|
||||
if texture:
|
||||
if should_texture["texture_prompt"] and should_texture["texture_image"] is not None:
|
||||
raise ValueError("texture_prompt and texture_image cannot be used at the same time")
|
||||
if should_texture["texture_prompt"]:
|
||||
validate_string(should_texture["texture_prompt"], field_name="texture_prompt", max_length=600)
|
||||
texture_prompt = should_texture["texture_prompt"]
|
||||
if should_texture["texture_image"] is not None:
|
||||
texture_image_url = (
|
||||
await upload_images_to_comfyapi(
|
||||
cls, should_texture["texture_image"], wait_label="Uploading texture"
|
||||
)
|
||||
)[0]
|
||||
response = await sync_op(
|
||||
cls,
|
||||
ApiEndpoint(path="/proxy/meshy/openapi/v1/multi-image-to-3d", method="POST"),
|
||||
response_model=MeshyTaskResponse,
|
||||
data=MeshyMultiImageToModelRequest(
|
||||
image_urls=await upload_images_to_comfyapi(
|
||||
cls, list(images.values()), wait_label="Uploading base images"
|
||||
),
|
||||
ai_model=model,
|
||||
topology=should_remesh.get("topology", None),
|
||||
target_polycount=should_remesh.get("target_polycount", None),
|
||||
symmetry_mode=symmetry_mode,
|
||||
should_remesh=should_remesh["should_remesh"] == "true",
|
||||
should_texture=texture,
|
||||
enable_pbr=should_texture.get("enable_pbr", None),
|
||||
pose_mode=pose_mode.lower(),
|
||||
texture_prompt=texture_prompt,
|
||||
texture_image_url=texture_image_url,
|
||||
seed=seed,
|
||||
),
|
||||
)
|
||||
result = await poll_op(
|
||||
cls,
|
||||
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/multi-image-to-3d/{response.result}"),
|
||||
response_model=MeshyModelResult,
|
||||
status_extractor=lambda r: r.status,
|
||||
progress_extractor=lambda r: r.progress,
|
||||
)
|
||||
model_file = f"meshy_model_{response.result}.glb"
|
||||
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file))
|
||||
return IO.NodeOutput(model_file, response.result)
|
||||
|
||||
|
||||
class MeshyRigModelNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="MeshyRigModelNode",
|
||||
display_name="Meshy: Rig Model",
|
||||
category="api node/3d/Meshy",
|
||||
description="Provides a rigged character in standard formats. "
|
||||
"Auto-rigging is currently not suitable for untextured meshes, non-humanoid assets, "
|
||||
"or humanoid assets with unclear limb and body structure.",
|
||||
inputs=[
|
||||
IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"),
|
||||
IO.Float.Input(
|
||||
"height_meters",
|
||||
min=0.1,
|
||||
max=15.0,
|
||||
default=1.7,
|
||||
tooltip="The approximate height of the character model in meters. "
|
||||
"This aids in scaling and rigging accuracy.",
|
||||
),
|
||||
IO.Image.Input(
|
||||
"texture_image",
|
||||
tooltip="The model's UV-unwrapped base color texture image.",
|
||||
optional=True,
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.String.Output(display_name="model_file"),
|
||||
IO.Custom("MESHY_RIGGED_TASK_ID").Output(display_name="rig_task_id"),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
is_output_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
expr="""{"type":"usd","usd":0.2}""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
meshy_task_id: str,
|
||||
height_meters: float,
|
||||
texture_image: Input.Image | None = None,
|
||||
) -> IO.NodeOutput:
|
||||
texture_image_url = None
|
||||
if texture_image is not None:
|
||||
texture_image_url = (await upload_images_to_comfyapi(cls, texture_image, wait_label="Uploading texture"))[0]
|
||||
response = await sync_op(
|
||||
cls,
|
||||
endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v1/rigging", method="POST"),
|
||||
response_model=MeshyTaskResponse,
|
||||
data=MeshyRiggingRequest(
|
||||
input_task_id=meshy_task_id,
|
||||
height_meters=height_meters,
|
||||
texture_image_url=texture_image_url,
|
||||
),
|
||||
)
|
||||
result = await poll_op(
|
||||
cls,
|
||||
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/rigging/{response.result}"),
|
||||
response_model=MeshyRiggedResult,
|
||||
status_extractor=lambda r: r.status,
|
||||
progress_extractor=lambda r: r.progress,
|
||||
)
|
||||
model_file = f"meshy_model_{response.result}.glb"
|
||||
await download_url_to_bytesio(
|
||||
result.result.rigged_character_glb_url, os.path.join(get_output_directory(), model_file)
|
||||
)
|
||||
return IO.NodeOutput(model_file, response.result)
|
||||
|
||||
|
||||
class MeshyAnimateModelNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="MeshyAnimateModelNode",
|
||||
display_name="Meshy: Animate Model",
|
||||
category="api node/3d/Meshy",
|
||||
description="Apply a specific animation action to a previously rigged character.",
|
||||
inputs=[
|
||||
IO.Custom("MESHY_RIGGED_TASK_ID").Input("rig_task_id"),
|
||||
IO.Int.Input(
|
||||
"action_id",
|
||||
default=0,
|
||||
min=0,
|
||||
max=696,
|
||||
tooltip="Visit https://docs.meshy.ai/en/api/animation-library for a list of available values.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.String.Output(display_name="model_file"),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
is_output_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
expr="""{"type":"usd","usd":0.12}""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
rig_task_id: str,
|
||||
action_id: int,
|
||||
) -> IO.NodeOutput:
|
||||
response = await sync_op(
|
||||
cls,
|
||||
endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v1/animations", method="POST"),
|
||||
response_model=MeshyTaskResponse,
|
||||
data=MeshyAnimationRequest(
|
||||
rig_task_id=rig_task_id,
|
||||
action_id=action_id,
|
||||
),
|
||||
)
|
||||
result = await poll_op(
|
||||
cls,
|
||||
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/animations/{response.result}"),
|
||||
response_model=MeshyAnimationResult,
|
||||
status_extractor=lambda r: r.status,
|
||||
progress_extractor=lambda r: r.progress,
|
||||
)
|
||||
model_file = f"meshy_model_{response.result}.glb"
|
||||
await download_url_to_bytesio(result.result.animation_glb_url, os.path.join(get_output_directory(), model_file))
|
||||
return IO.NodeOutput(model_file, response.result)
|
||||
|
||||
|
||||
class MeshyTextureNode(IO.ComfyNode):
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
return IO.Schema(
|
||||
node_id="MeshyTextureNode",
|
||||
display_name="Meshy: Texture Model",
|
||||
category="api node/3d/Meshy",
|
||||
inputs=[
|
||||
IO.Combo.Input("model", options=["latest"]),
|
||||
IO.Custom("MESHY_TASK_ID").Input("meshy_task_id"),
|
||||
IO.Boolean.Input(
|
||||
"enable_original_uv",
|
||||
default=True,
|
||||
tooltip="Use the original UV of the model instead of generating new UVs. "
|
||||
"When enabled, Meshy preserves existing textures from the uploaded model. "
|
||||
"If the model has no original UV, the quality of the output might not be as good.",
|
||||
),
|
||||
IO.Boolean.Input("pbr", default=False),
|
||||
IO.String.Input(
|
||||
"text_style_prompt",
|
||||
default="",
|
||||
multiline=True,
|
||||
tooltip="Describe your desired texture style of the object using text. Maximum 600 characters."
|
||||
"Maximum 600 characters. Cannot be used at the same time as 'image_style'.",
|
||||
),
|
||||
IO.Image.Input(
|
||||
"image_style",
|
||||
optional=True,
|
||||
tooltip="A 2d image to guide the texturing process. "
|
||||
"Can not be used at the same time with 'text_style_prompt'.",
|
||||
),
|
||||
],
|
||||
outputs=[
|
||||
IO.String.Output(display_name="model_file"),
|
||||
IO.Custom("MODEL_TASK_ID").Output(display_name="meshy_task_id"),
|
||||
],
|
||||
hidden=[
|
||||
IO.Hidden.auth_token_comfy_org,
|
||||
IO.Hidden.api_key_comfy_org,
|
||||
IO.Hidden.unique_id,
|
||||
],
|
||||
is_api_node=True,
|
||||
is_output_node=True,
|
||||
price_badge=IO.PriceBadge(
|
||||
expr="""{"type":"usd","usd":0.4}""",
|
||||
),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
async def execute(
|
||||
cls,
|
||||
model: str,
|
||||
meshy_task_id: str,
|
||||
enable_original_uv: bool,
|
||||
pbr: bool,
|
||||
text_style_prompt: str,
|
||||
image_style: Input.Image | None = None,
|
||||
) -> IO.NodeOutput:
|
||||
if text_style_prompt and image_style is not None:
|
||||
raise ValueError("text_style_prompt and image_style cannot be used at the same time")
|
||||
if not text_style_prompt and image_style is None:
|
||||
raise ValueError("Either text_style_prompt or image_style is required")
|
||||
image_style_url = None
|
||||
if image_style is not None:
|
||||
image_style_url = (await upload_images_to_comfyapi(cls, image_style, wait_label="Uploading style"))[0]
|
||||
response = await sync_op(
|
||||
cls,
|
||||
endpoint=ApiEndpoint(path="/proxy/meshy/openapi/v1/retexture", method="POST"),
|
||||
response_model=MeshyTaskResponse,
|
||||
data=MeshyTextureRequest(
|
||||
input_task_id=meshy_task_id,
|
||||
ai_model=model,
|
||||
enable_original_uv=enable_original_uv,
|
||||
enable_pbr=pbr,
|
||||
text_style_prompt=text_style_prompt if text_style_prompt else None,
|
||||
image_style_url=image_style_url,
|
||||
),
|
||||
)
|
||||
result = await poll_op(
|
||||
cls,
|
||||
ApiEndpoint(path=f"/proxy/meshy/openapi/v1/retexture/{response.result}"),
|
||||
response_model=MeshyModelResult,
|
||||
status_extractor=lambda r: r.status,
|
||||
progress_extractor=lambda r: r.progress,
|
||||
)
|
||||
model_file = f"meshy_model_{response.result}.glb"
|
||||
await download_url_to_bytesio(result.model_urls.glb, os.path.join(get_output_directory(), model_file))
|
||||
return IO.NodeOutput(model_file, response.result)
|
||||
|
||||
|
||||
class MeshyExtension(ComfyExtension):
|
||||
@override
|
||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||
return [
|
||||
MeshyTextToModelNode,
|
||||
MeshyRefineNode,
|
||||
MeshyImageToModelNode,
|
||||
MeshyMultiImageToModelNode,
|
||||
MeshyRigModelNode,
|
||||
MeshyAnimateModelNode,
|
||||
MeshyTextureNode,
|
||||
]
|
||||
|
||||
|
||||
async def comfy_entrypoint() -> MeshyExtension:
|
||||
return MeshyExtension()
|
||||
@ -43,7 +43,7 @@ class UploadResponse(BaseModel):
|
||||
|
||||
async def upload_images_to_comfyapi(
|
||||
cls: type[IO.ComfyNode],
|
||||
image: torch.Tensor,
|
||||
image: torch.Tensor | list[torch.Tensor],
|
||||
*,
|
||||
max_images: int = 8,
|
||||
mime_type: str | None = None,
|
||||
@ -55,15 +55,28 @@ async def upload_images_to_comfyapi(
|
||||
Uploads images to ComfyUI API and returns download URLs.
|
||||
To upload multiple images, stack them in the batch dimension first.
|
||||
"""
|
||||
tensors: list[torch.Tensor] = []
|
||||
if isinstance(image, list):
|
||||
for img in image:
|
||||
is_batch = len(img.shape) > 3
|
||||
if is_batch:
|
||||
tensors.extend(img[i] for i in range(img.shape[0]))
|
||||
else:
|
||||
tensors.append(img)
|
||||
else:
|
||||
is_batch = len(image.shape) > 3
|
||||
if is_batch:
|
||||
tensors.extend(image[i] for i in range(image.shape[0]))
|
||||
else:
|
||||
tensors.append(image)
|
||||
|
||||
# if batched, try to upload each file if max_images is greater than 0
|
||||
download_urls: list[str] = []
|
||||
is_batch = len(image.shape) > 3
|
||||
batch_len = image.shape[0] if is_batch else 1
|
||||
num_to_upload = min(batch_len, max_images)
|
||||
num_to_upload = min(len(tensors), max_images)
|
||||
batch_start_ts = time.monotonic()
|
||||
|
||||
for idx in range(num_to_upload):
|
||||
tensor = image[idx] if is_batch else image
|
||||
tensor = tensors[idx]
|
||||
img_io = tensor_to_bytesio(tensor, total_pixels=total_pixels, mime_type=mime_type)
|
||||
|
||||
effective_label = wait_label
|
||||
|
||||
@ -254,6 +254,7 @@ class ResizeType(str, Enum):
|
||||
SCALE_HEIGHT = "scale height"
|
||||
SCALE_TOTAL_PIXELS = "scale total pixels"
|
||||
MATCH_SIZE = "match size"
|
||||
SCALE_TO_MULTIPLE = "scale to multiple"
|
||||
|
||||
def is_image(input: torch.Tensor) -> bool:
|
||||
# images have 4 dimensions: [batch, height, width, channels]
|
||||
@ -328,7 +329,7 @@ def scale_shorter_dimension(input: torch.Tensor, shorter_size: int, scale_method
|
||||
if height < width:
|
||||
width = round((width / height) * shorter_size)
|
||||
height = shorter_size
|
||||
elif width > height:
|
||||
elif width < height:
|
||||
height = round((height / width) * shorter_size)
|
||||
width = shorter_size
|
||||
else:
|
||||
@ -363,6 +364,43 @@ def scale_match_size(input: torch.Tensor, match: torch.Tensor, scale_method: str
|
||||
input = finalize_image_mask_input(input, is_type_image)
|
||||
return input
|
||||
|
||||
def scale_to_multiple_cover(input: torch.Tensor, multiple: int, scale_method: str) -> torch.Tensor:
|
||||
if multiple <= 1:
|
||||
return input
|
||||
is_type_image = is_image(input)
|
||||
if is_type_image:
|
||||
_, height, width, _ = input.shape
|
||||
else:
|
||||
_, height, width = input.shape
|
||||
target_w = (width // multiple) * multiple
|
||||
target_h = (height // multiple) * multiple
|
||||
if target_w == 0 or target_h == 0:
|
||||
return input
|
||||
if target_w == width and target_h == height:
|
||||
return input
|
||||
s_w = target_w / width
|
||||
s_h = target_h / height
|
||||
if s_w >= s_h:
|
||||
scaled_w = target_w
|
||||
scaled_h = int(math.ceil(height * s_w))
|
||||
if scaled_h < target_h:
|
||||
scaled_h = target_h
|
||||
else:
|
||||
scaled_h = target_h
|
||||
scaled_w = int(math.ceil(width * s_h))
|
||||
if scaled_w < target_w:
|
||||
scaled_w = target_w
|
||||
input = init_image_mask_input(input, is_type_image)
|
||||
input = comfy.utils.common_upscale(input, scaled_w, scaled_h, scale_method, "disabled")
|
||||
input = finalize_image_mask_input(input, is_type_image)
|
||||
x0 = (scaled_w - target_w) // 2
|
||||
y0 = (scaled_h - target_h) // 2
|
||||
x1 = x0 + target_w
|
||||
y1 = y0 + target_h
|
||||
if is_type_image:
|
||||
return input[:, y0:y1, x0:x1, :]
|
||||
return input[:, y0:y1, x0:x1]
|
||||
|
||||
class ResizeImageMaskNode(io.ComfyNode):
|
||||
|
||||
scale_methods = ["nearest-exact", "bilinear", "area", "bicubic", "lanczos"]
|
||||
@ -378,6 +416,7 @@ class ResizeImageMaskNode(io.ComfyNode):
|
||||
longer_size: int
|
||||
shorter_size: int
|
||||
megapixels: float
|
||||
multiple: int
|
||||
|
||||
@classmethod
|
||||
def define_schema(cls):
|
||||
@ -417,6 +456,9 @@ class ResizeImageMaskNode(io.ComfyNode):
|
||||
io.MultiType.Input("match", [io.Image, io.Mask]),
|
||||
crop_combo,
|
||||
]),
|
||||
io.DynamicCombo.Option(ResizeType.SCALE_TO_MULTIPLE, [
|
||||
io.Int.Input("multiple", default=8, min=1, max=MAX_RESOLUTION, step=1),
|
||||
]),
|
||||
]),
|
||||
io.Combo.Input("scale_method", options=cls.scale_methods, default="area"),
|
||||
],
|
||||
@ -442,6 +484,8 @@ class ResizeImageMaskNode(io.ComfyNode):
|
||||
return io.NodeOutput(scale_total_pixels(input, resize_type["megapixels"], scale_method))
|
||||
elif selected_type == ResizeType.MATCH_SIZE:
|
||||
return io.NodeOutput(scale_match_size(input, resize_type["match"], scale_method, resize_type["crop"]))
|
||||
elif selected_type == ResizeType.SCALE_TO_MULTIPLE:
|
||||
return io.NodeOutput(scale_to_multiple_cover(input, resize_type["multiple"], scale_method))
|
||||
raise ValueError(f"Unsupported resize type: {selected_type}")
|
||||
|
||||
def batch_images(images: list[torch.Tensor]) -> torch.Tensor | None:
|
||||
|
||||
2
nodes.py
2
nodes.py
@ -788,6 +788,7 @@ class VAELoader:
|
||||
|
||||
#TODO: scale factor?
|
||||
def load_vae(self, vae_name):
|
||||
metadata = None
|
||||
if vae_name == "pixel_space":
|
||||
sd = {}
|
||||
sd["pixel_space_vae"] = torch.tensor(1.0)
|
||||
@ -2400,6 +2401,7 @@ async def init_builtin_api_nodes():
|
||||
"nodes_sora.py",
|
||||
"nodes_topaz.py",
|
||||
"nodes_tripo.py",
|
||||
"nodes_meshy.py",
|
||||
"nodes_moonvalley.py",
|
||||
"nodes_rodin.py",
|
||||
"nodes_gemini.py",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user