mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-23 17:48:05 +08:00
Compare commits
6 Commits
feature/cu
...
v0.22.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 85abace906 | |||
| f5d678d9ee | |||
| 59cafaf744 | |||
| 13e2d133a6 | |||
| ef46f5de76 | |||
| 7e02881b36 |
@ -35,6 +35,19 @@ class AnthropicMessage(BaseModel):
|
|||||||
content: list[AnthropicTextContent | AnthropicImageContent] = Field(...)
|
content: list[AnthropicTextContent | AnthropicImageContent] = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicThinkingConfig(BaseModel):
|
||||||
|
type: Literal["enabled", "disabled", "adaptive"] = Field(...)
|
||||||
|
budget_tokens: int | None = Field(
|
||||||
|
None, ge=1024,
|
||||||
|
description="Reasoning budget in tokens. Used when type is 'enabled'. Must be less than max_tokens.",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicOutputConfig(BaseModel):
|
||||||
|
"""Used with `thinking.type='adaptive'` on models like Opus 4.7."""
|
||||||
|
effort: Literal["low", "medium", "high"] | None = Field(None)
|
||||||
|
|
||||||
|
|
||||||
class AnthropicMessagesRequest(BaseModel):
|
class AnthropicMessagesRequest(BaseModel):
|
||||||
model: str = Field(...)
|
model: str = Field(...)
|
||||||
messages: list[AnthropicMessage] = Field(...)
|
messages: list[AnthropicMessage] = Field(...)
|
||||||
@ -44,6 +57,8 @@ class AnthropicMessagesRequest(BaseModel):
|
|||||||
top_p: float | None = Field(None, ge=0.0, le=1.0)
|
top_p: float | None = Field(None, ge=0.0, le=1.0)
|
||||||
top_k: int | None = Field(None, ge=0)
|
top_k: int | None = Field(None, ge=0)
|
||||||
stop_sequences: list[str] | None = Field(None)
|
stop_sequences: list[str] | None = Field(None)
|
||||||
|
thinking: AnthropicThinkingConfig | None = Field(None)
|
||||||
|
output_config: AnthropicOutputConfig | None = Field(None)
|
||||||
|
|
||||||
|
|
||||||
class AnthropicResponseTextBlock(BaseModel):
|
class AnthropicResponseTextBlock(BaseModel):
|
||||||
@ -51,6 +66,14 @@ class AnthropicResponseTextBlock(BaseModel):
|
|||||||
text: str = Field(...)
|
text: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class AnthropicResponseThinkingBlock(BaseModel):
|
||||||
|
type: Literal["thinking"] = "thinking"
|
||||||
|
thinking: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
AnthropicResponseBlock = AnthropicResponseTextBlock | AnthropicResponseThinkingBlock
|
||||||
|
|
||||||
|
|
||||||
class AnthropicCacheCreationUsage(BaseModel):
|
class AnthropicCacheCreationUsage(BaseModel):
|
||||||
ephemeral_5m_input_tokens: int | None = Field(None)
|
ephemeral_5m_input_tokens: int | None = Field(None)
|
||||||
ephemeral_1h_input_tokens: int | None = Field(None)
|
ephemeral_1h_input_tokens: int | None = Field(None)
|
||||||
@ -69,7 +92,7 @@ class AnthropicMessagesResponse(BaseModel):
|
|||||||
type: str | None = Field(None)
|
type: str | None = Field(None)
|
||||||
role: str | None = Field(None)
|
role: str | None = Field(None)
|
||||||
model: str | None = Field(None)
|
model: str | None = Field(None)
|
||||||
content: list[AnthropicResponseTextBlock] | None = Field(None)
|
content: list[AnthropicResponseBlock] | None = Field(None)
|
||||||
stop_reason: str | None = Field(None)
|
stop_reason: str | None = Field(None)
|
||||||
stop_sequence: str | None = Field(None)
|
stop_sequence: str | None = Field(None)
|
||||||
usage: AnthropicMessagesUsage | None = Field(None)
|
usage: AnthropicMessagesUsage | None = Field(None)
|
||||||
|
|||||||
93
comfy_api_nodes/apis/openrouter.py
Normal file
93
comfy_api_nodes/apis/openrouter.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""Pydantic models for the OpenRouter chat completions API.
|
||||||
|
|
||||||
|
See: https://openrouter.ai/docs/api/api-reference/chat/send-chat-completion-request
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterTextContent(BaseModel):
|
||||||
|
type: Literal["text"] = "text"
|
||||||
|
text: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterImageUrl(BaseModel):
|
||||||
|
url: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterImageContent(BaseModel):
|
||||||
|
type: Literal["image_url"] = "image_url"
|
||||||
|
image_url: OpenRouterImageUrl = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterVideoUrl(BaseModel):
|
||||||
|
url: str = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterVideoContent(BaseModel):
|
||||||
|
type: Literal["video_url"] = "video_url"
|
||||||
|
video_url: OpenRouterVideoUrl = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
OpenRouterContentBlock = OpenRouterTextContent | OpenRouterImageContent | OpenRouterVideoContent
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterMessage(BaseModel):
|
||||||
|
role: Literal["system", "user", "assistant"] = Field(...)
|
||||||
|
content: str | list[OpenRouterContentBlock] = Field(...)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterReasoningConfig(BaseModel):
|
||||||
|
effort: str | None = Field(None)
|
||||||
|
exclude: bool | None = Field(None, description="If true, model reasons but reasoning is excluded from response.")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterWebSearchOptions(BaseModel):
|
||||||
|
search_context_size: str | None = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterChatRequest(BaseModel):
|
||||||
|
model: str = Field(...)
|
||||||
|
messages: list[OpenRouterMessage] = Field(...)
|
||||||
|
seed: int | None = Field(None)
|
||||||
|
reasoning: OpenRouterReasoningConfig | None = Field(None)
|
||||||
|
web_search_options: OpenRouterWebSearchOptions | None = Field(None)
|
||||||
|
stream: bool = Field(False)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterUsage(BaseModel):
|
||||||
|
prompt_tokens: int | None = Field(None)
|
||||||
|
completion_tokens: int | None = Field(None)
|
||||||
|
total_tokens: int | None = Field(None)
|
||||||
|
cost: float | None = Field(None, description="Server-side authoritative USD cost of the call.")
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterResponseMessage(BaseModel):
|
||||||
|
role: str | None = Field(None)
|
||||||
|
content: str | None = Field(None)
|
||||||
|
reasoning: str | None = Field(None)
|
||||||
|
refusal: str | None = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterChoice(BaseModel):
|
||||||
|
index: int | None = Field(None)
|
||||||
|
message: OpenRouterResponseMessage | None = Field(None)
|
||||||
|
finish_reason: str | None = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterError(BaseModel):
|
||||||
|
code: int | str | None = Field(None)
|
||||||
|
message: str | None = Field(None)
|
||||||
|
metadata: dict | None = Field(None)
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterChatResponse(BaseModel):
|
||||||
|
id: str | None = Field(None)
|
||||||
|
model: str | None = Field(None)
|
||||||
|
object: str | None = Field(None)
|
||||||
|
provider: str | None = Field(None)
|
||||||
|
choices: list[OpenRouterChoice] | None = Field(None)
|
||||||
|
usage: OpenRouterUsage | None = Field(None)
|
||||||
|
error: OpenRouterError | None = Field(None)
|
||||||
@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
from typing import Optional, List
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
@ -11,44 +9,76 @@ class Rodin3DGenerateRequest(BaseModel):
|
|||||||
material: str = Field(..., description="The material type.")
|
material: str = Field(..., description="The material type.")
|
||||||
quality_override: int = Field(..., description="The poly count of the mesh.")
|
quality_override: int = Field(..., description="The poly count of the mesh.")
|
||||||
mesh_mode: str = Field(..., description="It controls the type of faces of generated models.")
|
mesh_mode: str = Field(..., description="It controls the type of faces of generated models.")
|
||||||
TAPose: Optional[bool] = Field(None, description="")
|
TAPose: bool | None = Field(None, description="")
|
||||||
|
|
||||||
|
|
||||||
|
class Rodin3DGen25Request(BaseModel):
|
||||||
|
|
||||||
|
tier: str = Field(..., description="Gen-2.5 tier (e.g. Gen-2.5-High).")
|
||||||
|
prompt: str | None = Field(None, description="Required for Text-to-3D; ignored otherwise.")
|
||||||
|
seed: int | None = Field(None, description="0-65535.")
|
||||||
|
material: str | None = Field(None, description="PBR | Shaded | All | None.")
|
||||||
|
geometry_file_format: str | None = Field(None, description="glb | usdz | fbx | obj | stl.")
|
||||||
|
texture_mode: str | None = Field(None, description="legacy | extreme-low | low | medium | high.")
|
||||||
|
mesh_mode: str | None = Field(None, description="Raw (triangular) | Quad.")
|
||||||
|
quality_override: int | None = Field(None, description="Mesh face count override.")
|
||||||
|
geometry_instruct_mode: str | None = Field(None, description="faithful | creative.")
|
||||||
|
bbox_condition: list[int] | None = Field(None, description="Bounding box [Width(Y), Height(Z), Length(X)] in cm.")
|
||||||
|
height: int | None = Field(None, description="Approximate model height in cm.")
|
||||||
|
TAPose: bool | None = Field(None, description="T/A pose for human-like models.")
|
||||||
|
hd_texture: bool | None = Field(None, description="Enhanced texture quality.")
|
||||||
|
texture_delight: bool | None = Field(None, description="Remove baked lighting from textures.")
|
||||||
|
is_micro: bool | None = Field(None, description="Micro detail (Extreme-High only).")
|
||||||
|
use_original_alpha: bool | None = Field(None, description="Preserve image transparency.")
|
||||||
|
preview_render: bool | None = Field(None, description="Generate high-quality preview render.")
|
||||||
|
addons: list[str] | None = Field(None, description='Optional addons, e.g. ["HighPack"].')
|
||||||
|
|
||||||
|
|
||||||
class GenerateJobsData(BaseModel):
|
class GenerateJobsData(BaseModel):
|
||||||
uuids: List[str] = Field(..., description="str LIST")
|
uuids: list[str] = Field(..., description="str LIST")
|
||||||
subscription_key: str = Field(..., description="subscription key")
|
subscription_key: str = Field(..., description="subscription key")
|
||||||
|
|
||||||
|
|
||||||
class Rodin3DGenerateResponse(BaseModel):
|
class Rodin3DGenerateResponse(BaseModel):
|
||||||
message: Optional[str] = Field(None, description="Return message.")
|
message: str | None = Field(None, description="Return message.")
|
||||||
prompt: Optional[str] = Field(None, description="Generated Prompt from image.")
|
prompt: str | None = Field(None, description="Generated Prompt from image.")
|
||||||
submit_time: Optional[str] = Field(None, description="Submit Time")
|
submit_time: str | None = Field(None, description="Submit Time")
|
||||||
uuid: Optional[str] = Field(None, description="Task str")
|
uuid: str | None = Field(None, description="Task str")
|
||||||
jobs: Optional[GenerateJobsData] = Field(None, description="Details of jobs")
|
jobs: GenerateJobsData | None = Field(None, description="Details of jobs")
|
||||||
|
|
||||||
|
|
||||||
class JobStatus(str, Enum):
|
class JobStatus(str, Enum):
|
||||||
"""
|
"""
|
||||||
Status for jobs
|
Status for jobs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Done = "Done"
|
Done = "Done"
|
||||||
Failed = "Failed"
|
Failed = "Failed"
|
||||||
Generating = "Generating"
|
Generating = "Generating"
|
||||||
Waiting = "Waiting"
|
Waiting = "Waiting"
|
||||||
|
|
||||||
|
|
||||||
class Rodin3DCheckStatusRequest(BaseModel):
|
class Rodin3DCheckStatusRequest(BaseModel):
|
||||||
subscription_key: str = Field(..., description="subscription from generate endpoint")
|
subscription_key: str = Field(..., description="subscription from generate endpoint")
|
||||||
|
|
||||||
|
|
||||||
class JobItem(BaseModel):
|
class JobItem(BaseModel):
|
||||||
uuid: str = Field(..., description="uuid")
|
uuid: str = Field(..., description="uuid")
|
||||||
status: JobStatus = Field(...,description="Status Currently")
|
status: JobStatus = Field(..., description="Status Currently")
|
||||||
|
|
||||||
|
|
||||||
class Rodin3DCheckStatusResponse(BaseModel):
|
class Rodin3DCheckStatusResponse(BaseModel):
|
||||||
jobs: List[JobItem] = Field(..., description="Job status List")
|
jobs: list[JobItem] = Field(..., description="Job status List")
|
||||||
|
|
||||||
|
|
||||||
class Rodin3DDownloadRequest(BaseModel):
|
class Rodin3DDownloadRequest(BaseModel):
|
||||||
task_uuid: str = Field(..., description="Task str")
|
task_uuid: str = Field(..., description="Task str")
|
||||||
|
|
||||||
|
|
||||||
class RodinResourceItem(BaseModel):
|
class RodinResourceItem(BaseModel):
|
||||||
url: str = Field(..., description="Download Url")
|
url: str = Field(..., description="Download Url")
|
||||||
name: str = Field(..., description="File name with ext")
|
name: str = Field(..., description="File name with ext")
|
||||||
|
|
||||||
|
|
||||||
class Rodin3DDownloadResponse(BaseModel):
|
class Rodin3DDownloadResponse(BaseModel):
|
||||||
list: List[RodinResourceItem] = Field(..., description="Source List")
|
items: list[RodinResourceItem] = Field(..., alias="list", description="Source List")
|
||||||
|
|||||||
@ -9,8 +9,11 @@ from comfy_api_nodes.apis.anthropic import (
|
|||||||
AnthropicMessage,
|
AnthropicMessage,
|
||||||
AnthropicMessagesRequest,
|
AnthropicMessagesRequest,
|
||||||
AnthropicMessagesResponse,
|
AnthropicMessagesResponse,
|
||||||
|
AnthropicOutputConfig,
|
||||||
|
AnthropicResponseTextBlock,
|
||||||
AnthropicRole,
|
AnthropicRole,
|
||||||
AnthropicTextContent,
|
AnthropicTextContent,
|
||||||
|
AnthropicThinkingConfig,
|
||||||
)
|
)
|
||||||
from comfy_api_nodes.util import (
|
from comfy_api_nodes.util import (
|
||||||
ApiEndpoint,
|
ApiEndpoint,
|
||||||
@ -32,15 +35,29 @@ CLAUDE_MODELS: dict[str, str] = {
|
|||||||
"Haiku 4.5": "claude-haiku-4-5-20251001",
|
"Haiku 4.5": "claude-haiku-4-5-20251001",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_THINKING_UNSUPPORTED = {"Haiku 4.5"}
|
||||||
|
# Models that use the newer "adaptive" thinking mode (Opus 4.7 requires it; older models keep the explicit budget API).
|
||||||
|
# Anthropic decides the actual budget when adaptive is used, based on the `output_config.effort` hint.
|
||||||
|
_ADAPTIVE_THINKING_MODELS = {"Opus 4.7", "Opus 4.6", "Sonnet 4.6"}
|
||||||
|
|
||||||
def _claude_model_inputs():
|
# Budget mode (Sonnet 4.5): effort -> reasoning budget in tokens. Must be < max_tokens.
|
||||||
return [
|
# Sized so even the "high" budget fits comfortably under the default max_tokens=32768.
|
||||||
|
_REASONING_BUDGET: dict[str, int] = {
|
||||||
|
"low": 2048,
|
||||||
|
"medium": 8192,
|
||||||
|
"high": 16384,
|
||||||
|
}
|
||||||
|
_REASONING_EFFORTS = ["off", "low", "medium", "high"]
|
||||||
|
|
||||||
|
|
||||||
|
def _claude_model_inputs(model_label: str):
|
||||||
|
inputs: list = [
|
||||||
IO.Int.Input(
|
IO.Int.Input(
|
||||||
"max_tokens",
|
"max_tokens",
|
||||||
default=16000,
|
default=32768,
|
||||||
min=32,
|
min=4096,
|
||||||
max=32000,
|
max=64000,
|
||||||
tooltip="Maximum number of tokens to generate before stopping.",
|
tooltip="Maximum number of tokens to generate (includes reasoning tokens when enabled).",
|
||||||
advanced=True,
|
advanced=True,
|
||||||
),
|
),
|
||||||
IO.Float.Input(
|
IO.Float.Input(
|
||||||
@ -49,10 +66,24 @@ def _claude_model_inputs():
|
|||||||
min=0.0,
|
min=0.0,
|
||||||
max=1.0,
|
max=1.0,
|
||||||
step=0.01,
|
step=0.01,
|
||||||
tooltip="Controls randomness. 0.0 is deterministic, 1.0 is most random. Ignored for Opus 4.7.",
|
tooltip=(
|
||||||
|
"Controls randomness. 0.0 is deterministic, 1.0 is most random. "
|
||||||
|
"Ignored for Opus 4.7 and any model when reasoning_effort is set."
|
||||||
|
),
|
||||||
advanced=True,
|
advanced=True,
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
if model_label not in _THINKING_UNSUPPORTED:
|
||||||
|
inputs.append(
|
||||||
|
IO.Combo.Input(
|
||||||
|
"reasoning_effort",
|
||||||
|
options=_REASONING_EFFORTS,
|
||||||
|
default="off",
|
||||||
|
tooltip="Extended thinking effort. 'off' disables reasoning.",
|
||||||
|
advanced=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
def _model_price_per_million(model: str) -> tuple[float, float] | None:
|
def _model_price_per_million(model: str) -> tuple[float, float] | None:
|
||||||
@ -95,7 +126,11 @@ def calculate_tokens_price(response: AnthropicMessagesResponse) -> float | None:
|
|||||||
def _get_text_from_response(response: AnthropicMessagesResponse) -> str:
|
def _get_text_from_response(response: AnthropicMessagesResponse) -> str:
|
||||||
if not response.content:
|
if not response.content:
|
||||||
return ""
|
return ""
|
||||||
return "\n".join(block.text for block in response.content if block.text)
|
# Thinking blocks are silently dropped — we never want reasoning in the output.
|
||||||
|
return "\n".join(
|
||||||
|
block.text for block in response.content
|
||||||
|
if isinstance(block, AnthropicResponseTextBlock) and block.text
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _build_image_content_blocks(
|
async def _build_image_content_blocks(
|
||||||
@ -133,7 +168,10 @@ class ClaudeNode(IO.ComfyNode):
|
|||||||
),
|
),
|
||||||
IO.DynamicCombo.Input(
|
IO.DynamicCombo.Input(
|
||||||
"model",
|
"model",
|
||||||
options=[IO.DynamicCombo.Option(label, _claude_model_inputs()) for label in CLAUDE_MODELS],
|
options=[
|
||||||
|
IO.DynamicCombo.Option(label, _claude_model_inputs(label))
|
||||||
|
for label in CLAUDE_MODELS
|
||||||
|
],
|
||||||
tooltip="The Claude model used to generate the response.",
|
tooltip="The Claude model used to generate the response.",
|
||||||
),
|
),
|
||||||
IO.Int.Input(
|
IO.Int.Input(
|
||||||
@ -207,8 +245,29 @@ class ClaudeNode(IO.ComfyNode):
|
|||||||
) -> IO.NodeOutput:
|
) -> IO.NodeOutput:
|
||||||
validate_string(prompt, strip_whitespace=True, min_length=1)
|
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||||||
model_label = model["model"]
|
model_label = model["model"]
|
||||||
max_tokens = model["max_tokens"]
|
max_tokens = model.get("max_tokens", 32768)
|
||||||
temperature = None if model_label == "Opus 4.7" else model["temperature"]
|
reasoning_effort = model.get("reasoning_effort", "off")
|
||||||
|
thinking_enabled = reasoning_effort not in ("off", None) and model_label not in _THINKING_UNSUPPORTED
|
||||||
|
|
||||||
|
# Anthropic requires temperature to be unset (defaults to 1.0) when thinking is enabled.
|
||||||
|
# Opus 4.7 also rejects user-supplied temperature.
|
||||||
|
if thinking_enabled or model_label == "Opus 4.7":
|
||||||
|
temperature = None
|
||||||
|
else:
|
||||||
|
temperature = model.get("temperature", 1.0)
|
||||||
|
|
||||||
|
thinking_cfg: AnthropicThinkingConfig | None = None
|
||||||
|
output_cfg: AnthropicOutputConfig | None = None
|
||||||
|
if thinking_enabled:
|
||||||
|
if model_label in _ADAPTIVE_THINKING_MODELS:
|
||||||
|
# Adaptive mode - Anthropic chooses the budget based on effort hint
|
||||||
|
thinking_cfg = AnthropicThinkingConfig(type="adaptive")
|
||||||
|
output_cfg = AnthropicOutputConfig(effort=reasoning_effort)
|
||||||
|
else:
|
||||||
|
# Budget mode (Sonnet 4.5). Leave at least 1024 tokens for the actual response
|
||||||
|
budget = _REASONING_BUDGET[reasoning_effort]
|
||||||
|
budget = min(budget, max(1024, max_tokens - 1024))
|
||||||
|
thinking_cfg = AnthropicThinkingConfig(type="enabled", budget_tokens=budget)
|
||||||
|
|
||||||
image_tensors: list[Input.Image] = [t for t in (images or {}).values() if t is not None]
|
image_tensors: list[Input.Image] = [t for t in (images or {}).values() if t is not None]
|
||||||
if sum(get_number_of_images(t) for t in image_tensors) > CLAUDE_MAX_IMAGES:
|
if sum(get_number_of_images(t) for t in image_tensors) > CLAUDE_MAX_IMAGES:
|
||||||
@ -229,6 +288,8 @@ class ClaudeNode(IO.ComfyNode):
|
|||||||
messages=[AnthropicMessage(role=AnthropicRole.user, content=content)],
|
messages=[AnthropicMessage(role=AnthropicRole.user, content=content)],
|
||||||
system=system_prompt or None,
|
system=system_prompt or None,
|
||||||
temperature=temperature,
|
temperature=temperature,
|
||||||
|
thinking=thinking_cfg,
|
||||||
|
output_config=output_cfg,
|
||||||
),
|
),
|
||||||
price_extractor=calculate_tokens_price,
|
price_extractor=calculate_tokens_price,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -43,15 +43,16 @@ from comfy_api_nodes.util import (
|
|||||||
ApiEndpoint,
|
ApiEndpoint,
|
||||||
download_url_to_image_tensor,
|
download_url_to_image_tensor,
|
||||||
download_url_to_video_output,
|
download_url_to_video_output,
|
||||||
|
downscale_video_to_max_pixels,
|
||||||
get_number_of_images,
|
get_number_of_images,
|
||||||
image_tensor_pair_to_batch,
|
image_tensor_pair_to_batch,
|
||||||
poll_op,
|
poll_op,
|
||||||
resize_video_to_pixel_budget,
|
|
||||||
sync_op,
|
sync_op,
|
||||||
upload_audio_to_comfyapi,
|
upload_audio_to_comfyapi,
|
||||||
upload_image_to_comfyapi,
|
upload_image_to_comfyapi,
|
||||||
upload_images_to_comfyapi,
|
upload_images_to_comfyapi,
|
||||||
upload_video_to_comfyapi,
|
upload_video_to_comfyapi,
|
||||||
|
upscale_video_to_min_pixels,
|
||||||
validate_image_aspect_ratio,
|
validate_image_aspect_ratio,
|
||||||
validate_image_dimensions,
|
validate_image_dimensions,
|
||||||
validate_string,
|
validate_string,
|
||||||
@ -110,12 +111,13 @@ def _validate_ref_video_pixels(video: Input.Video, model_id: str, resolution: st
|
|||||||
max_px = limits.get("max")
|
max_px = limits.get("max")
|
||||||
if min_px and pixels < min_px:
|
if min_px and pixels < min_px:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Reference video {index} is too small: {w}x{h} = {pixels:,}px. " f"Minimum is {min_px:,}px for this model."
|
f"Reference video {index} is too small: {w}x{h} = {pixels:,} total pixels. "
|
||||||
|
f"Minimum for this model is {min_px:,} total pixels."
|
||||||
)
|
)
|
||||||
if max_px and pixels > max_px:
|
if max_px and pixels > max_px:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"Reference video {index} is too large: {w}x{h} = {pixels:,}px. "
|
f"Reference video {index} is too large: {w}x{h} = {pixels:,} total pixels. "
|
||||||
f"Maximum is {max_px:,}px for this model. Try downscaling the video."
|
f"Maximum for this model is {max_px:,} total pixels. Try downscaling the video."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -1676,14 +1678,14 @@ class ByteDance2FirstLastFrameNode(IO.ComfyNode):
|
|||||||
"first_frame_asset_id",
|
"first_frame_asset_id",
|
||||||
default="",
|
default="",
|
||||||
tooltip="Seedance asset_id to use as the first frame. "
|
tooltip="Seedance asset_id to use as the first frame. "
|
||||||
"Mutually exclusive with the first_frame image input.",
|
"Mutually exclusive with the first_frame image input.",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
IO.String.Input(
|
IO.String.Input(
|
||||||
"last_frame_asset_id",
|
"last_frame_asset_id",
|
||||||
default="",
|
default="",
|
||||||
tooltip="Seedance asset_id to use as the last frame. "
|
tooltip="Seedance asset_id to use as the last frame. "
|
||||||
"Mutually exclusive with the last_frame image input.",
|
"Mutually exclusive with the last_frame image input.",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
IO.Int.Input(
|
IO.Int.Input(
|
||||||
@ -1865,11 +1867,20 @@ def _seedance2_reference_inputs(resolutions: list[str], default_ratio: str = "16
|
|||||||
IO.Boolean.Input(
|
IO.Boolean.Input(
|
||||||
"auto_downscale",
|
"auto_downscale",
|
||||||
default=False,
|
default=False,
|
||||||
advanced=True,
|
|
||||||
optional=True,
|
optional=True,
|
||||||
tooltip="Automatically downscale reference videos that exceed the model's pixel budget "
|
tooltip="Automatically downscale reference videos that exceed the model's pixel budget "
|
||||||
"for the selected resolution. Aspect ratio is preserved; videos already within limits are untouched.",
|
"for the selected resolution. Aspect ratio is preserved; videos already within limits are untouched.",
|
||||||
),
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"auto_upscale",
|
||||||
|
default=False,
|
||||||
|
advanced=True,
|
||||||
|
optional=True,
|
||||||
|
tooltip="Automatically upscale reference videos that are below the model's minimum pixel count "
|
||||||
|
"for the selected resolution. Aspect ratio is preserved; videos already meeting the minimum are "
|
||||||
|
"untouched. Note: upscaling a low-resolution source does not add real detail and may produce "
|
||||||
|
"lower-quality generations.",
|
||||||
|
),
|
||||||
IO.Autogrow.Input(
|
IO.Autogrow.Input(
|
||||||
"reference_assets",
|
"reference_assets",
|
||||||
template=IO.Autogrow.TemplateNames(
|
template=IO.Autogrow.TemplateNames(
|
||||||
@ -2030,7 +2041,13 @@ class ByteDance2ReferenceNode(IO.ComfyNode):
|
|||||||
max_px = SEEDANCE2_REF_VIDEO_PIXEL_LIMITS.get(model_id, {}).get(model["resolution"], {}).get("max")
|
max_px = SEEDANCE2_REF_VIDEO_PIXEL_LIMITS.get(model_id, {}).get(model["resolution"], {}).get("max")
|
||||||
if max_px:
|
if max_px:
|
||||||
for key in reference_videos:
|
for key in reference_videos:
|
||||||
reference_videos[key] = resize_video_to_pixel_budget(reference_videos[key], max_px)
|
reference_videos[key] = downscale_video_to_max_pixels(reference_videos[key], max_px)
|
||||||
|
|
||||||
|
if model.get("auto_upscale") and reference_videos:
|
||||||
|
min_px = SEEDANCE2_REF_VIDEO_PIXEL_LIMITS.get(model_id, {}).get(model["resolution"], {}).get("min")
|
||||||
|
if min_px:
|
||||||
|
for key in reference_videos:
|
||||||
|
reference_videos[key] = upscale_video_to_min_pixels(reference_videos[key], min_px)
|
||||||
|
|
||||||
total_video_duration = 0.0
|
total_video_duration = 0.0
|
||||||
for i, key in enumerate(reference_videos, 1):
|
for i, key in enumerate(reference_videos, 1):
|
||||||
|
|||||||
374
comfy_api_nodes/nodes_openrouter.py
Normal file
374
comfy_api_nodes/nodes_openrouter.py
Normal file
@ -0,0 +1,374 @@
|
|||||||
|
"""API Nodes for OpenRouter LLM chat completions."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from typing_extensions import override
|
||||||
|
|
||||||
|
from comfy_api.latest import IO, ComfyExtension, Input
|
||||||
|
from comfy_api_nodes.apis.openrouter import (
|
||||||
|
OpenRouterChatRequest,
|
||||||
|
OpenRouterChatResponse,
|
||||||
|
OpenRouterContentBlock,
|
||||||
|
OpenRouterImageContent,
|
||||||
|
OpenRouterImageUrl,
|
||||||
|
OpenRouterMessage,
|
||||||
|
OpenRouterReasoningConfig,
|
||||||
|
OpenRouterTextContent,
|
||||||
|
OpenRouterVideoContent,
|
||||||
|
OpenRouterVideoUrl,
|
||||||
|
OpenRouterWebSearchOptions,
|
||||||
|
)
|
||||||
|
from comfy_api_nodes.util import (
|
||||||
|
ApiEndpoint,
|
||||||
|
get_number_of_images,
|
||||||
|
sync_op,
|
||||||
|
upload_images_to_comfyapi,
|
||||||
|
upload_video_to_comfyapi,
|
||||||
|
validate_string,
|
||||||
|
)
|
||||||
|
|
||||||
|
OPENROUTER_CHAT_ENDPOINT = "/proxy/openrouter/api/v1/chat/completions"
|
||||||
|
|
||||||
|
|
||||||
|
Profile = Literal["standard", "reasoning", "frontier_reasoning", "perplexity", "perplexity_reasoning"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class _ModelSpec:
|
||||||
|
slug: str # exact OpenRouter model id
|
||||||
|
profile: Profile
|
||||||
|
price_in: float # USD per token (prompt)
|
||||||
|
price_out: float # USD per token (completion)
|
||||||
|
max_images: int = 0 # 0 = no image input; otherwise max URL-passed images supported
|
||||||
|
max_videos: int = 0 # 0 = no video input; otherwise max URL-passed videos supported
|
||||||
|
|
||||||
|
|
||||||
|
MODELS: list[_ModelSpec] = [
|
||||||
|
_ModelSpec("anthropic/claude-opus-4.7", "frontier_reasoning", 0.000005, 0.000025, max_images=20),
|
||||||
|
_ModelSpec("openai/gpt-5.5-pro", "frontier_reasoning", 0.00003, 0.00018, max_images=20),
|
||||||
|
_ModelSpec("openai/gpt-5.5", "frontier_reasoning", 0.000005, 0.00003, max_images=20),
|
||||||
|
_ModelSpec("google/gemini-3.5-flash", "reasoning", 0.0000015, 0.000009, max_images=20, max_videos=4),
|
||||||
|
_ModelSpec("x-ai/grok-4.20", "reasoning", 0.00000125, 0.0000025, max_images=20),
|
||||||
|
_ModelSpec("x-ai/grok-4.3", "reasoning", 0.00000125, 0.0000025, max_images=20),
|
||||||
|
_ModelSpec("deepseek/deepseek-v4-pro", "reasoning", 0.000000435, 0.00000087),
|
||||||
|
_ModelSpec("deepseek/deepseek-v4-flash", "reasoning", 0.000000112, 0.000000224),
|
||||||
|
_ModelSpec("deepseek/deepseek-v3.2", "reasoning", 0.000000252, 0.000000378),
|
||||||
|
_ModelSpec("qwen/qwen3.6-max-preview", "reasoning", 0.00000104, 0.00000624),
|
||||||
|
_ModelSpec("qwen/qwen3.6-plus", "reasoning", 0.000000325, 0.00000195, max_images=10, max_videos=4),
|
||||||
|
_ModelSpec("qwen/qwen3.6-flash", "reasoning", 0.0000001875, 0.000001125, max_images=10, max_videos=4),
|
||||||
|
_ModelSpec("mistralai/mistral-large-2512", "standard", 0.0000005, 0.0000015, max_images=8),
|
||||||
|
_ModelSpec("mistralai/mistral-medium-3-5", "reasoning", 0.0000015, 0.0000075, max_images=8),
|
||||||
|
_ModelSpec("z-ai/glm-4.6", "reasoning", 0.00000043, 0.00000174),
|
||||||
|
_ModelSpec("z-ai/glm-5", "reasoning", 0.0000006, 0.00000192),
|
||||||
|
_ModelSpec("moonshotai/kimi-k2.6", "reasoning", 0.00000073, 0.00000349, max_images=10),
|
||||||
|
_ModelSpec("moonshotai/kimi-k2-thinking", "reasoning", 0.0000006, 0.0000025),
|
||||||
|
_ModelSpec("perplexity/sonar-pro", "perplexity", 0.000003, 0.000015),
|
||||||
|
_ModelSpec("perplexity/sonar-reasoning-pro", "perplexity_reasoning", 0.000002, 0.000008),
|
||||||
|
_ModelSpec("perplexity/sonar-deep-research", "perplexity_reasoning", 0.000002, 0.000008),
|
||||||
|
]
|
||||||
|
|
||||||
|
_MODELS_BY_SLUG: dict[str, _ModelSpec] = {m.slug: m for m in MODELS}
|
||||||
|
_REASONING_EFFORTS = ["off", "low", "medium", "high"]
|
||||||
|
_SEARCH_CONTEXT_SIZES = ["low", "medium", "high"]
|
||||||
|
|
||||||
|
|
||||||
|
def _reasoning_extra_inputs() -> list:
|
||||||
|
return [
|
||||||
|
IO.Combo.Input(
|
||||||
|
"reasoning_effort",
|
||||||
|
options=_REASONING_EFFORTS,
|
||||||
|
default="off",
|
||||||
|
tooltip="Reasoning effort. 'off' disables reasoning entirely.",
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _perplexity_extra_inputs() -> list:
|
||||||
|
return [
|
||||||
|
IO.Combo.Input(
|
||||||
|
"search_context_size",
|
||||||
|
options=_SEARCH_CONTEXT_SIZES,
|
||||||
|
default="medium",
|
||||||
|
tooltip="How much web search context to retrieve. Larger = more grounded but slower/pricier.",
|
||||||
|
advanced=True,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_inputs(profile: Profile) -> list:
|
||||||
|
if profile == "standard":
|
||||||
|
return []
|
||||||
|
if profile in ("reasoning", "frontier_reasoning"):
|
||||||
|
return _reasoning_extra_inputs()
|
||||||
|
if profile == "perplexity":
|
||||||
|
return _perplexity_extra_inputs()
|
||||||
|
if profile == "perplexity_reasoning":
|
||||||
|
return _perplexity_extra_inputs() + _reasoning_extra_inputs()
|
||||||
|
raise ValueError(f"Unknown profile: {profile}")
|
||||||
|
|
||||||
|
|
||||||
|
def _media_inputs(spec: _ModelSpec) -> list:
|
||||||
|
extras: list = []
|
||||||
|
if spec.max_images > 0:
|
||||||
|
extras.append(
|
||||||
|
IO.Autogrow.Input(
|
||||||
|
"images",
|
||||||
|
template=IO.Autogrow.TemplateNames(
|
||||||
|
IO.Image.Input("image"),
|
||||||
|
names=[f"image_{i}" for i in range(1, spec.max_images + 1)],
|
||||||
|
min=0,
|
||||||
|
),
|
||||||
|
tooltip=f"Optional reference image(s) — up to {spec.max_images}. Sent as URLs.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if spec.max_videos > 0:
|
||||||
|
extras.append(
|
||||||
|
IO.Autogrow.Input(
|
||||||
|
"videos",
|
||||||
|
template=IO.Autogrow.TemplateNames(
|
||||||
|
IO.Video.Input("video"),
|
||||||
|
names=[f"video_{i}" for i in range(1, spec.max_videos + 1)],
|
||||||
|
min=0,
|
||||||
|
),
|
||||||
|
tooltip=f"Optional reference video(s) — up to {spec.max_videos}. Sent as URLs.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return extras
|
||||||
|
|
||||||
|
|
||||||
|
def _inputs_for_model(spec: _ModelSpec) -> list:
|
||||||
|
return _profile_inputs(spec.profile) + _media_inputs(spec)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_model_options() -> list[IO.DynamicCombo.Option]:
|
||||||
|
return [IO.DynamicCombo.Option(spec.slug, _inputs_for_model(spec)) for spec in MODELS]
|
||||||
|
|
||||||
|
|
||||||
|
def _calculate_price(response: OpenRouterChatResponse) -> float | None:
|
||||||
|
if response.usage and response.usage.cost is not None:
|
||||||
|
return float(response.usage.cost)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _price_badge_jsonata() -> str:
|
||||||
|
rates_pairs = []
|
||||||
|
for spec in MODELS:
|
||||||
|
prompt_per_1k = spec.price_in * 1000
|
||||||
|
completion_per_1k = spec.price_out * 1000
|
||||||
|
rates_pairs.append(f' "{spec.slug}": [{prompt_per_1k:.8g}, {completion_per_1k:.8g}]')
|
||||||
|
rates_block = ",\n".join(rates_pairs)
|
||||||
|
return (
|
||||||
|
"(\n"
|
||||||
|
" $rates := {\n"
|
||||||
|
f"{rates_block}\n"
|
||||||
|
" };\n"
|
||||||
|
" $r := $lookup($rates, widgets.model);\n"
|
||||||
|
" $r ? {\n"
|
||||||
|
' "type": "list_usd",\n'
|
||||||
|
' "usd": $r,\n'
|
||||||
|
' "format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }\n'
|
||||||
|
' } : {"type": "text", "text": "Token-based"}\n'
|
||||||
|
")"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_image_blocks(
|
||||||
|
cls: type[IO.ComfyNode], spec: _ModelSpec, images: list[Input.Image]
|
||||||
|
) -> list[OpenRouterImageContent]:
|
||||||
|
urls = await upload_images_to_comfyapi(
|
||||||
|
cls,
|
||||||
|
images,
|
||||||
|
max_images=spec.max_images,
|
||||||
|
total_pixels=2048 * 2048,
|
||||||
|
mime_type="image/png",
|
||||||
|
wait_label="Uploading reference images",
|
||||||
|
)
|
||||||
|
return [OpenRouterImageContent(image_url=OpenRouterImageUrl(url=url)) for url in urls]
|
||||||
|
|
||||||
|
|
||||||
|
async def _build_video_blocks(cls: type[IO.ComfyNode], videos: list[Input.Video]) -> list[OpenRouterVideoContent]:
|
||||||
|
blocks: list[OpenRouterVideoContent] = []
|
||||||
|
total = len(videos)
|
||||||
|
for idx, video in enumerate(videos):
|
||||||
|
label = "Uploading reference video"
|
||||||
|
if total > 1:
|
||||||
|
label = f"{label} ({idx + 1}/{total})"
|
||||||
|
url = await upload_video_to_comfyapi(cls, video, wait_label=label)
|
||||||
|
blocks.append(OpenRouterVideoContent(video_url=OpenRouterVideoUrl(url=url)))
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def _user_message(prompt: str, media_blocks: list[OpenRouterContentBlock]) -> OpenRouterMessage:
|
||||||
|
if not media_blocks:
|
||||||
|
return OpenRouterMessage(role="user", content=prompt)
|
||||||
|
blocks: list[OpenRouterContentBlock] = list(media_blocks)
|
||||||
|
blocks.append(OpenRouterTextContent(text=prompt))
|
||||||
|
return OpenRouterMessage(role="user", content=blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_messages(
|
||||||
|
system_prompt: str, prompt: str, media_blocks: list[OpenRouterContentBlock]
|
||||||
|
) -> list[OpenRouterMessage]:
|
||||||
|
messages: list[OpenRouterMessage] = []
|
||||||
|
if system_prompt:
|
||||||
|
messages.append(OpenRouterMessage(role="system", content=system_prompt))
|
||||||
|
messages.append(_user_message(prompt, media_blocks))
|
||||||
|
return messages
|
||||||
|
|
||||||
|
|
||||||
|
def _build_request(
|
||||||
|
slug: str,
|
||||||
|
system_prompt: str,
|
||||||
|
prompt: str,
|
||||||
|
media_blocks: list[OpenRouterContentBlock],
|
||||||
|
*,
|
||||||
|
seed: int,
|
||||||
|
reasoning_effort: str | None,
|
||||||
|
search_context_size: str | None,
|
||||||
|
) -> OpenRouterChatRequest:
|
||||||
|
reasoning_cfg: OpenRouterReasoningConfig | None = None
|
||||||
|
if reasoning_effort and reasoning_effort != "off":
|
||||||
|
# exclude=True asks providers to reason internally but not return the trace
|
||||||
|
reasoning_cfg = OpenRouterReasoningConfig(effort=reasoning_effort, exclude=True)
|
||||||
|
web_search_cfg: OpenRouterWebSearchOptions | None = None
|
||||||
|
if search_context_size:
|
||||||
|
web_search_cfg = OpenRouterWebSearchOptions(search_context_size=search_context_size)
|
||||||
|
return OpenRouterChatRequest(
|
||||||
|
model=slug,
|
||||||
|
messages=_build_messages(system_prompt, prompt, media_blocks),
|
||||||
|
seed=seed if seed > 0 else None,
|
||||||
|
reasoning=reasoning_cfg,
|
||||||
|
web_search_options=web_search_cfg,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_text(response: OpenRouterChatResponse) -> str:
|
||||||
|
if response.error:
|
||||||
|
code = response.error.code if response.error.code is not None else "unknown"
|
||||||
|
raise ValueError(f"OpenRouter error ({code}): {response.error.message or 'no message'}")
|
||||||
|
if not response.choices:
|
||||||
|
raise ValueError("Empty response from OpenRouter (no choices).")
|
||||||
|
message = response.choices[0].message
|
||||||
|
if not message:
|
||||||
|
raise ValueError("Empty response from OpenRouter (no message).")
|
||||||
|
if message.refusal:
|
||||||
|
raise ValueError(f"Model refused to respond: {message.refusal}")
|
||||||
|
return message.content or ""
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterLLMNode(IO.ComfyNode):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls):
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="OpenRouterLLMNode",
|
||||||
|
display_name="OpenRouter LLM",
|
||||||
|
category="api node/text/OpenRouter",
|
||||||
|
essentials_category="Text Generation",
|
||||||
|
description=(
|
||||||
|
"Generate text responses through OpenRouter. Routes to a curated set of popular "
|
||||||
|
"models from xAI, DeepSeek, Qwen, Mistral, Z.AI (GLM), Moonshot (Kimi), and "
|
||||||
|
"Perplexity Sonar."
|
||||||
|
),
|
||||||
|
inputs=[
|
||||||
|
IO.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Text input to the model.",
|
||||||
|
),
|
||||||
|
IO.DynamicCombo.Input(
|
||||||
|
"model",
|
||||||
|
options=_build_model_options(),
|
||||||
|
tooltip="The OpenRouter model used to generate the response.",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=2147483647,
|
||||||
|
control_after_generate=True,
|
||||||
|
tooltip="Seed for sampling. Set to 0 to omit. Most models treat this as a hint only.",
|
||||||
|
),
|
||||||
|
IO.String.Input(
|
||||||
|
"system_prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
tooltip="Foundational instructions that dictate the model's behavior.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
outputs=[IO.String.Output()],
|
||||||
|
hidden=[
|
||||||
|
IO.Hidden.auth_token_comfy_org,
|
||||||
|
IO.Hidden.api_key_comfy_org,
|
||||||
|
IO.Hidden.unique_id,
|
||||||
|
],
|
||||||
|
is_api_node=True,
|
||||||
|
price_badge=IO.PriceBadge(
|
||||||
|
depends_on=IO.PriceBadgeDepends(widgets=["model"]),
|
||||||
|
expr=_price_badge_jsonata(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
prompt: str,
|
||||||
|
model: dict,
|
||||||
|
seed: int,
|
||||||
|
system_prompt: str = "",
|
||||||
|
) -> IO.NodeOutput:
|
||||||
|
validate_string(prompt, strip_whitespace=True, min_length=1)
|
||||||
|
slug: str = model["model"]
|
||||||
|
spec = _MODELS_BY_SLUG.get(slug)
|
||||||
|
if spec is None:
|
||||||
|
raise ValueError(f"Unknown OpenRouter model: {slug}")
|
||||||
|
|
||||||
|
reasoning_effort: str | None = model.get("reasoning_effort")
|
||||||
|
search_context_size: str | None = model.get("search_context_size")
|
||||||
|
|
||||||
|
image_tensors: list[Input.Image] = [t for t in (model.get("images") or {}).values() if t is not None]
|
||||||
|
if image_tensors and sum(get_number_of_images(t) for t in image_tensors) > spec.max_images:
|
||||||
|
raise ValueError(f"Up to {spec.max_images} images are supported for {slug}.")
|
||||||
|
video_inputs: list[Input.Video] = [v for v in (model.get("videos") or {}).values() if v is not None]
|
||||||
|
if video_inputs and len(video_inputs) > spec.max_videos:
|
||||||
|
raise ValueError(f"Up to {spec.max_videos} videos are supported for {slug}.")
|
||||||
|
|
||||||
|
media_blocks: list[OpenRouterContentBlock] = []
|
||||||
|
if image_tensors:
|
||||||
|
media_blocks.extend(await _build_image_blocks(cls, spec, image_tensors))
|
||||||
|
if video_inputs:
|
||||||
|
media_blocks.extend(await _build_video_blocks(cls, video_inputs))
|
||||||
|
|
||||||
|
request = _build_request(
|
||||||
|
slug,
|
||||||
|
system_prompt,
|
||||||
|
prompt,
|
||||||
|
media_blocks,
|
||||||
|
seed=seed,
|
||||||
|
reasoning_effort=reasoning_effort,
|
||||||
|
search_context_size=search_context_size,
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path=OPENROUTER_CHAT_ENDPOINT, method="POST"),
|
||||||
|
response_model=OpenRouterChatResponse,
|
||||||
|
data=request,
|
||||||
|
price_extractor=_calculate_price,
|
||||||
|
)
|
||||||
|
return IO.NodeOutput(_extract_text(response))
|
||||||
|
|
||||||
|
|
||||||
|
class OpenRouterExtension(ComfyExtension):
|
||||||
|
@override
|
||||||
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||||
|
return [OpenRouterLLMNode]
|
||||||
|
|
||||||
|
|
||||||
|
async def comfy_entrypoint() -> OpenRouterExtension:
|
||||||
|
return OpenRouterExtension()
|
||||||
@ -5,32 +5,37 @@ Rodin API docs: https://developer.hyper3d.ai/
|
|||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from inspect import cleandoc
|
|
||||||
import folder_paths as comfy_paths
|
|
||||||
import os
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import os
|
||||||
|
from inspect import cleandoc
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
from typing_extensions import override
|
from typing import Any
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
from typing_extensions import override
|
||||||
|
|
||||||
|
import folder_paths as comfy_paths
|
||||||
|
from comfy_api.latest import IO, ComfyExtension, Types
|
||||||
from comfy_api_nodes.apis.rodin import (
|
from comfy_api_nodes.apis.rodin import (
|
||||||
Rodin3DGenerateRequest,
|
JobStatus,
|
||||||
Rodin3DGenerateResponse,
|
|
||||||
Rodin3DCheckStatusRequest,
|
Rodin3DCheckStatusRequest,
|
||||||
Rodin3DCheckStatusResponse,
|
Rodin3DCheckStatusResponse,
|
||||||
Rodin3DDownloadRequest,
|
Rodin3DDownloadRequest,
|
||||||
Rodin3DDownloadResponse,
|
Rodin3DDownloadResponse,
|
||||||
JobStatus,
|
Rodin3DGen25Request,
|
||||||
|
Rodin3DGenerateRequest,
|
||||||
|
Rodin3DGenerateResponse,
|
||||||
)
|
)
|
||||||
from comfy_api_nodes.util import (
|
from comfy_api_nodes.util import (
|
||||||
sync_op,
|
|
||||||
poll_op,
|
|
||||||
ApiEndpoint,
|
ApiEndpoint,
|
||||||
download_url_to_bytesio,
|
download_url_to_bytesio,
|
||||||
download_url_to_file_3d,
|
download_url_to_file_3d,
|
||||||
|
poll_op,
|
||||||
|
sync_op,
|
||||||
|
validate_string,
|
||||||
)
|
)
|
||||||
from comfy_api.latest import ComfyExtension, IO, Types
|
|
||||||
|
|
||||||
|
|
||||||
COMMON_PARAMETERS = [
|
COMMON_PARAMETERS = [
|
||||||
IO.Int.Input(
|
IO.Int.Input(
|
||||||
@ -51,40 +56,30 @@ COMMON_PARAMETERS = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
def get_quality_mode(poly_count):
|
_QUALITY_MESH_OPTIONS: dict[str, tuple[str, int]] = {
|
||||||
polycount = poly_count.split("-")
|
"4K-Quad": ("Quad", 4000),
|
||||||
poly = polycount[1]
|
"8K-Quad": ("Quad", 8000),
|
||||||
count = polycount[0]
|
"18K-Quad": ("Quad", 18000),
|
||||||
if poly == "Triangle":
|
"50K-Quad": ("Quad", 50000),
|
||||||
mesh_mode = "Raw"
|
"200K-Quad": ("Quad", 200000),
|
||||||
elif poly == "Quad":
|
"2K-Triangle": ("Raw", 2000),
|
||||||
mesh_mode = "Quad"
|
"20K-Triangle": ("Raw", 20000),
|
||||||
else:
|
"150K-Triangle": ("Raw", 150000),
|
||||||
mesh_mode = "Quad"
|
"200K-Triangle": ("Raw", 200000),
|
||||||
|
"500K-Triangle": ("Raw", 500000),
|
||||||
if count == "4K":
|
"1M-Triangle": ("Raw", 1000000),
|
||||||
quality_override = 4000
|
}
|
||||||
elif count == "8K":
|
|
||||||
quality_override = 8000
|
|
||||||
elif count == "18K":
|
|
||||||
quality_override = 18000
|
|
||||||
elif count == "50K":
|
|
||||||
quality_override = 50000
|
|
||||||
elif count == "2K":
|
|
||||||
quality_override = 2000
|
|
||||||
elif count == "20K":
|
|
||||||
quality_override = 20000
|
|
||||||
elif count == "150K":
|
|
||||||
quality_override = 150000
|
|
||||||
elif count == "500K":
|
|
||||||
quality_override = 500000
|
|
||||||
else:
|
|
||||||
quality_override = 18000
|
|
||||||
|
|
||||||
return mesh_mode, quality_override
|
|
||||||
|
|
||||||
|
|
||||||
def tensor_to_filelike(tensor, max_pixels: int = 2048*2048):
|
def get_quality_mode(poly_count: str) -> tuple[str, int]:
|
||||||
|
"""Map a polygon-count preset like '18K-Quad' to (mesh_mode, quality_override).
|
||||||
|
|
||||||
|
Falls back to ('Quad', 18000) for unknown labels; legacy parity.
|
||||||
|
"""
|
||||||
|
return _QUALITY_MESH_OPTIONS.get(poly_count, ("Quad", 18000))
|
||||||
|
|
||||||
|
|
||||||
|
def tensor_to_filelike(tensor, max_pixels: int = 2048 * 2048):
|
||||||
"""
|
"""
|
||||||
Converts a PyTorch tensor to a file-like object.
|
Converts a PyTorch tensor to a file-like object.
|
||||||
|
|
||||||
@ -96,8 +91,8 @@ def tensor_to_filelike(tensor, max_pixels: int = 2048*2048):
|
|||||||
- io.BytesIO: A file-like object containing the image data.
|
- io.BytesIO: A file-like object containing the image data.
|
||||||
"""
|
"""
|
||||||
array = tensor.cpu().numpy()
|
array = tensor.cpu().numpy()
|
||||||
array = (array * 255).astype('uint8')
|
array = (array * 255).astype("uint8")
|
||||||
image = Image.fromarray(array, 'RGB')
|
image = Image.fromarray(array, "RGB")
|
||||||
|
|
||||||
original_width, original_height = image.size
|
original_width, original_height = image.size
|
||||||
original_pixels = original_width * original_height
|
original_pixels = original_width * original_height
|
||||||
@ -112,7 +107,7 @@ def tensor_to_filelike(tensor, max_pixels: int = 2048*2048):
|
|||||||
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
img_byte_arr = BytesIO()
|
img_byte_arr = BytesIO()
|
||||||
image.save(img_byte_arr, format='PNG') # PNG is used for lossless compression
|
image.save(img_byte_arr, format="PNG") # PNG is used for lossless compression
|
||||||
img_byte_arr.seek(0)
|
img_byte_arr.seek(0)
|
||||||
return img_byte_arr
|
return img_byte_arr
|
||||||
|
|
||||||
@ -145,11 +140,9 @@ async def create_generate_task(
|
|||||||
TAPose=ta_pose,
|
TAPose=ta_pose,
|
||||||
),
|
),
|
||||||
files=[
|
files=[
|
||||||
(
|
("images", open(image, "rb") if isinstance(image, str) else tensor_to_filelike(image))
|
||||||
"images",
|
for image in images
|
||||||
open(image, "rb") if isinstance(image, str) else tensor_to_filelike(image)
|
if image is not None
|
||||||
)
|
|
||||||
for image in images if image is not None
|
|
||||||
],
|
],
|
||||||
content_type="multipart/form-data",
|
content_type="multipart/form-data",
|
||||||
)
|
)
|
||||||
@ -177,6 +170,7 @@ def check_rodin_status(response: Rodin3DCheckStatusResponse) -> str:
|
|||||||
return "DONE"
|
return "DONE"
|
||||||
return "Generating"
|
return "Generating"
|
||||||
|
|
||||||
|
|
||||||
def extract_progress(response: Rodin3DCheckStatusResponse) -> int | None:
|
def extract_progress(response: Rodin3DCheckStatusResponse) -> int | None:
|
||||||
if not response.jobs:
|
if not response.jobs:
|
||||||
return None
|
return None
|
||||||
@ -214,7 +208,7 @@ async def download_files(url_list, task_uuid: str) -> tuple[str | None, Types.Fi
|
|||||||
model_file_path = None
|
model_file_path = None
|
||||||
file_3d = None
|
file_3d = None
|
||||||
|
|
||||||
for i in url_list.list:
|
for i in url_list.items:
|
||||||
file_path = os.path.join(save_path, i.name)
|
file_path = os.path.join(save_path, i.name)
|
||||||
if i.name.lower().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)
|
||||||
@ -489,7 +483,16 @@ class Rodin3D_Gen2(IO.ComfyNode):
|
|||||||
IO.Combo.Input("Material_Type", options=["PBR", "Shaded"], default="PBR", optional=True),
|
IO.Combo.Input("Material_Type", options=["PBR", "Shaded"], default="PBR", optional=True),
|
||||||
IO.Combo.Input(
|
IO.Combo.Input(
|
||||||
"Polygon_count",
|
"Polygon_count",
|
||||||
options=["4K-Quad", "8K-Quad", "18K-Quad", "50K-Quad", "2K-Triangle", "20K-Triangle", "150K-Triangle", "500K-Triangle"],
|
options=[
|
||||||
|
"4K-Quad",
|
||||||
|
"8K-Quad",
|
||||||
|
"18K-Quad",
|
||||||
|
"50K-Quad",
|
||||||
|
"2K-Triangle",
|
||||||
|
"20K-Triangle",
|
||||||
|
"150K-Triangle",
|
||||||
|
"500K-Triangle",
|
||||||
|
],
|
||||||
default="500K-Triangle",
|
default="500K-Triangle",
|
||||||
optional=True,
|
optional=True,
|
||||||
),
|
),
|
||||||
@ -542,6 +545,566 @@ class Rodin3D_Gen2(IO.ComfyNode):
|
|||||||
return IO.NodeOutput(model_path, file_3d)
|
return IO.NodeOutput(model_path, file_3d)
|
||||||
|
|
||||||
|
|
||||||
|
def _rodin_multipart_parser(data: dict[str, Any]) -> aiohttp.FormData:
|
||||||
|
"""Convert a Rodin request dict to an aiohttp form, fixing bool/list serialization.
|
||||||
|
|
||||||
|
Booleans --> "true"/"false". Lists --> one field per element.
|
||||||
|
"""
|
||||||
|
form = aiohttp.FormData(default_to_multipart=True)
|
||||||
|
for key, value in data.items():
|
||||||
|
if value is None:
|
||||||
|
continue
|
||||||
|
if isinstance(value, bool):
|
||||||
|
form.add_field(key, "true" if value else "false")
|
||||||
|
elif isinstance(value, list):
|
||||||
|
for item in value:
|
||||||
|
form.add_field(key, str(item))
|
||||||
|
elif isinstance(value, (bytes, bytearray)):
|
||||||
|
form.add_field(key, value)
|
||||||
|
else:
|
||||||
|
form.add_field(key, str(value))
|
||||||
|
return form
|
||||||
|
|
||||||
|
|
||||||
|
async def _create_gen25_task(
|
||||||
|
cls: type[IO.ComfyNode],
|
||||||
|
request: Rodin3DGen25Request,
|
||||||
|
images: list | None,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
"""Submit a Gen-2.5 generate job; returns (task_uuid, subscription_key)."""
|
||||||
|
|
||||||
|
if images is not None and len(images) > 5:
|
||||||
|
raise ValueError("Rodin Gen-2.5 supports at most 5 input images.")
|
||||||
|
|
||||||
|
files = None
|
||||||
|
if images:
|
||||||
|
files = [
|
||||||
|
(
|
||||||
|
"images",
|
||||||
|
open(image, "rb") if isinstance(image, str) else tensor_to_filelike(image),
|
||||||
|
)
|
||||||
|
for image in images
|
||||||
|
if image is not None
|
||||||
|
]
|
||||||
|
|
||||||
|
response = await sync_op(
|
||||||
|
cls,
|
||||||
|
ApiEndpoint(path="/proxy/rodin/api/v2/rodin", method="POST"),
|
||||||
|
response_model=Rodin3DGenerateResponse,
|
||||||
|
data=request,
|
||||||
|
files=files,
|
||||||
|
content_type="multipart/form-data",
|
||||||
|
multipart_parser=_rodin_multipart_parser,
|
||||||
|
)
|
||||||
|
|
||||||
|
if not response.uuid or not response.jobs or not response.jobs.subscription_key:
|
||||||
|
raise RuntimeError(f"Rodin Gen-2.5 submit failed: message={response.message!r}")
|
||||||
|
return response.uuid, response.jobs.subscription_key
|
||||||
|
|
||||||
|
|
||||||
|
_PREVIEWABLE_3D_EXTS = {".glb", ".obj", ".fbx", ".stl", ".gltf"}
|
||||||
|
|
||||||
|
|
||||||
|
async def _download_gen25_files(
|
||||||
|
download_list: Rodin3DDownloadResponse,
|
||||||
|
task_uuid: str,
|
||||||
|
geometry_file_format: str,
|
||||||
|
) -> Types.File3D | None:
|
||||||
|
"""Download every file in the list; return the File3D matching the chosen format."""
|
||||||
|
|
||||||
|
folder_name = f"Rodin3D_Gen25_{task_uuid}"
|
||||||
|
save_dir = os.path.join(comfy_paths.get_output_directory(), folder_name)
|
||||||
|
os.makedirs(save_dir, exist_ok=True)
|
||||||
|
|
||||||
|
target_ext = f".{geometry_file_format.lower().lstrip('.')}"
|
||||||
|
file_3d: Types.File3D | None = None
|
||||||
|
|
||||||
|
for item in download_list.items:
|
||||||
|
file_path = os.path.join(save_dir, item.name)
|
||||||
|
ext = os.path.splitext(item.name.lower())[1]
|
||||||
|
# Prefer the file matching the user's chosen format; fall back below.
|
||||||
|
if file_3d is None and ext == target_ext and ext in _PREVIEWABLE_3D_EXTS:
|
||||||
|
file_3d = await download_url_to_file_3d(item.url, target_ext.lstrip("."))
|
||||||
|
with open(file_path, "wb") as f:
|
||||||
|
f.write(file_3d.get_bytes())
|
||||||
|
continue
|
||||||
|
await download_url_to_bytesio(item.url, file_path)
|
||||||
|
|
||||||
|
# If the chosen format wasn't found, surface any model file we did get.
|
||||||
|
if file_3d is None:
|
||||||
|
for item in download_list.items:
|
||||||
|
ext = os.path.splitext(item.name.lower())[1]
|
||||||
|
if ext in _PREVIEWABLE_3D_EXTS:
|
||||||
|
file_3d = await download_url_to_file_3d(item.url, ext.lstrip("."))
|
||||||
|
break
|
||||||
|
return file_3d
|
||||||
|
|
||||||
|
|
||||||
|
_MODE_REGULAR = "Regular"
|
||||||
|
_MODE_FAST = "Fast"
|
||||||
|
_MODE_EXTREME_HIGH = "Extreme-High"
|
||||||
|
|
||||||
|
_REGULAR_POLY_OPTIONS = [
|
||||||
|
"Default",
|
||||||
|
"4K-Quad",
|
||||||
|
"8K-Quad",
|
||||||
|
"18K-Quad",
|
||||||
|
"50K-Quad",
|
||||||
|
"2K-Triangle",
|
||||||
|
"20K-Triangle",
|
||||||
|
"150K-Triangle",
|
||||||
|
"500K-Triangle",
|
||||||
|
"1M-Triangle",
|
||||||
|
]
|
||||||
|
|
||||||
|
_TEXTURE_MODE_OPTIONS = ["Default", "legacy", "extreme-low", "low", "medium", "high"]
|
||||||
|
_GEOMETRY_FORMAT_OPTIONS = ["glb", "fbx", "obj", "stl"]
|
||||||
|
_MATERIAL_OPTIONS = ["PBR", "Shaded", "All", "None"]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_mode_input(name: str = "mode") -> IO.DynamicCombo.Input:
|
||||||
|
return IO.DynamicCombo.Input(
|
||||||
|
name,
|
||||||
|
options=[
|
||||||
|
IO.DynamicCombo.Option(
|
||||||
|
_MODE_REGULAR,
|
||||||
|
[
|
||||||
|
IO.Combo.Input(
|
||||||
|
"tier",
|
||||||
|
options=["Gen-2.5-Low", "Gen-2.5-Medium", "Gen-2.5-High"],
|
||||||
|
default="Gen-2.5-High",
|
||||||
|
tooltip="Quality tier. Higher tiers produce higher-fidelity geometry.",
|
||||||
|
),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"polygon_count",
|
||||||
|
options=_REGULAR_POLY_OPTIONS,
|
||||||
|
default="Default",
|
||||||
|
tooltip="Preset face count. 'Default' uses the server's default for the selected tier.",
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"creative",
|
||||||
|
default=False,
|
||||||
|
tooltip="Creative mode (Medium/High only). Enhances generative robustness.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IO.DynamicCombo.Option(
|
||||||
|
_MODE_FAST,
|
||||||
|
[
|
||||||
|
IO.Combo.Input(
|
||||||
|
"tier",
|
||||||
|
options=[
|
||||||
|
"Gen-2.5-Extreme-Low",
|
||||||
|
"Gen-2.5-Low",
|
||||||
|
"Gen-2.5-Medium",
|
||||||
|
"Gen-2.5-High",
|
||||||
|
],
|
||||||
|
default="Gen-2.5-Low",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"mesh_faces",
|
||||||
|
default=20000,
|
||||||
|
min=1000,
|
||||||
|
max=20000,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
tooltip="Mesh face count (1K-20K in Fast mode).",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IO.DynamicCombo.Option(
|
||||||
|
_MODE_EXTREME_HIGH,
|
||||||
|
[
|
||||||
|
IO.Combo.Input("mesh_mode", options=["Raw", "Quad"], default="Raw"),
|
||||||
|
IO.Int.Input(
|
||||||
|
"mesh_faces",
|
||||||
|
default=1000000,
|
||||||
|
min=20000,
|
||||||
|
max=2000000,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
tooltip=(
|
||||||
|
"Mesh face count. Raw mode: 20K-2M. "
|
||||||
|
"Quad mode: keep under 200K (upstream may reject higher values)."
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"is_micro",
|
||||||
|
default=False,
|
||||||
|
tooltip="Enable micro detail (Extreme-High only).",
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"creative",
|
||||||
|
default=False,
|
||||||
|
tooltip="Creative mode. Enhances generative robustness.",
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
tooltip=(
|
||||||
|
"Generation mode. Regular = balanced. Fast = 1K-20K faces for rapid prototyping. "
|
||||||
|
"Extreme-High = 20K-2M faces with optional micro details."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_common_inputs(*, include_image_only: bool) -> list:
|
||||||
|
inputs: list = [
|
||||||
|
IO.Combo.Input("material", options=_MATERIAL_OPTIONS, default="Shaded"),
|
||||||
|
IO.Combo.Input("geometry_file_format", options=_GEOMETRY_FORMAT_OPTIONS, default="glb"),
|
||||||
|
IO.Combo.Input(
|
||||||
|
"texture_mode",
|
||||||
|
options=_TEXTURE_MODE_OPTIONS,
|
||||||
|
default="Default",
|
||||||
|
optional=True,
|
||||||
|
tooltip="Texture quality preset. 'Default' uses the server's default for the selected tier.",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"seed",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=65535,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
control_after_generate=True,
|
||||||
|
optional=True,
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"TAPose", default=False, optional=True, advanced=True, tooltip="T/A pose for human-like models."
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"hd_texture", default=False, optional=True, advanced=True, tooltip="High-quality texture enhancement."
|
||||||
|
),
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"texture_delight",
|
||||||
|
default=False,
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
tooltip="Remove baked lighting from textures.",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
if include_image_only:
|
||||||
|
inputs.append(
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"use_original_alpha",
|
||||||
|
default=False,
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
tooltip="Preserve image transparency.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
inputs.extend(
|
||||||
|
[
|
||||||
|
IO.Boolean.Input(
|
||||||
|
"addon_highpack",
|
||||||
|
default=False,
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
tooltip="HighPack addon: 4K textures and ~16x faces in Quad mode.",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"bbox_width",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=300,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
tooltip="Bounding-box width (Y axis). Set to 0 with the others to skip bbox.",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"bbox_height",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=300,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
tooltip="Bounding-box height (Z axis).",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"bbox_length",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=300,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
tooltip="Bounding-box length (X axis).",
|
||||||
|
),
|
||||||
|
IO.Int.Input(
|
||||||
|
"height_cm",
|
||||||
|
default=0,
|
||||||
|
min=0,
|
||||||
|
max=10000,
|
||||||
|
display_mode=IO.NumberDisplay.number,
|
||||||
|
optional=True,
|
||||||
|
advanced=True,
|
||||||
|
tooltip="Approximate model height in centimeters (0 to skip).",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
return inputs
|
||||||
|
|
||||||
|
|
||||||
|
_PRICE_EXPR = """
|
||||||
|
(
|
||||||
|
$baseCredits := widgets.mode = "extreme-high" ? 1.0 : 0.5;
|
||||||
|
$addonCredits := widgets.addon_highpack ? 1.0 : 0.0;
|
||||||
|
$total := ($baseCredits * 1.5) + ($addonCredits * 0.8);
|
||||||
|
{"type":"usd","usd": $total}
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_mode_params(mode_input: dict) -> dict:
|
||||||
|
"""Translate the DynamicCombo `mode` payload into Gen-2.5 request fields.
|
||||||
|
|
||||||
|
Returns a dict with: tier, quality_override, mesh_mode, geometry_instruct_mode, is_micro.
|
||||||
|
Missing keys mean "do not send" (so we don't override server defaults).
|
||||||
|
"""
|
||||||
|
selected = mode_input["mode"]
|
||||||
|
out: dict = {}
|
||||||
|
|
||||||
|
if selected == _MODE_REGULAR:
|
||||||
|
out["tier"] = mode_input["tier"]
|
||||||
|
polygon = mode_input.get("polygon_count", "Default")
|
||||||
|
if polygon != "Default":
|
||||||
|
mesh_mode, faces = get_quality_mode(polygon)
|
||||||
|
out["mesh_mode"] = mesh_mode
|
||||||
|
out["quality_override"] = faces
|
||||||
|
if mode_input.get("creative"):
|
||||||
|
out["geometry_instruct_mode"] = "creative"
|
||||||
|
|
||||||
|
elif selected == _MODE_FAST:
|
||||||
|
out["tier"] = mode_input["tier"]
|
||||||
|
out["mesh_mode"] = "Raw"
|
||||||
|
out["quality_override"] = int(mode_input["mesh_faces"])
|
||||||
|
|
||||||
|
elif selected == _MODE_EXTREME_HIGH:
|
||||||
|
out["tier"] = "Gen-2.5-Extreme-High"
|
||||||
|
out["mesh_mode"] = mode_input["mesh_mode"]
|
||||||
|
out["quality_override"] = int(mode_input["mesh_faces"])
|
||||||
|
if mode_input.get("is_micro"):
|
||||||
|
out["is_micro"] = True
|
||||||
|
if mode_input.get("creative"):
|
||||||
|
out["geometry_instruct_mode"] = "creative"
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _build_request(
|
||||||
|
*,
|
||||||
|
mode_input: dict,
|
||||||
|
material: str,
|
||||||
|
geometry_file_format: str,
|
||||||
|
texture_mode: str,
|
||||||
|
seed: int,
|
||||||
|
TAPose: bool,
|
||||||
|
hd_texture: bool,
|
||||||
|
texture_delight: bool,
|
||||||
|
addon_highpack: bool,
|
||||||
|
bbox_width: int,
|
||||||
|
bbox_height: int,
|
||||||
|
bbox_length: int,
|
||||||
|
height_cm: int,
|
||||||
|
prompt: str | None = None,
|
||||||
|
use_original_alpha: bool = False,
|
||||||
|
) -> Rodin3DGen25Request:
|
||||||
|
mode_params = _resolve_mode_params(mode_input)
|
||||||
|
|
||||||
|
bbox = None
|
||||||
|
if bbox_width and bbox_height and bbox_length:
|
||||||
|
bbox = [bbox_width, bbox_height, bbox_length]
|
||||||
|
|
||||||
|
return Rodin3DGen25Request(
|
||||||
|
tier=mode_params["tier"],
|
||||||
|
prompt=prompt or None,
|
||||||
|
seed=seed,
|
||||||
|
material=material,
|
||||||
|
geometry_file_format=geometry_file_format,
|
||||||
|
texture_mode=None if texture_mode == "Default" else texture_mode,
|
||||||
|
mesh_mode=mode_params.get("mesh_mode"),
|
||||||
|
quality_override=mode_params.get("quality_override"),
|
||||||
|
geometry_instruct_mode=mode_params.get("geometry_instruct_mode"),
|
||||||
|
bbox_condition=bbox,
|
||||||
|
height=height_cm or None,
|
||||||
|
TAPose=TAPose or None,
|
||||||
|
hd_texture=hd_texture or None,
|
||||||
|
texture_delight=texture_delight or None,
|
||||||
|
is_micro=mode_params.get("is_micro"),
|
||||||
|
use_original_alpha=use_original_alpha or None,
|
||||||
|
addons=["HighPack"] if addon_highpack else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Rodin3D_Gen25_Image(IO.ComfyNode):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls) -> IO.Schema:
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="Rodin3D_Gen25_Image",
|
||||||
|
display_name="Rodin 3D Gen-2.5 - Image to 3D",
|
||||||
|
category="api node/3d/Rodin",
|
||||||
|
description=(
|
||||||
|
"Generate a 3D model from 1-5 reference images via Rodin Gen-2.5. "
|
||||||
|
"Pick a mode (Fast / Regular / Extreme-High) to tune quality vs. cost."
|
||||||
|
),
|
||||||
|
inputs=[
|
||||||
|
IO.Autogrow.Input(
|
||||||
|
"images",
|
||||||
|
template=IO.Autogrow.TemplatePrefix(IO.Image.Input("image"), prefix="image", min=1, max=5),
|
||||||
|
tooltip="1-5 images. The first image is used for materials when multi-view.",
|
||||||
|
),
|
||||||
|
_build_mode_input(),
|
||||||
|
*_build_common_inputs(include_image_only=True),
|
||||||
|
],
|
||||||
|
outputs=[IO.File3DAny.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,
|
||||||
|
price_badge=IO.PriceBadge(
|
||||||
|
depends_on=IO.PriceBadgeDepends(widgets=["mode", "addon_highpack"]),
|
||||||
|
expr=_PRICE_EXPR,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
images: IO.Autogrow.Type,
|
||||||
|
mode: dict,
|
||||||
|
material: str,
|
||||||
|
geometry_file_format: str,
|
||||||
|
texture_mode: str,
|
||||||
|
seed: int,
|
||||||
|
TAPose: bool,
|
||||||
|
hd_texture: bool,
|
||||||
|
texture_delight: bool,
|
||||||
|
use_original_alpha: bool,
|
||||||
|
addon_highpack: bool,
|
||||||
|
bbox_width: int,
|
||||||
|
bbox_height: int,
|
||||||
|
bbox_length: int,
|
||||||
|
height_cm: int,
|
||||||
|
) -> IO.NodeOutput:
|
||||||
|
image_tensors = [img for img in images.values() if img is not None]
|
||||||
|
if not image_tensors:
|
||||||
|
raise ValueError("Rodin Gen-2.5 Image-to-3D requires at least one image.")
|
||||||
|
|
||||||
|
# Flatten multi-image tensors into individual frames; the API accepts each as a separate part.
|
||||||
|
flat_images: list = []
|
||||||
|
for tensor in image_tensors:
|
||||||
|
if hasattr(tensor, "shape") and len(tensor.shape) == 4:
|
||||||
|
for i in range(tensor.shape[0]):
|
||||||
|
flat_images.append(tensor[i])
|
||||||
|
else:
|
||||||
|
flat_images.append(tensor)
|
||||||
|
|
||||||
|
if len(flat_images) > 5:
|
||||||
|
raise ValueError(f"Rodin Gen-2.5 accepts at most 5 images; received {len(flat_images)}.")
|
||||||
|
|
||||||
|
request = _build_request(
|
||||||
|
mode_input=mode,
|
||||||
|
material=material,
|
||||||
|
geometry_file_format=geometry_file_format,
|
||||||
|
texture_mode=texture_mode,
|
||||||
|
seed=seed,
|
||||||
|
TAPose=TAPose,
|
||||||
|
hd_texture=hd_texture,
|
||||||
|
texture_delight=texture_delight,
|
||||||
|
addon_highpack=addon_highpack,
|
||||||
|
bbox_width=bbox_width,
|
||||||
|
bbox_height=bbox_height,
|
||||||
|
bbox_length=bbox_length,
|
||||||
|
height_cm=height_cm,
|
||||||
|
prompt=None,
|
||||||
|
use_original_alpha=use_original_alpha,
|
||||||
|
)
|
||||||
|
|
||||||
|
task_uuid, subscription_key = await _create_gen25_task(cls, request, flat_images)
|
||||||
|
await poll_for_task_status(subscription_key, cls)
|
||||||
|
download_list = await get_rodin_download_list(task_uuid, cls)
|
||||||
|
file_3d = await _download_gen25_files(download_list, task_uuid, geometry_file_format)
|
||||||
|
return IO.NodeOutput(file_3d)
|
||||||
|
|
||||||
|
|
||||||
|
class Rodin3D_Gen25_Text(IO.ComfyNode):
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def define_schema(cls) -> IO.Schema:
|
||||||
|
return IO.Schema(
|
||||||
|
node_id="Rodin3D_Gen25_Text",
|
||||||
|
display_name="Rodin 3D Gen-2.5 - Text to 3D",
|
||||||
|
category="api node/3d/Rodin",
|
||||||
|
description=(
|
||||||
|
"Generate a 3D model from a text prompt via Rodin Gen-2.5. "
|
||||||
|
"Pick a mode (Fast / Regular / Extreme-High) to tune quality vs. cost."
|
||||||
|
),
|
||||||
|
inputs=[
|
||||||
|
IO.String.Input(
|
||||||
|
"prompt",
|
||||||
|
multiline=True,
|
||||||
|
default="",
|
||||||
|
tooltip="Text prompt for the 3D model.",
|
||||||
|
),
|
||||||
|
_build_mode_input(),
|
||||||
|
*_build_common_inputs(include_image_only=False),
|
||||||
|
],
|
||||||
|
outputs=[IO.File3DAny.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,
|
||||||
|
price_badge=IO.PriceBadge(
|
||||||
|
depends_on=IO.PriceBadgeDepends(widgets=["mode", "addon_highpack"]),
|
||||||
|
expr=_PRICE_EXPR,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def execute(
|
||||||
|
cls,
|
||||||
|
prompt: str,
|
||||||
|
mode: dict,
|
||||||
|
material: str,
|
||||||
|
geometry_file_format: str,
|
||||||
|
texture_mode: str,
|
||||||
|
seed: int,
|
||||||
|
TAPose: bool,
|
||||||
|
hd_texture: bool,
|
||||||
|
texture_delight: bool,
|
||||||
|
addon_highpack: bool,
|
||||||
|
bbox_width: int,
|
||||||
|
bbox_height: int,
|
||||||
|
bbox_length: int,
|
||||||
|
height_cm: int,
|
||||||
|
) -> IO.NodeOutput:
|
||||||
|
validate_string(prompt, field_name="prompt", min_length=1, max_length=2500)
|
||||||
|
request = _build_request(
|
||||||
|
mode_input=mode,
|
||||||
|
material=material,
|
||||||
|
geometry_file_format=geometry_file_format,
|
||||||
|
texture_mode=texture_mode,
|
||||||
|
seed=seed,
|
||||||
|
TAPose=TAPose,
|
||||||
|
hd_texture=hd_texture,
|
||||||
|
texture_delight=texture_delight,
|
||||||
|
addon_highpack=addon_highpack,
|
||||||
|
bbox_width=bbox_width,
|
||||||
|
bbox_height=bbox_height,
|
||||||
|
bbox_length=bbox_length,
|
||||||
|
height_cm=height_cm,
|
||||||
|
prompt=prompt,
|
||||||
|
)
|
||||||
|
task_uuid, subscription_key = await _create_gen25_task(cls, request, images=None)
|
||||||
|
await poll_for_task_status(subscription_key, cls)
|
||||||
|
download_list = await get_rodin_download_list(task_uuid, cls)
|
||||||
|
file_3d = await _download_gen25_files(download_list, task_uuid, geometry_file_format)
|
||||||
|
return IO.NodeOutput(file_3d)
|
||||||
|
|
||||||
|
|
||||||
class Rodin3DExtension(ComfyExtension):
|
class Rodin3DExtension(ComfyExtension):
|
||||||
@override
|
@override
|
||||||
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
|
||||||
@ -551,6 +1114,8 @@ class Rodin3DExtension(ComfyExtension):
|
|||||||
Rodin3D_Smooth,
|
Rodin3D_Smooth,
|
||||||
Rodin3D_Sketch,
|
Rodin3D_Sketch,
|
||||||
Rodin3D_Gen2,
|
Rodin3D_Gen2,
|
||||||
|
Rodin3D_Gen25_Image,
|
||||||
|
Rodin3D_Gen25_Text,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -16,16 +16,17 @@ from .conversions import (
|
|||||||
convert_mask_to_image,
|
convert_mask_to_image,
|
||||||
downscale_image_tensor,
|
downscale_image_tensor,
|
||||||
downscale_image_tensor_by_max_side,
|
downscale_image_tensor_by_max_side,
|
||||||
|
downscale_video_to_max_pixels,
|
||||||
image_tensor_pair_to_batch,
|
image_tensor_pair_to_batch,
|
||||||
pil_to_bytesio,
|
pil_to_bytesio,
|
||||||
resize_mask_to_image,
|
resize_mask_to_image,
|
||||||
resize_video_to_pixel_budget,
|
|
||||||
tensor_to_base64_string,
|
tensor_to_base64_string,
|
||||||
tensor_to_bytesio,
|
tensor_to_bytesio,
|
||||||
tensor_to_pil,
|
tensor_to_pil,
|
||||||
text_filepath_to_base64_string,
|
text_filepath_to_base64_string,
|
||||||
text_filepath_to_data_uri,
|
text_filepath_to_data_uri,
|
||||||
trim_video,
|
trim_video,
|
||||||
|
upscale_video_to_min_pixels,
|
||||||
video_to_base64_string,
|
video_to_base64_string,
|
||||||
)
|
)
|
||||||
from .download_helpers import (
|
from .download_helpers import (
|
||||||
@ -88,16 +89,17 @@ __all__ = [
|
|||||||
"convert_mask_to_image",
|
"convert_mask_to_image",
|
||||||
"downscale_image_tensor",
|
"downscale_image_tensor",
|
||||||
"downscale_image_tensor_by_max_side",
|
"downscale_image_tensor_by_max_side",
|
||||||
|
"downscale_video_to_max_pixels",
|
||||||
"image_tensor_pair_to_batch",
|
"image_tensor_pair_to_batch",
|
||||||
"pil_to_bytesio",
|
"pil_to_bytesio",
|
||||||
"resize_mask_to_image",
|
"resize_mask_to_image",
|
||||||
"resize_video_to_pixel_budget",
|
|
||||||
"tensor_to_base64_string",
|
"tensor_to_base64_string",
|
||||||
"tensor_to_bytesio",
|
"tensor_to_bytesio",
|
||||||
"tensor_to_pil",
|
"tensor_to_pil",
|
||||||
"text_filepath_to_base64_string",
|
"text_filepath_to_base64_string",
|
||||||
"text_filepath_to_data_uri",
|
"text_filepath_to_data_uri",
|
||||||
"trim_video",
|
"trim_video",
|
||||||
|
"upscale_video_to_min_pixels",
|
||||||
"video_to_base64_string",
|
"video_to_base64_string",
|
||||||
# Validation utilities
|
# Validation utilities
|
||||||
"get_image_dimensions",
|
"get_image_dimensions",
|
||||||
|
|||||||
@ -415,14 +415,48 @@ def trim_video(video: Input.Video, duration_sec: float) -> Input.Video:
|
|||||||
raise RuntimeError(f"Failed to trim video: {str(e)}") from e
|
raise RuntimeError(f"Failed to trim video: {str(e)}") from e
|
||||||
|
|
||||||
|
|
||||||
def resize_video_to_pixel_budget(video: Input.Video, total_pixels: int) -> Input.Video:
|
def downscale_video_to_max_pixels(video: Input.Video, max_pixels: int) -> Input.Video:
|
||||||
"""Downscale a video to fit within ``total_pixels`` (w * h), preserving aspect ratio.
|
"""Downscale a video to fit within ``max_pixels`` (w * h), preserving aspect ratio.
|
||||||
|
|
||||||
Returns the original video object untouched when it already fits. Preserves frame rate, duration, and audio.
|
Returns the original video object untouched when it already fits. Preserves frame rate, duration, and audio.
|
||||||
Aspect ratio is preserved up to a fraction of a percent (even-dim rounding).
|
Aspect ratio is preserved up to a fraction of a percent (even-dim rounding).
|
||||||
"""
|
"""
|
||||||
src_w, src_h = video.get_dimensions()
|
src_w, src_h = video.get_dimensions()
|
||||||
scale_dims = _compute_downscale_dims(src_w, src_h, total_pixels)
|
scale_dims = _compute_downscale_dims(src_w, src_h, max_pixels)
|
||||||
|
if scale_dims is None:
|
||||||
|
return video
|
||||||
|
return _apply_video_scale(video, scale_dims)
|
||||||
|
|
||||||
|
|
||||||
|
def _compute_upscale_dims(src_w: int, src_h: int, total_pixels: int) -> tuple[int, int] | None:
|
||||||
|
"""Return upscaled (w, h) with even dims meeting at least ``total_pixels``, or None if already large enough.
|
||||||
|
|
||||||
|
Source aspect ratio is preserved; output may drift by a fraction of a percent because both dimensions
|
||||||
|
are rounded up to even values (many codecs require divisible-by-2). The result is guaranteed to be at
|
||||||
|
least ``total_pixels``.
|
||||||
|
"""
|
||||||
|
pixels = src_w * src_h
|
||||||
|
if pixels >= total_pixels:
|
||||||
|
return None
|
||||||
|
scale = math.sqrt(total_pixels / pixels)
|
||||||
|
new_w = math.ceil(src_w * scale)
|
||||||
|
new_h = math.ceil(src_h * scale)
|
||||||
|
if new_w % 2:
|
||||||
|
new_w += 1
|
||||||
|
if new_h % 2:
|
||||||
|
new_h += 1
|
||||||
|
return new_w, new_h
|
||||||
|
|
||||||
|
|
||||||
|
def upscale_video_to_min_pixels(video: Input.Video, min_pixels: int) -> Input.Video:
|
||||||
|
"""Upscale a video to meet at least ``min_pixels`` (w * h), preserving aspect ratio.
|
||||||
|
|
||||||
|
Returns the original video object untouched when it already meets the minimum. Preserves frame rate,
|
||||||
|
duration, and audio. Aspect ratio is preserved up to a fraction of a percent (even-dim rounding).
|
||||||
|
Note: upscaling a low-resolution source does not add real detail; downstream model quality may suffer.
|
||||||
|
"""
|
||||||
|
src_w, src_h = video.get_dimensions()
|
||||||
|
scale_dims = _compute_upscale_dims(src_w, src_h, min_pixels)
|
||||||
if scale_dims is None:
|
if scale_dims is None:
|
||||||
return video
|
return video
|
||||||
return _apply_video_scale(video, scale_dims)
|
return _apply_video_scale(video, scale_dims)
|
||||||
|
|||||||
@ -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.22.0"
|
__version__ = "0.22.2"
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "ComfyUI"
|
name = "ComfyUI"
|
||||||
version = "0.22.0"
|
version = "0.22.2"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
comfyui-frontend-package==1.43.18
|
comfyui-frontend-package==1.43.18
|
||||||
comfyui-workflow-templates==0.9.79
|
comfyui-workflow-templates==0.9.82
|
||||||
comfyui-embedded-docs==0.5.0
|
comfyui-embedded-docs==0.5.0
|
||||||
torch
|
torch
|
||||||
torchsde
|
torchsde
|
||||||
|
|||||||
Reference in New Issue
Block a user