Compare commits

..

13 Commits

Author SHA1 Message Date
a7a6335be5 ComfyUI v0.16.4 2026-03-07 16:52:39 -05:00
bcf1a1fab1 mm: reset_cast_buffers: sync compute stream before free (#12822)
Sync the compute stream before freeing the cast buffers. This can cause
use after free issues when the cast stream frees the buffer while the
compute stream is behind enough to still needs a casted weight.
2026-03-07 09:38:08 -08:00
6ac8152fc8 chore: update workflow templates to v0.9.11 (#12821) 2026-03-06 23:54:09 -08:00
afc00f0055 Fix requirements version. (#12817) 2026-03-06 20:10:53 -05:00
d69d30819b Don't run TE on cpu when dynamic vram enabled. (#12815) 2026-03-06 19:11:16 -05:00
f466b06601 Fix fp16 audio encoder models (#12811)
* mp: respect model_defined_dtypes in default caster

This is needed for parametrizations when the dtype changes between sd
and model.

* audio_encoders: archive model dtypes

Archive model dtypes to stop the state dict load override the dtypes
defined by the core for compute etc.
2026-03-06 18:20:07 -05:00
34e55f0061 feat(api-nodes): add Gemini 3.1 Flash Lite model to LLM node (#12803) 2026-03-06 09:54:27 -08:00
3b93d5d571 feat(api-nodes): add TencentSmartTopology node (#12741)
* feat(api-nodes): add TencentSmartTopology node

* feat(api-nodes): enable TencentModelTo3DUV node

* chore(Tencent endpoints): add "wait" to queued statuses
2026-03-06 01:04:48 -08:00
e544c65db9 feat: add Math Expression node with simpleeval evaluation (#12687)
* feat: add EagerEval dataclass for frontend-side node evaluation

Add EagerEval to the V3 API schema, enabling nodes to declare
frontend-evaluated JSONata expressions. The frontend uses this to
display computation results as badges without a backend round-trip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add Math Expression node with JSONata evaluation

Add ComfyMathExpression node that evaluates JSONata expressions against
dynamically-grown numeric inputs using Autogrow + MatchType. Sends
input context via ui output so the frontend can re-evaluate when
the expression changes without a backend round-trip.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: register nodes_math.py in extras_files loader list

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address CodeRabbit review feedback

- Harden EagerEval.validate with type checks and strip() for empty strings
- Add _positional_alias for spreadsheet-style names beyond z (aa, ab...)
- Validate JSONata result is numeric before returning
- Add jsonata to requirements.txt

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: remove EagerEval, scope PR to math node only

Remove EagerEval dataclass from _io.py and eager_eval usage from
nodes_math.py. Eager execution will be designed as a general-purpose
system in a separate effort.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: use TemplateNames, cap inputs at 26, improve error message

Address Kosinkadink review feedback:
- Switch from Autogrow.TemplatePrefix to Autogrow.TemplateNames so input
  slots are named a-z, matching expression variables directly
- Cap max inputs at 26 (a-z) instead of 100
- Simplify execute() by removing dual-mapping hack
- Include expression and result value in error message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add unit tests for Math Expression node

Add tests for _positional_alias (a-z mapping) and execute() covering
arithmetic operations, float inputs, $sum(values), and error cases.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: replace jsonata with simpleeval for math evaluation

jsonata PyPI package has critical issues: no Python 3.12/3.13 wheels,
no ARM/Apple Silicon wheels, abandoned (last commit 2023), C extension.

Replace with simpleeval (pure Python, 3.4M downloads/month, MIT,
AST-based security). Add math module functions (sqrt, ceil, floor,
log, sin, cos, tan) and variadic sum() supporting both sum(values)
and sum(a, b, c). Pin version to >=1.0,<2.0.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: update tests for simpleeval migration

Update JSONata syntax to Python syntax ($sum -> sum, $string -> str),
add tests for math functions (sqrt, ceil, floor, sin, log10) and
variadic sum(a, b, c).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: replace MatchType with MultiType inputs and dual FLOAT/INT outputs

Allow mixing INT and FLOAT connections on the same node by switching
from MatchType (which forces all inputs to the same type) to MultiType.
Output both FLOAT and INT so users can pick the type they need.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: update tests for mixed INT/FLOAT inputs and dual outputs

Add assertions for both FLOAT (result[0]) and INT (result[1]) outputs.
Add test_mixed_int_float_inputs and test_mixed_resolution_scale to
verify the primary use case of multiplying resolutions by a float factor.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: make expression input multiline and validate empty expression

- Add multiline=True to expression input for better UX with longer expressions
- Add empty expression validation with clear "Expression cannot be empty." message

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* test: add tests for empty expression validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address review feedback — safe pow, isfinite guard, test coverage

- Wrap pow() with _safe_pow to prevent DoS via huge exponents
  (pow() bypasses simpleeval's safe_power guard on **)
- Add math.isfinite() check to catch inf/nan before int() conversion
- Add int/float converters to MATH_FUNCTIONS for explicit casting
- Add "calculator" search alias
- Replace _positional_alias helper with string.ascii_lowercase
- Narrow test assertions and add error path + function coverage tests

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* Update requirements.txt

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Jedrzej Kosinski <kosinkadink1@gmail.com>
Co-authored-by: Christian Byrne <abolkonsky.rem@gmail.com>
2026-03-05 18:51:28 -08:00
1c21828236 ComfyUI v0.16.3 2026-03-05 17:25:49 -05:00
58017e8726 feat: add causal_fix parameter to add_keyframe_index and append_keyframe (#12797)
Allows explicit control over the causal_fix flag passed to
latent_to_pixel_coords. Defaults to frame_idx == 0 when not
specified, fixing the previous heuristic.
2026-03-05 16:51:20 -05:00
17b43c2b87 LTX audio vae novram fixes. (#12796) 2026-03-05 16:31:28 -05:00
8befce5c7b Add manual cast to LTX2 vocoder conv_transpose1d (#12795)
* Add manual cast to LTX2 vocoder

* Update vocoder.py
2026-03-05 12:37:25 -08:00
15 changed files with 482 additions and 51 deletions

View File

@ -27,6 +27,7 @@ class AudioEncoderModel():
self.model.eval()
self.patcher = comfy.model_patcher.CoreModelPatcher(self.model, load_device=self.load_device, offload_device=offload_device)
self.model_sample_rate = 16000
comfy.model_management.archive_model_dtypes(self.model)
def load_sd(self, sd):
return self.model.load_state_dict(sd, strict=False, assign=self.patcher.is_dynamic())

View File

@ -2,6 +2,7 @@ import torch
import torch.nn.functional as F
import torch.nn as nn
import comfy.ops
import comfy.model_management
import numpy as np
import math
@ -81,7 +82,7 @@ class LowPassFilter1d(nn.Module):
_, C, _ = x.shape
if self.padding:
x = F.pad(x, (self.pad_left, self.pad_right), mode=self.padding_mode)
return F.conv1d(x, self.filter.expand(C, -1, -1), stride=self.stride, groups=C)
return F.conv1d(x, comfy.model_management.cast_to(self.filter.expand(C, -1, -1), dtype=x.dtype, device=x.device), stride=self.stride, groups=C)
class UpSample1d(nn.Module):
@ -125,7 +126,7 @@ class UpSample1d(nn.Module):
_, C, _ = x.shape
x = F.pad(x, (self.pad, self.pad), mode="replicate")
x = self.ratio * F.conv_transpose1d(
x, self.filter.expand(C, -1, -1), stride=self.stride, groups=C
x, comfy.model_management.cast_to(self.filter.expand(C, -1, -1), dtype=x.dtype, device=x.device), stride=self.stride, groups=C
)
x = x[..., self.pad_left : -self.pad_right]
return x
@ -190,7 +191,7 @@ class Snake(nn.Module):
self.eps = 1e-9
def forward(self, x):
a = self.alpha.unsqueeze(0).unsqueeze(-1)
a = comfy.model_management.cast_to(self.alpha.unsqueeze(0).unsqueeze(-1), dtype=x.dtype, device=x.device)
if self.alpha_logscale:
a = torch.exp(a)
return x + (1.0 / (a + self.eps)) * torch.sin(x * a).pow(2)
@ -217,8 +218,8 @@ class SnakeBeta(nn.Module):
self.eps = 1e-9
def forward(self, x):
a = self.alpha.unsqueeze(0).unsqueeze(-1)
b = self.beta.unsqueeze(0).unsqueeze(-1)
a = comfy.model_management.cast_to(self.alpha.unsqueeze(0).unsqueeze(-1), dtype=x.dtype, device=x.device)
b = comfy.model_management.cast_to(self.beta.unsqueeze(0).unsqueeze(-1), dtype=x.dtype, device=x.device)
if self.alpha_logscale:
a = torch.exp(a)
b = torch.exp(b)
@ -596,7 +597,7 @@ class _STFTFn(nn.Module):
y = y.unsqueeze(1) # (B, 1, T)
left_pad = max(0, self.win_length - self.hop_length) # causal: left-only
y = F.pad(y, (left_pad, 0))
spec = F.conv1d(y, self.forward_basis, stride=self.hop_length, padding=0)
spec = F.conv1d(y, comfy.model_management.cast_to(self.forward_basis, dtype=y.dtype, device=y.device), stride=self.hop_length, padding=0)
n_freqs = spec.shape[1] // 2
real, imag = spec[:, :n_freqs], spec[:, n_freqs:]
magnitude = torch.sqrt(real ** 2 + imag ** 2)
@ -647,7 +648,7 @@ class MelSTFT(nn.Module):
"""
magnitude, phase = self.stft_fn(y)
energy = torch.norm(magnitude, dim=1)
mel = torch.matmul(self.mel_basis.to(magnitude.dtype), magnitude)
mel = torch.matmul(comfy.model_management.cast_to(self.mel_basis, dtype=magnitude.dtype, device=y.device), magnitude)
log_mel = torch.log(torch.clamp(mel, min=1e-5))
return log_mel, magnitude, phase, energy

View File

@ -939,7 +939,7 @@ def text_encoder_offload_device():
def text_encoder_device():
if args.gpu_only:
return get_torch_device()
elif vram_state == VRAMState.HIGH_VRAM or vram_state == VRAMState.NORMAL_VRAM:
elif vram_state in (VRAMState.HIGH_VRAM, VRAMState.NORMAL_VRAM) or comfy.memory_management.aimdo_enabled:
if should_use_fp16(prioritize_performance=False):
return get_torch_device()
else:
@ -1148,6 +1148,7 @@ def reset_cast_buffers():
LARGEST_CASTED_WEIGHT = (None, 0)
for offload_stream in STREAM_CAST_BUFFERS:
offload_stream.synchronize()
synchronize()
STREAM_CAST_BUFFERS.clear()
soft_empty_cache()

View File

@ -715,8 +715,8 @@ class ModelPatcher:
default = True # default random weights in non leaf modules
break
if default and default_device is not None:
for param in params.values():
param.data = param.data.to(device=default_device)
for param_name, param in params.items():
param.data = param.data.to(device=default_device, dtype=getattr(m, param_name + "_comfy_model_dtype", None))
if not default and (hasattr(m, "comfy_cast_weights") or len(params) > 0):
module_mem = comfy.model_management.module_size(m)
module_offload_mem = module_mem

View File

@ -66,13 +66,17 @@ class To3DProTaskQueryRequest(BaseModel):
JobId: str = Field(...)
class To3DUVFileInput(BaseModel):
class TaskFile3DInput(BaseModel):
Type: str = Field(..., description="File type: GLB, OBJ, or FBX")
Url: str = Field(...)
class To3DUVTaskRequest(BaseModel):
File: To3DUVFileInput = Field(...)
File: TaskFile3DInput = Field(...)
class To3DPartTaskRequest(BaseModel):
File: TaskFile3DInput = Field(...)
class TextureEditImageInfo(BaseModel):
@ -80,7 +84,13 @@ class TextureEditImageInfo(BaseModel):
class TextureEditTaskRequest(BaseModel):
File3D: To3DUVFileInput = Field(...)
File3D: TaskFile3DInput = Field(...)
Image: TextureEditImageInfo | None = Field(None)
Prompt: str | None = Field(None)
EnablePBR: bool | None = Field(None)
class SmartTopologyRequest(BaseModel):
File3D: TaskFile3DInput = Field(...)
PolygonType: str | None = Field(...)
FaceLevel: str | None = Field(...)

View File

@ -72,18 +72,6 @@ GEMINI_IMAGE_2_PRICE_BADGE = IO.PriceBadge(
)
class GeminiModel(str, Enum):
"""
Gemini Model Names allowed by comfy-api
"""
gemini_2_5_pro_preview_05_06 = "gemini-2.5-pro-preview-05-06"
gemini_2_5_flash_preview_04_17 = "gemini-2.5-flash-preview-04-17"
gemini_2_5_pro = "gemini-2.5-pro"
gemini_2_5_flash = "gemini-2.5-flash"
gemini_3_0_pro = "gemini-3-pro-preview"
class GeminiImageModel(str, Enum):
"""
Gemini Image Model Names allowed by comfy-api
@ -237,10 +225,14 @@ def calculate_tokens_price(response: GeminiGenerateContentResponse) -> float | N
input_tokens_price = 0.30
output_text_tokens_price = 2.50
output_image_tokens_price = 30.0
elif response.modelVersion == "gemini-3-pro-preview":
elif response.modelVersion in ("gemini-3-pro-preview", "gemini-3.1-pro-preview"):
input_tokens_price = 2
output_text_tokens_price = 12.0
output_image_tokens_price = 0.0
elif response.modelVersion == "gemini-3.1-flash-lite-preview":
input_tokens_price = 0.25
output_text_tokens_price = 1.50
output_image_tokens_price = 0.0
elif response.modelVersion == "gemini-3-pro-image-preview":
input_tokens_price = 2
output_text_tokens_price = 12.0
@ -292,8 +284,16 @@ class GeminiNode(IO.ComfyNode):
),
IO.Combo.Input(
"model",
options=GeminiModel,
default=GeminiModel.gemini_2_5_pro,
options=[
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-flash-preview-04-17",
"gemini-2.5-pro",
"gemini-2.5-flash",
"gemini-3-pro-preview",
"gemini-3-1-pro",
"gemini-3-1-flash-lite",
],
default="gemini-3-1-pro",
tooltip="The Gemini model to use for generating responses.",
),
IO.Int.Input(
@ -363,11 +363,16 @@ class GeminiNode(IO.ComfyNode):
"usd": [0.00125, 0.01],
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
}
: $contains($m, "gemini-3-pro-preview") ? {
: ($contains($m, "gemini-3-pro-preview") or $contains($m, "gemini-3-1-pro")) ? {
"type": "list_usd",
"usd": [0.002, 0.012],
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
}
: $contains($m, "gemini-3-1-flash-lite") ? {
"type": "list_usd",
"usd": [0.00025, 0.0015],
"format": { "approximate": true, "separator": "-", "suffix": " per 1K tokens" }
}
: {"type":"text", "text":"Token-based"}
)
""",
@ -436,12 +441,14 @@ class GeminiNode(IO.ComfyNode):
files: list[GeminiPart] | None = None,
system_prompt: str = "",
) -> IO.NodeOutput:
validate_string(prompt, strip_whitespace=False)
if model == "gemini-3-pro-preview":
model = "gemini-3.1-pro-preview" # model "gemini-3-pro-preview" will be soon deprecated by Google
elif model == "gemini-3-1-pro":
model = "gemini-3.1-pro-preview"
elif model == "gemini-3-1-flash-lite":
model = "gemini-3.1-flash-lite-preview"
# Create parts list with text prompt as the first part
parts: list[GeminiPart] = [GeminiPart(text=prompt)]
# Add other modal parts
if images is not None:
parts.extend(await create_image_parts(cls, images))
if audio is not None:

View File

@ -5,18 +5,19 @@ from comfy_api_nodes.apis.hunyuan3d import (
Hunyuan3DViewImage,
InputGenerateType,
ResultFile3D,
SmartTopologyRequest,
TaskFile3DInput,
TextureEditTaskRequest,
To3DPartTaskRequest,
To3DProTaskCreateResponse,
To3DProTaskQueryRequest,
To3DProTaskRequest,
To3DProTaskResultResponse,
To3DUVFileInput,
To3DUVTaskRequest,
)
from comfy_api_nodes.util import (
ApiEndpoint,
download_url_to_file_3d,
download_url_to_image_tensor,
downscale_image_tensor_by_max_side,
poll_op,
sync_op,
@ -344,7 +345,6 @@ class TencentModelTo3DUVNode(IO.ComfyNode):
outputs=[
IO.File3DOBJ.Output(display_name="OBJ"),
IO.File3DFBX.Output(display_name="FBX"),
IO.Image.Output(),
],
hidden=[
IO.Hidden.auth_token_comfy_org,
@ -375,7 +375,7 @@ class TencentModelTo3DUVNode(IO.ComfyNode):
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-uv", method="POST"),
response_model=To3DProTaskCreateResponse,
data=To3DUVTaskRequest(
File=To3DUVFileInput(
File=TaskFile3DInput(
Type=file_format.upper(),
Url=await upload_3d_model_to_comfyapi(cls, model_3d, file_format),
)
@ -394,7 +394,6 @@ class TencentModelTo3DUVNode(IO.ComfyNode):
return IO.NodeOutput(
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "obj").Url, "obj"),
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "fbx").Url, "fbx"),
await download_url_to_image_tensor(get_file_from_response(result.ResultFile3Ds, "image").Url),
)
@ -463,7 +462,7 @@ class Tencent3DTextureEditNode(IO.ComfyNode):
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-texture-edit", method="POST"),
response_model=To3DProTaskCreateResponse,
data=TextureEditTaskRequest(
File3D=To3DUVFileInput(Type=file_format.upper(), Url=model_url),
File3D=TaskFile3DInput(Type=file_format.upper(), Url=model_url),
Prompt=prompt,
EnablePBR=True,
),
@ -538,8 +537,8 @@ class Tencent3DPartNode(IO.ComfyNode):
cls,
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-part", method="POST"),
response_model=To3DProTaskCreateResponse,
data=To3DUVTaskRequest(
File=To3DUVFileInput(Type=file_format.upper(), Url=model_url),
data=To3DPartTaskRequest(
File=TaskFile3DInput(Type=file_format.upper(), Url=model_url),
),
is_rate_limited=_is_tencent_rate_limited,
)
@ -557,15 +556,107 @@ class Tencent3DPartNode(IO.ComfyNode):
)
class TencentSmartTopologyNode(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="TencentSmartTopologyNode",
display_name="Hunyuan3D: Smart Topology",
category="api node/3d/Tencent",
description="Perform smart retopology on a 3D model. "
"Supports GLB/OBJ formats; max 200MB; recommended for high-poly models.",
inputs=[
IO.MultiType.Input(
"model_3d",
types=[IO.File3DGLB, IO.File3DOBJ, IO.File3DAny],
tooltip="Input 3D model (GLB or OBJ)",
),
IO.Combo.Input(
"polygon_type",
options=["triangle", "quadrilateral"],
tooltip="Surface composition type.",
),
IO.Combo.Input(
"face_level",
options=["medium", "high", "low"],
tooltip="Polygon reduction level.",
),
IO.Int.Input(
"seed",
default=0,
min=0,
max=2147483647,
display_mode=IO.NumberDisplay.number,
control_after_generate=True,
tooltip="Seed controls whether the node should re-run; "
"results are non-deterministic regardless of seed.",
),
],
outputs=[
IO.File3DOBJ.Output(display_name="OBJ"),
],
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(expr='{"type":"usd","usd":1.0}'),
)
SUPPORTED_FORMATS = {"glb", "obj"}
@classmethod
async def execute(
cls,
model_3d: Types.File3D,
polygon_type: str,
face_level: str,
seed: int,
) -> IO.NodeOutput:
_ = seed
file_format = model_3d.format.lower()
if file_format not in cls.SUPPORTED_FORMATS:
raise ValueError(
f"Unsupported file format: '{file_format}'. " f"Supported: {', '.join(sorted(cls.SUPPORTED_FORMATS))}."
)
model_url = await upload_3d_model_to_comfyapi(cls, model_3d, file_format)
response = await sync_op(
cls,
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-smart-topology", method="POST"),
response_model=To3DProTaskCreateResponse,
data=SmartTopologyRequest(
File3D=TaskFile3DInput(Type=file_format.upper(), Url=model_url),
PolygonType=polygon_type,
FaceLevel=face_level,
),
is_rate_limited=_is_tencent_rate_limited,
)
if response.Error:
raise ValueError(f"Task creation failed: [{response.Error.Code}] {response.Error.Message}")
result = await poll_op(
cls,
ApiEndpoint(path="/proxy/tencent/hunyuan/3d-smart-topology/query", method="POST"),
data=To3DProTaskQueryRequest(JobId=response.JobId),
response_model=To3DProTaskResultResponse,
status_extractor=lambda r: r.Status,
)
return IO.NodeOutput(
await download_url_to_file_3d(get_file_from_response(result.ResultFile3Ds, "obj").Url, "obj"),
)
class TencentHunyuan3DExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
TencentTextToModelNode,
TencentImageToModelNode,
# TencentModelTo3DUVNode,
TencentModelTo3DUVNode,
# Tencent3DTextureEditNode,
Tencent3DPartNode,
TencentSmartTopologyNode,
]

View File

@ -83,7 +83,7 @@ class _PollUIState:
_RETRY_STATUS = {408, 500, 502, 503, 504} # status 429 is handled separately
COMPLETED_STATUSES = ["succeeded", "succeed", "success", "completed", "finished", "done", "complete"]
FAILED_STATUSES = ["cancelled", "canceled", "canceling", "fail", "failed", "error"]
QUEUED_STATUSES = ["created", "queued", "queueing", "submitted", "initializing"]
QUEUED_STATUSES = ["created", "queued", "queueing", "submitted", "initializing", "wait"]
async def sync_op(

View File

@ -253,10 +253,12 @@ class LTXVAddGuide(io.ComfyNode):
return frame_idx, latent_idx
@classmethod
def add_keyframe_index(cls, cond, frame_idx, guiding_latent, scale_factors, latent_downscale_factor=1):
def add_keyframe_index(cls, cond, frame_idx, guiding_latent, scale_factors, latent_downscale_factor=1, causal_fix=None):
keyframe_idxs, _ = get_keyframe_idxs(cond)
_, latent_coords = cls.PATCHIFIER.patchify(guiding_latent)
pixel_coords = latent_to_pixel_coords(latent_coords, scale_factors, causal_fix=frame_idx == 0) # we need the causal fix only if we're placing the new latents at index 0
if causal_fix is None:
causal_fix = frame_idx == 0 or guiding_latent.shape[2] == 1
pixel_coords = latent_to_pixel_coords(latent_coords, scale_factors, causal_fix=causal_fix)
pixel_coords[:, 0] += frame_idx
# The following adjusts keyframe end positions for small grid IC-LoRA.
@ -278,12 +280,12 @@ class LTXVAddGuide(io.ComfyNode):
return node_helpers.conditioning_set_values(cond, {"keyframe_idxs": keyframe_idxs})
@classmethod
def append_keyframe(cls, positive, negative, frame_idx, latent_image, noise_mask, guiding_latent, strength, scale_factors, guide_mask=None, in_channels=128, latent_downscale_factor=1):
def append_keyframe(cls, positive, negative, frame_idx, latent_image, noise_mask, guiding_latent, strength, scale_factors, guide_mask=None, in_channels=128, latent_downscale_factor=1, causal_fix=None):
if latent_image.shape[1] != in_channels or guiding_latent.shape[1] != in_channels:
raise ValueError("Adding guide to a combined AV latent is not supported.")
positive = cls.add_keyframe_index(positive, frame_idx, guiding_latent, scale_factors, latent_downscale_factor)
negative = cls.add_keyframe_index(negative, frame_idx, guiding_latent, scale_factors, latent_downscale_factor)
positive = cls.add_keyframe_index(positive, frame_idx, guiding_latent, scale_factors, latent_downscale_factor, causal_fix=causal_fix)
negative = cls.add_keyframe_index(negative, frame_idx, guiding_latent, scale_factors, latent_downscale_factor, causal_fix=causal_fix)
if guide_mask is not None:
target_h = max(noise_mask.shape[3], guide_mask.shape[3])

119
comfy_extras/nodes_math.py Normal file
View File

@ -0,0 +1,119 @@
"""Math expression node using simpleeval for safe evaluation.
Provides a ComfyMathExpression node that evaluates math expressions
against dynamically-grown numeric inputs.
"""
from __future__ import annotations
import math
import string
from simpleeval import simple_eval
from typing_extensions import override
from comfy_api.latest import ComfyExtension, io
MAX_EXPONENT = 4000
def _variadic_sum(*args):
"""Support both sum(values) and sum(a, b, c)."""
if len(args) == 1 and hasattr(args[0], "__iter__"):
return sum(args[0])
return sum(args)
def _safe_pow(base, exp):
"""Wrap pow() with an exponent cap to prevent DoS via huge exponents.
The ** operator is already guarded by simpleeval's safe_power, but
pow() as a callable bypasses that guard.
"""
if abs(exp) > MAX_EXPONENT:
raise ValueError(f"Exponent {exp} exceeds maximum allowed ({MAX_EXPONENT})")
return pow(base, exp)
MATH_FUNCTIONS = {
"sum": _variadic_sum,
"min": min,
"max": max,
"abs": abs,
"round": round,
"pow": _safe_pow,
"sqrt": math.sqrt,
"ceil": math.ceil,
"floor": math.floor,
"log": math.log,
"log2": math.log2,
"log10": math.log10,
"sin": math.sin,
"cos": math.cos,
"tan": math.tan,
"int": int,
"float": float,
}
class MathExpressionNode(io.ComfyNode):
"""Evaluates a math expression against dynamically-grown inputs."""
@classmethod
def define_schema(cls) -> io.Schema:
autogrow = io.Autogrow.TemplateNames(
input=io.MultiType.Input("value", [io.Float, io.Int]),
names=list(string.ascii_lowercase),
min=1,
)
return io.Schema(
node_id="ComfyMathExpression",
display_name="Math Expression",
category="math",
search_aliases=[
"expression", "formula", "calculate", "calculator",
"eval", "math",
],
inputs=[
io.String.Input("expression", default="a + b", multiline=True),
io.Autogrow.Input("values", template=autogrow),
],
outputs=[
io.Float.Output(display_name="FLOAT"),
io.Int.Output(display_name="INT"),
],
)
@classmethod
def execute(
cls, expression: str, values: io.Autogrow.Type
) -> io.NodeOutput:
if not expression.strip():
raise ValueError("Expression cannot be empty.")
context: dict = dict(values)
context["values"] = list(values.values())
result = simple_eval(expression, names=context, functions=MATH_FUNCTIONS)
# bool check must come first because bool is a subclass of int in Python
if isinstance(result, bool) or not isinstance(result, (int, float)):
raise ValueError(
f"Math Expression '{expression}' must evaluate to a numeric result, "
f"got {type(result).__name__}: {result!r}"
)
if not math.isfinite(result):
raise ValueError(
f"Math Expression '{expression}' produced a non-finite result: {result}"
)
return io.NodeOutput(float(result), int(result))
class MathExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[io.ComfyNode]]:
return [MathExpressionNode]
async def comfy_entrypoint() -> MathExtension:
return MathExtension()

View File

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

View File

@ -2449,6 +2449,7 @@ async def init_builtin_extra_nodes():
"nodes_replacements.py",
"nodes_nag.py",
"nodes_sdpose.py",
"nodes_math.py",
]
import_failed = []

View File

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

View File

@ -1,5 +1,5 @@
comfyui-frontend-package==1.39.19
comfyui-workflow-templates==0.9.10
comfyui-workflow-templates==0.9.11
comfyui-embedded-docs==0.4.3
torch
torchsde
@ -24,6 +24,7 @@ av>=14.2.0
comfy-kitchen>=0.2.7
comfy-aimdo>=0.2.7
requests
simpleeval>=1.0.0
#non essential dependencies:
kornia>=0.7.1

View File

@ -0,0 +1,197 @@
import math
import pytest
from collections import OrderedDict
from unittest.mock import patch, MagicMock
mock_nodes = MagicMock()
mock_nodes.MAX_RESOLUTION = 16384
mock_server = MagicMock()
with patch.dict("sys.modules", {"nodes": mock_nodes, "server": mock_server}):
from comfy_extras.nodes_math import MathExpressionNode
class TestMathExpressionExecute:
@staticmethod
def _exec(expression: str, **kwargs) -> object:
values = OrderedDict(kwargs)
return MathExpressionNode.execute(expression, values)
def test_addition(self):
result = self._exec("a + b", a=3, b=4)
assert result[0] == 7.0
assert result[1] == 7
def test_subtraction(self):
result = self._exec("a - b", a=10, b=3)
assert result[0] == 7.0
assert result[1] == 7
def test_multiplication(self):
result = self._exec("a * b", a=3, b=5)
assert result[0] == 15.0
assert result[1] == 15
def test_division(self):
result = self._exec("a / b", a=10, b=4)
assert result[0] == 2.5
assert result[1] == 2
def test_single_input(self):
result = self._exec("a * 2", a=5)
assert result[0] == 10.0
assert result[1] == 10
def test_three_inputs(self):
result = self._exec("a + b + c", a=1, b=2, c=3)
assert result[0] == 6.0
assert result[1] == 6
def test_float_inputs(self):
result = self._exec("a + b", a=1.5, b=2.5)
assert result[0] == 4.0
assert result[1] == 4
def test_mixed_int_float_inputs(self):
result = self._exec("a * b", a=1024, b=1.5)
assert result[0] == 1536.0
assert result[1] == 1536
def test_mixed_resolution_scale(self):
result = self._exec("a * b", a=512, b=0.75)
assert result[0] == 384.0
assert result[1] == 384
def test_sum_values_array(self):
result = self._exec("sum(values)", a=1, b=2, c=3)
assert result[0] == 6.0
def test_sum_variadic(self):
result = self._exec("sum(a, b, c)", a=1, b=2, c=3)
assert result[0] == 6.0
def test_min_values(self):
result = self._exec("min(values)", a=5, b=2, c=8)
assert result[0] == 2.0
def test_max_values(self):
result = self._exec("max(values)", a=5, b=2, c=8)
assert result[0] == 8.0
def test_abs_function(self):
result = self._exec("abs(a)", a=-7)
assert result[0] == 7.0
assert result[1] == 7
def test_sqrt(self):
result = self._exec("sqrt(a)", a=16)
assert result[0] == 4.0
assert result[1] == 4
def test_ceil(self):
result = self._exec("ceil(a)", a=2.3)
assert result[0] == 3.0
assert result[1] == 3
def test_floor(self):
result = self._exec("floor(a)", a=2.7)
assert result[0] == 2.0
assert result[1] == 2
def test_sin(self):
result = self._exec("sin(a)", a=0)
assert result[0] == 0.0
def test_log10(self):
result = self._exec("log10(a)", a=100)
assert result[0] == 2.0
assert result[1] == 2
def test_float_output_type(self):
result = self._exec("a + b", a=1, b=2)
assert isinstance(result[0], float)
def test_int_output_type(self):
result = self._exec("a + b", a=1, b=2)
assert isinstance(result[1], int)
def test_non_numeric_result_raises(self):
with pytest.raises(ValueError, match="must evaluate to a numeric result"):
self._exec("'hello'", a=42)
def test_undefined_function_raises(self):
with pytest.raises(Exception, match="not defined"):
self._exec("str(a)", a=42)
def test_boolean_result_raises(self):
with pytest.raises(ValueError, match="got bool"):
self._exec("a > b", a=5, b=3)
def test_empty_expression_raises(self):
with pytest.raises(ValueError, match="Expression cannot be empty"):
self._exec("", a=1)
def test_whitespace_only_expression_raises(self):
with pytest.raises(ValueError, match="Expression cannot be empty"):
self._exec(" ", a=1)
# --- Missing function coverage (round, pow, log, log2, cos, tan) ---
def test_round(self):
result = self._exec("round(a)", a=2.7)
assert result[0] == 3.0
assert result[1] == 3
def test_round_with_ndigits(self):
result = self._exec("round(a, 2)", a=3.14159)
assert result[0] == pytest.approx(3.14)
def test_pow(self):
result = self._exec("pow(a, b)", a=2, b=10)
assert result[0] == 1024.0
assert result[1] == 1024
def test_log(self):
result = self._exec("log(a)", a=math.e)
assert result[0] == pytest.approx(1.0)
def test_log2(self):
result = self._exec("log2(a)", a=8)
assert result[0] == pytest.approx(3.0)
def test_cos(self):
result = self._exec("cos(a)", a=0)
assert result[0] == 1.0
def test_tan(self):
result = self._exec("tan(a)", a=0)
assert result[0] == 0.0
# --- int/float converter functions ---
def test_int_converter(self):
result = self._exec("int(a / b)", a=7, b=2)
assert result[1] == 3
def test_float_converter(self):
result = self._exec("float(a)", a=5)
assert result[0] == 5.0
# --- Error path tests ---
def test_division_by_zero_raises(self):
with pytest.raises(ZeroDivisionError):
self._exec("a / b", a=1, b=0)
def test_sqrt_negative_raises(self):
with pytest.raises(ValueError, match="math domain error"):
self._exec("sqrt(a)", a=-1)
def test_overflow_inf_raises(self):
with pytest.raises(ValueError, match="non-finite result"):
self._exec("a * b", a=1e308, b=10)
def test_pow_huge_exponent_raises(self):
with pytest.raises(ValueError, match="Exponent .* exceeds maximum"):
self._exec("pow(a, b)", a=10, b=10000000)