Merge branch 'master' into matt/be-944-core-cursor-based-pagination-for-get-apiassets

This commit is contained in:
Matt Miller 2026-05-21 13:17:52 -07:00 committed by GitHub
commit e4508af5e4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 827 additions and 125 deletions

View File

@ -1613,6 +1613,16 @@ class ModelPatcherDynamic(ModelPatcher):
#use all ModelPatcherDynamic this is ignored and its all done dynamically. #use all ModelPatcherDynamic this is ignored and its all done dynamically.
return super().memory_required(input_shape=input_shape) * 1.3 + (1024 ** 3) return super().memory_required(input_shape=input_shape) * 1.3 + (1024 ** 3)
def restore_loaded_backups(self):
restored = self.model.model_loaded_weight_memory
for key in list(self.backup.keys()):
bk = self.backup.pop(key)
comfy.utils.set_attr_param(self.model, key, bk.weight)
for key in list(self.backup_buffers.keys()):
comfy.utils.set_attr_buffer(self.model, key, self.backup_buffers.pop(key))
self.model.model_loaded_weight_memory = 0
return restored
def load(self, device_to=None, lowvram_model_memory=0, force_patch_weights=False, full_load=False, dirty=False): def load(self, device_to=None, lowvram_model_memory=0, force_patch_weights=False, full_load=False, dirty=False):
@ -1629,7 +1639,7 @@ class ModelPatcherDynamic(ModelPatcher):
num_patches = 0 num_patches = 0
allocated_size = 0 allocated_size = 0
self.model.model_loaded_weight_memory = 0 self.restore_loaded_backups()
with self.use_ejected(): with self.use_ejected():
self.unpatch_hooks() self.unpatch_hooks()
@ -1716,6 +1726,9 @@ class ModelPatcherDynamic(ModelPatcher):
force_load=True force_load=True
if force_load: if force_load:
if hasattr(m, "_v"):
comfy_aimdo.model_vbar.vbar_unpin(m._v)
delattr(m, "_v")
force_load_param(self, "weight", device_to) force_load_param(self, "weight", device_to)
force_load_param(self, "bias", device_to) force_load_param(self, "bias", device_to)
else: else:
@ -1773,13 +1786,7 @@ class ModelPatcherDynamic(ModelPatcher):
freed = 0 if vbar is None else vbar.free_memory(memory_to_free) freed = 0 if vbar is None else vbar.free_memory(memory_to_free)
if freed < memory_to_free: if freed < memory_to_free:
for key in list(self.backup.keys()): freed += self.restore_loaded_backups()
bk = self.backup.pop(key)
comfy.utils.set_attr_param(self.model, key, bk.weight)
for key in list(self.backup_buffers.keys()):
comfy.utils.set_attr_buffer(self.model, key, self.backup_buffers.pop(key))
freed += self.model.model_loaded_weight_memory
self.model.model_loaded_weight_memory = 0
return freed return freed

View File

@ -1019,10 +1019,11 @@ def bislerp(samples, width, height):
def lanczos(samples, width, height): def lanczos(samples, width, height):
#the below API is strict and expects grayscale to be squeezed #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) if samples.ndim == 4:
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.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 = [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] images = [torch.from_numpy(t).movedim(-1, 0) if (t := np.array(image).astype(np.float32) / 255.0).ndim == 3 else torch.from_numpy(t) for image in images]
result = torch.stack(images) result = torch.stack(images)
return result.to(samples.device, samples.dtype) return result.to(samples.device, samples.dtype)

View File

@ -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)

View 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)

View File

@ -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,
) )

View File

@ -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):

View 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()

View File

@ -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",

View File

@ -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)

View File

@ -543,7 +543,7 @@ class AudioConcat(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="AudioConcat", node_id="AudioConcat",
search_aliases=["join audio", "combine audio", "append audio"], search_aliases=["join audio", "combine audio", "append audio"],
display_name="Audio Concat", display_name="Concatenate Audio",
description="Concatenates the audio1 to audio2 in the specified direction.", description="Concatenates the audio1 to audio2 in the specified direction.",
category="audio", category="audio",
inputs=[ inputs=[
@ -597,7 +597,7 @@ class AudioMerge(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="AudioMerge", node_id="AudioMerge",
search_aliases=["mix audio", "overlay audio", "layer audio"], search_aliases=["mix audio", "overlay audio", "layer audio"],
display_name="Audio Merge", display_name="Merge Audio",
description="Combine two audio tracks by overlaying their waveforms.", description="Combine two audio tracks by overlaying their waveforms.",
category="audio", category="audio",
inputs=[ inputs=[
@ -667,8 +667,9 @@ class AudioAdjustVolume(IO.ComfyNode):
return IO.Schema( return IO.Schema(
node_id="AudioAdjustVolume", node_id="AudioAdjustVolume",
search_aliases=["audio gain", "loudness", "audio level"], search_aliases=["audio gain", "loudness", "audio level"],
display_name="Audio Adjust Volume", display_name="Adjust Audio Volume",
category="audio", category="audio",
description="Adjust the volume of the audio by a specified amount in decibels (dB).",
inputs=[ inputs=[
IO.Audio.Input("audio"), IO.Audio.Input("audio"),
IO.Int.Input( IO.Int.Input(

View File

@ -47,8 +47,10 @@ class LoadImageDataSetFromFolderNode(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="LoadImageDataSetFromFolder", node_id="LoadImageDataSetFromFolder",
display_name="Load Image Dataset from Folder", search_aliases=["load folder", "load from folder", "load dataset", "load images", "import dataset"],
category="dataset", display_name="Load Image (from Folder)",
category="image",
description="Load a dataset of images from a specified folder and return a list of images. Supported formats: PNG, JPG, JPEG, WEBP.",
is_experimental=True, is_experimental=True,
inputs=[ inputs=[
io.Combo.Input( io.Combo.Input(
@ -84,14 +86,16 @@ class LoadImageTextDataSetFromFolderNode(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="LoadImageTextDataSetFromFolder", node_id="LoadImageTextDataSetFromFolder",
display_name="Load Image and Text Dataset from Folder", search_aliases=["load folder", "load from folder", "load dataset", "load images", "import dataset"],
category="dataset", display_name="Load Image-Text (from Folder)",
category="image",
description="Load a dataset of pairs of images and text captions from a specified folder and return them as a list. Supported formats: PNG, JPG, JPEG, WEBP.",
is_experimental=True, is_experimental=True,
inputs=[ inputs=[
io.Combo.Input( io.Combo.Input(
"folder", "folder",
options=folder_paths.get_input_subfolders(), options=folder_paths.get_input_subfolders(),
tooltip="The folder to load images from.", tooltip="The folder to load images and text captions from.",
) )
], ],
outputs=[ outputs=[
@ -206,8 +210,10 @@ class SaveImageDataSetToFolderNode(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="SaveImageDataSetToFolder", node_id="SaveImageDataSetToFolder",
display_name="Save Image Dataset to Folder", search_aliases=["save folder", "save to folder", "save dataset", "save images", "export dataset"],
category="dataset", display_name="Save Image (to Folder) (DEPRECATED)",
category="image",
description="Save a dataset of images to a specified folder. Supported formats: PNG.",
is_experimental=True, is_experimental=True,
is_output_node=True, is_output_node=True,
is_input_list=True, # Receive images as list is_input_list=True, # Receive images as list
@ -226,6 +232,7 @@ class SaveImageDataSetToFolderNode(io.ComfyNode):
), ),
], ],
outputs=[], outputs=[],
is_deprecated=True, # This node is redundant and superseded by existing Save Image nodes where the target folder can be specified in the filename_prefix
) )
@classmethod @classmethod
@ -246,14 +253,20 @@ class SaveImageTextDataSetToFolderNode(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="SaveImageTextDataSetToFolder", node_id="SaveImageTextDataSetToFolder",
display_name="Save Image and Text Dataset to Folder", search_aliases=["save folder", "save to folder", "save dataset", "save images", "save text", "export dataset"],
category="dataset", display_name="Save Image-Text (to Folder)",
category="image",
description="Save a dataset of pairs of images and text captions to a specified folder. Images are saved as PNG files and captions are saved as TXT files with the same filename_prefix.",
is_experimental=True, is_experimental=True,
is_output_node=True, is_output_node=True,
is_input_list=True, # Receive both images and texts as lists is_input_list=True, # Receive both images and texts as lists
inputs=[ inputs=[
io.Image.Input("images", tooltip="List of images to save."), io.Image.Input("images", tooltip="List of images to save."),
io.String.Input("texts", tooltip="List of text captions to save."), io.String.Input("texts",
optional=True,
force_input=True,
tooltip="List of text captions to save."
),
io.String.Input( io.String.Input(
"folder_name", "folder_name",
default="dataset", default="dataset",
@ -270,7 +283,7 @@ class SaveImageTextDataSetToFolderNode(io.ComfyNode):
) )
@classmethod @classmethod
def execute(cls, images, texts, folder_name, filename_prefix): def execute(cls, images, folder_name, filename_prefix, texts=None):
# Extract scalar values # Extract scalar values
folder_name = folder_name[0] folder_name = folder_name[0]
filename_prefix = filename_prefix[0] filename_prefix = filename_prefix[0]
@ -279,11 +292,12 @@ class SaveImageTextDataSetToFolderNode(io.ComfyNode):
saved_files = save_images_to_folder(images, output_dir, filename_prefix) saved_files = save_images_to_folder(images, output_dir, filename_prefix)
# Save captions # Save captions
for idx, (filename, caption) in enumerate(zip(saved_files, texts)): if texts:
caption_filename = filename.replace(".png", ".txt") for idx, (filename, caption) in enumerate(zip(saved_files, texts)):
caption_path = os.path.join(output_dir, caption_filename) caption_filename = filename.replace(".png", ".txt")
with open(caption_path, "w", encoding="utf-8") as f: caption_path = os.path.join(output_dir, caption_filename)
f.write(caption) with open(caption_path, "w", encoding="utf-8") as f:
f.write(caption)
logging.info(f"Saved {len(saved_files)} images and captions to {output_dir}.") logging.info(f"Saved {len(saved_files)} images and captions to {output_dir}.")
return io.NodeOutput() return io.NodeOutput()
@ -314,11 +328,13 @@ class ImageProcessingNode(io.ComfyNode):
Child classes should set: Child classes should set:
node_id: Unique node identifier (required) node_id: Unique node identifier (required)
search_aliases: List of search aliases (optional)
display_name: Display name (optional, defaults to node_id) display_name: Display name (optional, defaults to node_id)
description: Node description (optional) description: Node description (optional)
extra_inputs: List of additional io.Input objects beyond "images" (optional) extra_inputs: List of additional io.Input objects beyond "images" (optional)
is_group_process: None (auto-detect), True (group), or False (individual) (optional) is_group_process: None (auto-detect), True (group), or False (individual) (optional)
is_output_list: True (list output) or False (single output) (optional, default True) is_output_list: True (list output) or False (single output) (optional, default True)
is_deprecated: True if the node is deprecated (optional, default False)
Child classes must implement ONE of: Child classes must implement ONE of:
_process(cls, image, **kwargs) -> tensor (for single-item processing) _process(cls, image, **kwargs) -> tensor (for single-item processing)
@ -326,12 +342,13 @@ class ImageProcessingNode(io.ComfyNode):
""" """
node_id = None node_id = None
search_aliases = []
display_name = None display_name = None
description = None description = None
extra_inputs = [] extra_inputs = []
is_group_process = None # None = auto-detect, True/False = explicit is_group_process = None # None = auto-detect, True/False = explicit
is_output_list = None # None = auto-detect based on processing mode is_output_list = None # None = auto-detect based on processing mode
is_deprecated = False
@classmethod @classmethod
def _detect_processing_mode(cls): def _detect_processing_mode(cls):
"""Detect whether this node uses group or individual processing. """Detect whether this node uses group or individual processing.
@ -402,8 +419,10 @@ class ImageProcessingNode(io.ComfyNode):
return io.Schema( return io.Schema(
node_id=cls.node_id, node_id=cls.node_id,
search_aliases=cls.search_aliases,
display_name=cls.display_name or cls.node_id, display_name=cls.display_name or cls.node_id,
category="dataset/image", category=cls.category,
description=cls.description,
is_experimental=True, is_experimental=True,
is_input_list=is_group, # True for group, False for individual is_input_list=is_group, # True for group, False for individual
inputs=inputs, inputs=inputs,
@ -472,11 +491,13 @@ class TextProcessingNode(io.ComfyNode):
Child classes should set: Child classes should set:
node_id: Unique node identifier (required) node_id: Unique node identifier (required)
search_aliases: List of search aliases (optional)
display_name: Display name (optional, defaults to node_id) display_name: Display name (optional, defaults to node_id)
description: Node description (optional) description: Node description (optional)
extra_inputs: List of additional io.Input objects beyond "texts" (optional) extra_inputs: List of additional io.Input objects beyond "texts" (optional)
is_group_process: None (auto-detect), True (group), or False (individual) (optional) is_group_process: None (auto-detect), True (group), or False (individual) (optional)
is_output_list: True (list output) or False (single output) (optional, default True) is_output_list: True (list output) or False (single output) (optional, default True)
is_deprecated: True if the node is deprecated (optional, default False)
Child classes must implement ONE of: Child classes must implement ONE of:
_process(cls, text, **kwargs) -> str (for single-item processing) _process(cls, text, **kwargs) -> str (for single-item processing)
@ -484,12 +505,13 @@ class TextProcessingNode(io.ComfyNode):
""" """
node_id = None node_id = None
search_aliases = []
display_name = None display_name = None
description = None description = None
extra_inputs = [] extra_inputs = []
is_group_process = None # None = auto-detect, True/False = explicit is_group_process = None # None = auto-detect, True/False = explicit
is_output_list = None # None = auto-detect based on processing mode is_output_list = None # None = auto-detect based on processing mode
is_deprecated = False
@classmethod @classmethod
def _detect_processing_mode(cls): def _detect_processing_mode(cls):
"""Detect whether this node uses group or individual processing. """Detect whether this node uses group or individual processing.
@ -627,15 +649,17 @@ class TextProcessingNode(io.ComfyNode):
class ResizeImagesByShorterEdgeNode(ImageProcessingNode): class ResizeImagesByShorterEdgeNode(ImageProcessingNode):
node_id = "ResizeImagesByShorterEdge" node_id = "ResizeImagesByShorterEdge"
display_name = "Resize Images by Shorter Edge" display_name = "Resize Images by Shorter Edge (DEPRECATED)"
description = "Resize images so that the shorter edge matches the specified length while preserving aspect ratio." category = "image/transform"
description = "Resize images so that the shorter edge matches the specified dimension while preserving aspect ratio."
is_deprecated = True # This node is superseded by Resize Image/Mask with resize_type = scale shorter dimension
extra_inputs = [ extra_inputs = [
io.Int.Input( io.Int.Input(
"shorter_edge", "shorter_edge",
default=512, default=512,
min=1, min=1,
max=8192, max=8192,
tooltip="Target length for the shorter edge.", tooltip="Target dimension for the shorter edge.",
), ),
] ]
@ -655,15 +679,17 @@ class ResizeImagesByShorterEdgeNode(ImageProcessingNode):
class ResizeImagesByLongerEdgeNode(ImageProcessingNode): class ResizeImagesByLongerEdgeNode(ImageProcessingNode):
node_id = "ResizeImagesByLongerEdge" node_id = "ResizeImagesByLongerEdge"
display_name = "Resize Images by Longer Edge" display_name = "Resize Images by Longer Edge (DEPRECATED)"
description = "Resize images so that the longer edge matches the specified length while preserving aspect ratio." category = "image/transform"
description = "Resize images so that the longer edge matches the specified dimension while preserving aspect ratio."
is_deprecated = True # This node is superseded by Resize Image/Mask with resize_type = scale longer dimension
extra_inputs = [ extra_inputs = [
io.Int.Input( io.Int.Input(
"longer_edge", "longer_edge",
default=1024, default=1024,
min=1, min=1,
max=8192, max=8192,
tooltip="Target length for the longer edge.", tooltip="Target dimension for the longer edge.",
), ),
] ]
@ -686,8 +712,10 @@ class ResizeImagesByLongerEdgeNode(ImageProcessingNode):
class CenterCropImagesNode(ImageProcessingNode): class CenterCropImagesNode(ImageProcessingNode):
node_id = "CenterCropImages" node_id = "CenterCropImages"
display_name = "Center Crop Images" search_aliases=["crop", "cut", "trim"]
description = "Center crop all images to the specified dimensions." display_name="Crop Image (Center)"
category="image/transform"
description = "Center crop an image to the specified dimensions."
extra_inputs = [ extra_inputs = [
io.Int.Input("width", default=512, min=1, max=8192, tooltip="Crop width."), io.Int.Input("width", default=512, min=1, max=8192, tooltip="Crop width."),
io.Int.Input("height", default=512, min=1, max=8192, tooltip="Crop height."), io.Int.Input("height", default=512, min=1, max=8192, tooltip="Crop height."),
@ -706,10 +734,11 @@ class CenterCropImagesNode(ImageProcessingNode):
class RandomCropImagesNode(ImageProcessingNode): class RandomCropImagesNode(ImageProcessingNode):
node_id = "RandomCropImages" node_id = "RandomCropImages"
display_name = "Random Crop Images" search_aliases=["crop", "cut", "trim"]
description = ( display_name = "Crop Image (Random)"
"Randomly crop all images to the specified dimensions (for data augmentation)." category="image/transform"
) description = "Randomly crop an image to the specified dimensions."
extra_inputs = [ extra_inputs = [
io.Int.Input("width", default=512, min=1, max=8192, tooltip="Crop width."), io.Int.Input("width", default=512, min=1, max=8192, tooltip="Crop width."),
io.Int.Input("height", default=512, min=1, max=8192, tooltip="Crop height."), io.Int.Input("height", default=512, min=1, max=8192, tooltip="Crop height."),
@ -734,7 +763,9 @@ class RandomCropImagesNode(ImageProcessingNode):
class NormalizeImagesNode(ImageProcessingNode): class NormalizeImagesNode(ImageProcessingNode):
node_id = "NormalizeImages" node_id = "NormalizeImages"
display_name = "Normalize Images" search_aliases=["normalize", "normalize colors"]
display_name = "Normalize Image Colors"
category = "image/color"
description = "Normalize images using mean and standard deviation." description = "Normalize images using mean and standard deviation."
extra_inputs = [ extra_inputs = [
io.Float.Input( io.Float.Input(
@ -762,8 +793,10 @@ class NormalizeImagesNode(ImageProcessingNode):
class AdjustBrightnessNode(ImageProcessingNode): class AdjustBrightnessNode(ImageProcessingNode):
node_id = "AdjustBrightness" node_id = "AdjustBrightness"
search_aliases=["brightness"]
display_name = "Adjust Brightness" display_name = "Adjust Brightness"
description = "Adjust brightness of all images." category="image/adjustments"
description = "Adjust the brightness of an image."
extra_inputs = [ extra_inputs = [
io.Float.Input( io.Float.Input(
"factor", "factor",
@ -781,8 +814,10 @@ class AdjustBrightnessNode(ImageProcessingNode):
class AdjustContrastNode(ImageProcessingNode): class AdjustContrastNode(ImageProcessingNode):
node_id = "AdjustContrast" node_id = "AdjustContrast"
search_aliases=["contrast"]
display_name = "Adjust Contrast" display_name = "Adjust Contrast"
description = "Adjust contrast of all images." category="image/adjustments"
description = "Adjust the contrast of an image."
extra_inputs = [ extra_inputs = [
io.Float.Input( io.Float.Input(
"factor", "factor",
@ -800,8 +835,10 @@ class AdjustContrastNode(ImageProcessingNode):
class ShuffleDatasetNode(ImageProcessingNode): class ShuffleDatasetNode(ImageProcessingNode):
node_id = "ShuffleDataset" node_id = "ShuffleDataset"
display_name = "Shuffle Image Dataset" search_aliases=["shuffle", "randomize", "mix"]
description = "Randomly shuffle the order of images in the dataset." display_name = "Shuffle Images List"
category = "image/batch"
description = "Randomly shuffle the order of images in a list."
is_group_process = True # Requires full list to shuffle is_group_process = True # Requires full list to shuffle
extra_inputs = [ extra_inputs = [
io.Int.Input( io.Int.Input(
@ -823,13 +860,15 @@ class ShuffleImageTextDatasetNode(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="ShuffleImageTextDataset", node_id="ShuffleImageTextDataset",
display_name="Shuffle Image-Text Dataset", search_aliases=["shuffle", "randomize", "mix"],
category="dataset/image", display_name = "Shuffle Pairs of Image-Text",
category = "image/batch",
description = "Randomly shuffle the order of pairs of image-text in a list.",
is_experimental=True, is_experimental=True,
is_input_list=True, is_input_list=True,
inputs=[ inputs=[
io.Image.Input("images", tooltip="List of images to shuffle."), io.Image.Input("images", tooltip="List of images to shuffle."),
io.String.Input("texts", tooltip="List of texts to shuffle."), io.String.Input("texts", tooltip="List of texts to shuffle.", force_input=True),
io.Int.Input( io.Int.Input(
"seed", "seed",
default=0, default=0,
@ -865,8 +904,11 @@ class ShuffleImageTextDatasetNode(io.ComfyNode):
class TextToLowercaseNode(TextProcessingNode): class TextToLowercaseNode(TextProcessingNode):
node_id = "TextToLowercase" node_id = "TextToLowercase"
display_name = "Text to Lowercase" search_aliases=["lowercase"]
description = "Convert all texts to lowercase." display_name = "Convert Text to Lowercase (DEPRECATED)"
category = "text"
description = "Convert text to lowercase."
is_deprecated = True # This node is superseded by the Convert Text Case node
@classmethod @classmethod
def _process(cls, text): def _process(cls, text):
@ -875,8 +917,11 @@ class TextToLowercaseNode(TextProcessingNode):
class TextToUppercaseNode(TextProcessingNode): class TextToUppercaseNode(TextProcessingNode):
node_id = "TextToUppercase" node_id = "TextToUppercase"
display_name = "Text to Uppercase" search_aliases=["uppercase"]
description = "Convert all texts to uppercase." display_name = "Convert Text to Uppercase (DEPRECATED)"
category = "text"
description = "Convert text to uppercase."
is_deprecated = True # This node is superseded by the Convert Text Case node
@classmethod @classmethod
def _process(cls, text): def _process(cls, text):
@ -885,8 +930,10 @@ class TextToUppercaseNode(TextProcessingNode):
class TruncateTextNode(TextProcessingNode): class TruncateTextNode(TextProcessingNode):
node_id = "TruncateText" node_id = "TruncateText"
search_aliases=["truncate", "cut", "shorten"]
display_name = "Truncate Text" display_name = "Truncate Text"
description = "Truncate all texts to a maximum length." category = "text"
description = "Truncate text to a maximum length."
extra_inputs = [ extra_inputs = [
io.Int.Input( io.Int.Input(
"max_length", default=77, min=1, max=10000, tooltip="Maximum text length." "max_length", default=77, min=1, max=10000, tooltip="Maximum text length."
@ -900,8 +947,10 @@ class TruncateTextNode(TextProcessingNode):
class AddTextPrefixNode(TextProcessingNode): class AddTextPrefixNode(TextProcessingNode):
node_id = "AddTextPrefix" node_id = "AddTextPrefix"
display_name = "Add Text Prefix" display_name = "Add Text Prefix (DEPRECATED)"
category = "text"
description = "Add a prefix to all texts." description = "Add a prefix to all texts."
is_deprecated = True # This node is superseded by the Concatenate Text node
extra_inputs = [ extra_inputs = [
io.String.Input("prefix", default="", tooltip="Prefix to add."), io.String.Input("prefix", default="", tooltip="Prefix to add."),
] ]
@ -913,8 +962,10 @@ class AddTextPrefixNode(TextProcessingNode):
class AddTextSuffixNode(TextProcessingNode): class AddTextSuffixNode(TextProcessingNode):
node_id = "AddTextSuffix" node_id = "AddTextSuffix"
display_name = "Add Text Suffix" display_name = "Add Text Suffix (DEPRECATED)"
category = "text"
description = "Add a suffix to all texts." description = "Add a suffix to all texts."
is_deprecated = True # This node is superseded by the Concatenate Text node
extra_inputs = [ extra_inputs = [
io.String.Input("suffix", default="", tooltip="Suffix to add."), io.String.Input("suffix", default="", tooltip="Suffix to add."),
] ]
@ -926,8 +977,10 @@ class AddTextSuffixNode(TextProcessingNode):
class ReplaceTextNode(TextProcessingNode): class ReplaceTextNode(TextProcessingNode):
node_id = "ReplaceText" node_id = "ReplaceText"
display_name = "Replace Text" display_name = "Replace Text (DEPRECATED)"
category = "text"
description = "Replace text in all texts." description = "Replace text in all texts."
is_deprecated = True # This node is superseded by the other Replace Text node
extra_inputs = [ extra_inputs = [
io.String.Input("find", default="", tooltip="Text to find."), io.String.Input("find", default="", tooltip="Text to find."),
io.String.Input("replace", default="", tooltip="Text to replace with."), io.String.Input("replace", default="", tooltip="Text to replace with."),
@ -940,8 +993,10 @@ class ReplaceTextNode(TextProcessingNode):
class StripWhitespaceNode(TextProcessingNode): class StripWhitespaceNode(TextProcessingNode):
node_id = "StripWhitespace" node_id = "StripWhitespace"
display_name = "Strip Whitespace" display_name = "Strip Whitespace (DEPRECATED)"
category = "text"
description = "Strip leading and trailing whitespace from all texts." description = "Strip leading and trailing whitespace from all texts."
is_deprecated = True # This node is superseded by the Trim Text node
@classmethod @classmethod
def _process(cls, text): def _process(cls, text):
@ -952,11 +1007,13 @@ class StripWhitespaceNode(TextProcessingNode):
class ImageDeduplicationNode(ImageProcessingNode): class ImageDeduplicationNode(ImageProcessingNode):
"""Remove duplicate or very similar images from the dataset using perceptual hashing.""" """Remove duplicate or very similar images from a list using perceptual hashing."""
node_id = "ImageDeduplication" node_id = "ImageDeduplication"
display_name = "Image Deduplication" search_aliases=["deduplicate", "remove duplicates", "similarity filter"]
description = "Remove duplicate or very similar images from the dataset." display_name = "Deduplicate Images"
category = "image/batch"
description = "Remove duplicate or very similar images from a list."
is_group_process = True # Requires full list to compare images is_group_process = True # Requires full list to compare images
extra_inputs = [ extra_inputs = [
io.Float.Input( io.Float.Input(
@ -1026,7 +1083,9 @@ class ImageGridNode(ImageProcessingNode):
"""Combine multiple images into a single grid/collage.""" """Combine multiple images into a single grid/collage."""
node_id = "ImageGrid" node_id = "ImageGrid"
display_name = "Image Grid" search_aliases=["grid", "collage", "combine"]
display_name = "Make Image Grid"
category="image/batch"
description = "Arrange multiple images into a grid layout." description = "Arrange multiple images into a grid layout."
is_group_process = True # Requires full list to create grid is_group_process = True # Requires full list to create grid
is_output_list = False # Outputs single grid image is_output_list = False # Outputs single grid image
@ -1102,9 +1161,12 @@ class MergeImageListsNode(ImageProcessingNode):
"""Merge multiple image lists into a single list.""" """Merge multiple image lists into a single list."""
node_id = "MergeImageLists" node_id = "MergeImageLists"
display_name = "Merge Image Lists" search_aliases=["list", "merge list", "make list"]
display_name = "Merge Image Lists (DEPRECATED)"
category = "image/batch"
description = "Concatenate multiple image lists into one." description = "Concatenate multiple image lists into one."
is_group_process = True # Receives images as list is_group_process = True # Receives images as list
is_deprecated = True # This node is superseded by the Create List node
@classmethod @classmethod
def _group_process(cls, images): def _group_process(cls, images):
@ -1119,9 +1181,11 @@ class MergeTextListsNode(TextProcessingNode):
"""Merge multiple text lists into a single list.""" """Merge multiple text lists into a single list."""
node_id = "MergeTextLists" node_id = "MergeTextLists"
display_name = "Merge Text Lists" display_name = "Merge Text Lists (DEPRECATED)"
category = "text"
description = "Concatenate multiple text lists into one." description = "Concatenate multiple text lists into one."
is_group_process = True # Receives texts as list is_group_process = True # Receives texts as list
is_deprecated = True # This node is superseded by the Create List node
@classmethod @classmethod
def _group_process(cls, texts): def _group_process(cls, texts):
@ -1142,8 +1206,10 @@ class ResolutionBucket(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="ResolutionBucket", node_id="ResolutionBucket",
search_aliases=["bucket by resolution", "group by resolution", "batch by resolution"],
display_name="Resolution Bucket", display_name="Resolution Bucket",
category="dataset", category="training",
description="Group latents and conditionings into buckets",
is_experimental=True, is_experimental=True,
is_input_list=True, is_input_list=True,
inputs=[ inputs=[
@ -1236,7 +1302,8 @@ class MakeTrainingDataset(io.ComfyNode):
node_id="MakeTrainingDataset", node_id="MakeTrainingDataset",
search_aliases=["encode dataset"], search_aliases=["encode dataset"],
display_name="Make Training Dataset", display_name="Make Training Dataset",
category="dataset", category="training",
description="Encode images with VAE and texts with CLIP to create a training dataset of latents and conditionings.",
is_experimental=True, is_experimental=True,
is_input_list=True, # images and texts as lists is_input_list=True, # images and texts as lists
inputs=[ inputs=[
@ -1251,6 +1318,7 @@ class MakeTrainingDataset(io.ComfyNode):
"texts", "texts",
optional=True, optional=True,
tooltip="List of text captions. Can be length n (matching images), 1 (repeated for all), or omitted (uses empty string).", tooltip="List of text captions. Can be length n (matching images), 1 (repeated for all), or omitted (uses empty string).",
force_input=True
), ),
], ],
outputs=[ outputs=[
@ -1320,9 +1388,10 @@ class SaveTrainingDataset(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="SaveTrainingDataset", node_id="SaveTrainingDataset",
search_aliases=["export training data"], search_aliases=["export dataset", "save dataset"],
display_name="Save Training Dataset", display_name="Save Training Dataset",
category="dataset", category="training",
description="Save encoded training dataset (latents + conditioning) to disk for efficient loading during training.",
is_experimental=True, is_experimental=True,
is_output_node=True, is_output_node=True,
is_input_list=True, # Receive lists is_input_list=True, # Receive lists
@ -1424,7 +1493,8 @@ class LoadTrainingDataset(io.ComfyNode):
node_id="LoadTrainingDataset", node_id="LoadTrainingDataset",
search_aliases=["import dataset", "training data"], search_aliases=["import dataset", "training data"],
display_name="Load Training Dataset", display_name="Load Training Dataset",
category="dataset", category="training",
description="Load encoded training dataset (latents + conditioning) from disk for use in training.",
is_experimental=True, is_experimental=True,
inputs=[ inputs=[
io.String.Input( io.String.Input(

View File

@ -419,15 +419,17 @@ class VoxelToMeshBasic(IO.ComfyNode):
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="VoxelToMeshBasic", node_id="VoxelToMeshBasic",
display_name="Voxel to Mesh (Basic)", display_name="Voxel to Mesh (Basic) (DEPRECATED)",
category="3d", category="3d",
description="Converts a voxel grid to a mesh.",
is_deprecated=True, # This node is superseded by the Voxel To Mesh node
inputs=[ inputs=[
IO.Voxel.Input("voxel"), IO.Voxel.Input("voxel"),
IO.Float.Input("threshold", default=0.6, min=-1.0, max=1.0, step=0.01), IO.Float.Input("threshold", default=0.6, min=-1.0, max=1.0, step=0.01),
], ],
outputs=[ outputs=[
IO.Mesh.Output(), IO.Mesh.Output(),
] ],
) )
@classmethod @classmethod
@ -453,9 +455,10 @@ class VoxelToMesh(IO.ComfyNode):
node_id="VoxelToMesh", node_id="VoxelToMesh",
display_name="Voxel to Mesh", display_name="Voxel to Mesh",
category="3d", category="3d",
description="Converts a voxel grid to a mesh.",
inputs=[ inputs=[
IO.Voxel.Input("voxel"), IO.Voxel.Input("voxel"),
IO.Combo.Input("algorithm", options=["surface net", "basic"], advanced=True), IO.Combo.Input("algorithm", options=["surface net", "basic"]),
IO.Float.Input("threshold", default=0.6, min=-1.0, max=1.0, step=0.01), IO.Float.Input("threshold", default=0.6, min=-1.0, max=1.0, step=0.01),
], ],
outputs=[ outputs=[

View File

@ -55,9 +55,10 @@ class ImageCropV2(IO.ComfyNode):
def define_schema(cls): def define_schema(cls):
return IO.Schema( return IO.Schema(
node_id="ImageCropV2", node_id="ImageCropV2",
search_aliases=["trim"], search_aliases=["crop", "cut", "trim"],
display_name="Crop Image", display_name="Crop Image",
category="image/transform", category="image/transform",
description = "Crop an image to the specified dimensions.",
essentials_category="Image Tools", essentials_category="Image Tools",
has_intermediate_output=True, has_intermediate_output=True,
inputs=[ inputs=[

View File

@ -11,8 +11,8 @@ class LTXVAudioVAELoader(io.ComfyNode):
def define_schema(cls) -> io.Schema: def define_schema(cls) -> io.Schema:
return io.Schema( return io.Schema(
node_id="LTXVAudioVAELoader", node_id="LTXVAudioVAELoader",
display_name="LTXV Audio VAE Loader", display_name="Load LTXV Audio VAE",
category="audio", category="loaders",
inputs=[ inputs=[
io.Combo.Input( io.Combo.Input(
"ckpt_name", "ckpt_name",
@ -40,7 +40,7 @@ class LTXVAudioVAEEncode(VAEEncodeAudio):
return io.Schema( return io.Schema(
node_id="LTXVAudioVAEEncode", node_id="LTXVAudioVAEEncode",
display_name="LTXV Audio VAE Encode", display_name="LTXV Audio VAE Encode",
category="audio", category="latent/audio",
inputs=[ inputs=[
io.Audio.Input("audio", tooltip="The audio to be encoded."), io.Audio.Input("audio", tooltip="The audio to be encoded."),
io.Vae.Input( io.Vae.Input(
@ -63,7 +63,7 @@ class LTXVAudioVAEDecode(io.ComfyNode):
return io.Schema( return io.Schema(
node_id="LTXVAudioVAEDecode", node_id="LTXVAudioVAEDecode",
display_name="LTXV Audio VAE Decode", display_name="LTXV Audio VAE Decode",
category="audio", category="latent/audio",
inputs=[ inputs=[
io.Latent.Input("samples", tooltip="The latent to be decoded."), io.Latent.Input("samples", tooltip="The latent to be decoded."),
io.Vae.Input( io.Vae.Input(

View File

@ -28,7 +28,7 @@ from comfy_extras.mediapipe.face_landmarker import FaceLandmarker
from comfy_extras.mediapipe.face_geometry import transformation_matrix_from_detection from comfy_extras.mediapipe.face_geometry import transformation_matrix_from_detection
FaceLandmarkerType = io.Custom("FACE_LANDMARKER") FaceDetectionType = io.Custom("FACE_DETECTION_MODEL")
FaceLandmarksType = io.Custom("FACE_LANDMARKS") FaceLandmarksType = io.Custom("FACE_LANDMARKS")
_CANONICAL_KEYS = ("canonical_vertices", "procrustes_indices", "procrustes_weights") _CANONICAL_KEYS = ("canonical_vertices", "procrustes_indices", "procrustes_weights")
@ -204,18 +204,19 @@ class LoadMediaPipeFaceLandmarker(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="LoadMediaPipeFaceLandmarker", node_id="LoadMediaPipeFaceLandmarker",
display_name="Load MediaPipe Face Landmarker", search_aliases=["face", "facial", "mediapipe", "face landmark", "face mesh", "blazeface", "face detection"],
display_name="Load Face Detection Model (MediaPipe)",
category="loaders", category="loaders",
inputs=[ inputs=[
io.Combo.Input("model_name", options=folder_paths.get_filename_list("mediapipe"), io.Combo.Input("model_name", options=folder_paths.get_filename_list("detection"),
tooltip="Face Landmarker safetensors from models/mediapipe/."), tooltip="Face detection model from models/detection/."),
], ],
outputs=[FaceLandmarkerType.Output()], outputs=[FaceDetectionType.Output()],
) )
@classmethod @classmethod
def execute(cls, model_name) -> io.NodeOutput: def execute(cls, model_name) -> io.NodeOutput:
sd = comfy.utils.load_torch_file(folder_paths.get_full_path_or_raise("mediapipe", model_name), safe_load=True) sd = comfy.utils.load_torch_file(folder_paths.get_full_path_or_raise("detection", model_name), safe_load=True)
wrapper = FaceLandmarkerModel(sd) wrapper = FaceLandmarkerModel(sd)
return io.NodeOutput(wrapper) return io.NodeOutput(wrapper)
@ -234,10 +235,12 @@ class MediaPipeFaceLandmarker(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="MediaPipeFaceLandmarker", node_id="MediaPipeFaceLandmarker",
display_name="MediaPipe Face Landmarker", search_aliases=["face", "facial", "mediapipe", "face landmark", "face mesh", "blazeface", "face detection"],
display_name="Detect Face Landmarks (MediaPipe)",
category="image/detection", category="image/detection",
description="Detects facial landmarks using MediaPipe model.",
inputs=[ inputs=[
FaceLandmarkerType.Input("face_landmarker"), FaceDetectionType.Input("face_detection_model"),
io.Image.Input("image"), io.Image.Input("image"),
io.Combo.Input("detector_variant", options=["short", "full", "both"], default="short", io.Combo.Input("detector_variant", options=["short", "full", "both"], default="short",
tooltip="Face detector range. 'short' is tuned for close-up faces " tooltip="Face detector range. 'short' is tuned for close-up faces "
@ -261,9 +264,9 @@ class MediaPipeFaceLandmarker(io.ComfyNode):
) )
@classmethod @classmethod
def execute(cls, face_landmarker, image, detector_variant, num_faces, min_confidence, def execute(cls, face_detection_model, image, detector_variant, num_faces, min_confidence,
missing_frame_fallback) -> io.NodeOutput: missing_frame_fallback) -> io.NodeOutput:
canonical = face_landmarker.canonical_data canonical = face_detection_model.canonical_data
img_np = _image_to_uint8(image) img_np = _image_to_uint8(image)
B, H, W = img_np.shape[:3] B, H, W = img_np.shape[:3]
chunk = 16 chunk = 16
@ -276,7 +279,7 @@ class MediaPipeFaceLandmarker(io.ComfyNode):
with tqdm(total=B, desc=f"MediaPipe Face Landmarker ({variant})") as tq: with tqdm(total=B, desc=f"MediaPipe Face Landmarker ({variant})") as tq:
for i in range(0, B, chunk): for i in range(0, B, chunk):
end = min(i + chunk, B) end = min(i + chunk, B)
res.extend(face_landmarker.detect_batch( res.extend(face_detection_model.detect_batch(
[img_np[bi] for bi in range(i, end)], [img_np[bi] for bi in range(i, end)],
num_faces=int(num_faces), num_faces=int(num_faces),
score_thresh=float(min_confidence), score_thresh=float(min_confidence),
@ -306,7 +309,7 @@ class MediaPipeFaceLandmarker(io.ComfyNode):
per_bb.append({"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1, "label": "face", "score": float(f["score"])}) per_bb.append({"x": x1, "y": y1, "width": x2 - x1, "height": y2 - y1, "label": "face", "score": float(f["score"])})
bboxes.append(per_bb) bboxes.append(per_bb)
return io.NodeOutput({"frames": frames, "image_size": (H, W), return io.NodeOutput({"frames": frames, "image_size": (H, W),
"connection_sets": face_landmarker.connection_sets}, bboxes) "connection_sets": face_detection_model.connection_sets}, bboxes)
# Topology keys unioned by the 'all' connections preset (contour parts + irises + nose). # Topology keys unioned by the 'all' connections preset (contour parts + irises + nose).
@ -332,8 +335,10 @@ class MediaPipeFaceMeshVisualize(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="MediaPipeFaceMeshVisualize", node_id="MediaPipeFaceMeshVisualize",
display_name="MediaPipe Face Mesh Visualize", search_aliases=["face", "facial", "mediapipe", "face landmark", "face mesh", "blazeface", "face detection", "visualize"],
display_name="Visualize Face Landmarks (MediaPipe)",
category="image/detection", category="image/detection",
description="Draws face landmarks mesh on the input image.",
inputs=[ inputs=[
FaceLandmarksType.Input("face_landmarks"), FaceLandmarksType.Input("face_landmarks"),
io.Image.Input("image", optional=True, tooltip="If not connected, a black canvas will be used."), io.Image.Input("image", optional=True, tooltip="If not connected, a black canvas will be used."),
@ -443,8 +448,10 @@ class MediaPipeFaceMask(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="MediaPipeFaceMask", node_id="MediaPipeFaceMask",
display_name="MediaPipe Face Mask", search_aliases=["face", "facial", "mediapipe", "face mask", "blazeface", "face detection", "visualize"],
display_name="Draw Face Mask (MediaPipe)",
category="image/detection", category="image/detection",
description="Draws a mask from face landmarks.",
inputs=[ inputs=[
FaceLandmarksType.Input("face_landmarks"), FaceLandmarksType.Input("face_landmarks"),
io.DynamicCombo.Input( io.DynamicCombo.Input(

View File

@ -103,8 +103,10 @@ class MoGePanoramaInference(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="MoGePanoramaInference", node_id="MoGePanoramaInference",
display_name="MoGe Panorama Inference", search_aliases=["moge", "panorama", "depth", "geometry", "depth estimation", "geometry estimation"],
display_name="Run MoGe Panorama Inference",
category="image/geometry_estimation", category="image/geometry_estimation",
description="Run MoGe on an equirectangular panorama by splitting it into 12 perspective views, running inference on each, and merging the results into a single depth map.",
inputs=[ inputs=[
MoGeModelType.Input("moge_model"), MoGeModelType.Input("moge_model"),
io.Image.Input("image", tooltip="Equirectangular panorama (any aspect)."), io.Image.Input("image", tooltip="Equirectangular panorama (any aspect)."),
@ -222,7 +224,9 @@ class MoGeInference(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="MoGeInference", node_id="MoGeInference",
display_name="MoGe Inference", search_aliases=["moge", "depth", "geometry", "depth estimation", "geometry estimation"],
display_name="Run MoGe Inference",
description="Run MoGe on a single image to estimate depth and geometry.",
category="image/geometry_estimation", category="image/geometry_estimation",
inputs=[ inputs=[
MoGeModelType.Input("moge_model"), MoGeModelType.Input("moge_model"),
@ -277,7 +281,9 @@ class MoGeRender(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="MoGeRender", node_id="MoGeRender",
display_name="MoGe Render", search_aliases=["moge", "render", "geometry", "depth", "normal"],
display_name="Render MoGe Geometry",
description="Render a depth map or normal map from geometry data",
category="image/geometry_estimation", category="image/geometry_estimation",
inputs=[ inputs=[
MoGeGeometry.Input("moge_geometry"), MoGeGeometry.Input("moge_geometry"),
@ -342,7 +348,9 @@ class MoGePointMapToMesh(io.ComfyNode):
def define_schema(cls): def define_schema(cls):
return io.Schema( return io.Schema(
node_id="MoGePointMapToMesh", node_id="MoGePointMapToMesh",
display_name="MoGe Point Map to Mesh", search_aliases=["moge", "mesh", "geometry", "point map"],
display_name="Convert MoGe Point Map to Mesh",
description="Convert a MoGe point map into a 3D mesh.",
category="image/geometry_estimation", category="image/geometry_estimation",
inputs=[ inputs=[
MoGeGeometry.Input("moge_geometry"), MoGeGeometry.Input("moge_geometry"),

View File

@ -60,7 +60,7 @@ folder_names_and_paths["geometry_estimation"] = ([os.path.join(models_dir, "geom
folder_names_and_paths["optical_flow"] = ([os.path.join(models_dir, "optical_flow")], supported_pt_extensions) folder_names_and_paths["optical_flow"] = ([os.path.join(models_dir, "optical_flow")], supported_pt_extensions)
folder_names_and_paths["mediapipe"] = ([os.path.join(models_dir, "mediapipe")], supported_pt_extensions) folder_names_and_paths["detection"] = ([os.path.join(models_dir, "detection")], supported_pt_extensions)
output_directory = os.path.join(base_path, "output") output_directory = os.path.join(base_path, "output")
temp_directory = os.path.join(base_path, "temp") temp_directory = os.path.join(base_path, "temp")

View File

@ -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