Expand secure V2 SDK surface

This commit is contained in:
benjcooley
2026-08-31 12:50:58 -07:00
parent ed6abc963d
commit 4c665f3263
48 changed files with 52009 additions and 358 deletions
+32 -1
View File
@@ -11,6 +11,35 @@ import comfy.utils
MODULE_PATTERN = re.compile(r"lllite_dit_blocks_(\d+)_(self_attn_[qkv]_proj|cross_attn_q_proj|mlp_layer1)$")
def _spatial_tile_image(image, tile):
if tile is None:
return image
fields = (
"top", "bottom", "left", "right", "source_height", "source_width",
)
if not isinstance(tile, dict) or any(
isinstance(tile.get(field), bool) or not isinstance(tile.get(field), int)
for field in fields
):
raise ValueError("Anima LLLite spatial tile descriptor is invalid")
top, bottom = tile["top"], tile["bottom"]
left, right = tile["left"], tile["right"]
source_height, source_width = tile["source_height"], tile["source_width"]
if (
source_height < 1
or source_width < 1
or not 0 <= top < bottom <= source_height
or not 0 <= left < right <= source_width
):
raise ValueError("Anima LLLite spatial tile bounds are invalid")
height, width = image.shape[1:3]
y1 = top * height // source_height
y2 = (bottom * height + source_height - 1) // source_height
x1 = left * width // source_width
x2 = (right * width + source_width - 1) // source_width
return image[:, y1:y2, x1:x2, :]
def _group_norm(channels, device=None, dtype=None, operations=None):
groups = 8
while groups > 1 and channels % groups != 0:
@@ -208,8 +237,10 @@ class AnimaLLLitePatch:
target_height = x.shape[-2] * 8
target_width = x.shape[-1] * 8
image = _spatial_tile_image(
self.image, transformer_options.get("spatial_tile"))
image = comfy.utils.common_upscale(
self.image.movedim(-1, 1), target_width, target_height, "bicubic", crop="center"
image.movedim(-1, 1), target_width, target_height, "bicubic", crop="center"
).clamp(0.0, 1.0)
image = image.to(device=x.device, dtype=x.dtype) * 2.0 - 1.0
+180
View File
@@ -0,0 +1,180 @@
"""Big-LaMa image inpainting generator.
The model architecture is the fixed ``big-lama`` FFC generator from
https://github.com/advimman/lama (Apache-2.0). Training-only options and
unused architecture variants are intentionally omitted.
"""
import torch
from torch import nn
class FourierUnit(nn.Module):
def __init__(self, channels, operations):
super().__init__()
self.conv_layer = operations.Conv2d(
channels * 2, channels * 2, kernel_size=1, bias=False)
self.bn = operations.BatchNorm2d(channels * 2)
self.relu = nn.ReLU(inplace=True)
def forward(self, x):
batch = x.shape[0]
transformed = torch.fft.rfftn(x, dim=(-2, -1), norm="ortho")
height, frequency_width = transformed.shape[-2:]
transformed = torch.stack(
(transformed.real, transformed.imag), dim=-1)
transformed = transformed.permute(0, 1, 4, 2, 3).reshape(
batch, -1, height, frequency_width)
transformed = self.relu(self.bn(self.conv_layer(transformed)))
transformed = transformed.reshape(
batch, -1, 2, transformed.shape[-2], transformed.shape[-1]
).permute(0, 1, 3, 4, 2).contiguous()
transformed = torch.complex(
transformed[..., 0], transformed[..., 1])
return torch.fft.irfftn(
transformed, s=x.shape[-2:], dim=(-2, -1), norm="ortho")
class SpectralTransform(nn.Module):
def __init__(self, in_channels, out_channels, operations):
super().__init__()
self.conv1 = nn.Sequential(
operations.Conv2d(
in_channels, out_channels // 2, kernel_size=1, bias=False),
operations.BatchNorm2d(out_channels // 2),
nn.ReLU(inplace=True),
)
self.fu = FourierUnit(out_channels // 2, operations)
self.conv2 = operations.Conv2d(
out_channels // 2, out_channels, kernel_size=1, bias=False)
def forward(self, x):
x = self.conv1(x)
return self.conv2(x + self.fu(x))
class FFC(nn.Module):
def __init__(
self, in_channels, out_channels, ratio_gin, ratio_gout,
operations, kernel_size=3, stride=1, padding=1,
):
super().__init__()
in_global = int(in_channels * ratio_gin)
in_local = in_channels - in_global
out_global = int(out_channels * ratio_gout)
out_local = out_channels - out_global
def conv(enabled, source, target):
if not enabled:
return nn.Identity()
return operations.Conv2d(
source, target, kernel_size=kernel_size,
stride=stride, padding=padding, bias=False,
padding_mode="reflect")
self.ratio_gout = ratio_gout
self.global_in_num = in_global
self.convl2l = conv(in_local > 0 and out_local > 0,
in_local, out_local)
self.convl2g = conv(in_local > 0 and out_global > 0,
in_local, out_global)
self.convg2l = conv(in_global > 0 and out_local > 0,
in_global, out_local)
self.convg2g = (
SpectralTransform(in_global, out_global, operations)
if in_global > 0 and out_global > 0 else nn.Identity()
)
def forward(self, value):
local, global_ = value if isinstance(value, tuple) else (value, 0)
out_local = 0
out_global = 0
if self.ratio_gout != 1:
out_local = self.convl2l(local) + self.convg2l(global_)
if self.ratio_gout != 0:
out_global = self.convl2g(local) + self.convg2g(global_)
return out_local, out_global
class FFCBlock(nn.Module):
def __init__(
self, in_channels, out_channels, ratio_gin, ratio_gout,
operations, kernel_size=3, stride=1, padding=1,
):
super().__init__()
self.ffc = FFC(
in_channels, out_channels, ratio_gin, ratio_gout,
operations, kernel_size=kernel_size, stride=stride,
padding=padding)
global_channels = int(out_channels * ratio_gout)
self.bn_l = (
nn.Identity() if ratio_gout == 1
else operations.BatchNorm2d(out_channels - global_channels)
)
self.bn_g = (
nn.Identity() if ratio_gout == 0
else operations.BatchNorm2d(global_channels)
)
self.act_l = nn.Identity() if ratio_gout == 1 else nn.ReLU(inplace=True)
self.act_g = nn.Identity() if ratio_gout == 0 else nn.ReLU(inplace=True)
def forward(self, value):
local, global_ = self.ffc(value)
return self.act_l(self.bn_l(local)), self.act_g(self.bn_g(global_))
class FFCResnetBlock(nn.Module):
def __init__(self, channels, operations):
super().__init__()
self.conv1 = FFCBlock(
channels, channels, 0.75, 0.75, operations)
self.conv2 = FFCBlock(
channels, channels, 0.75, 0.75, operations)
def forward(self, value):
identity_local, identity_global = value
local, global_ = self.conv1(value)
local, global_ = self.conv2((local, global_))
return identity_local + local, identity_global + global_
class ConcatTupleLayer(nn.Module):
def forward(self, value):
return torch.cat(value, dim=1)
class BigLamaGenerator(nn.Module):
"""The single published 18-block Big-LaMa generator configuration."""
def __init__(self, operations):
super().__init__()
layers = [
nn.ReflectionPad2d(3),
FFCBlock(
4, 64, 0.0, 0.0, operations,
kernel_size=7, stride=1, padding=0),
FFCBlock(64, 128, 0.0, 0.0, operations, stride=2),
FFCBlock(128, 256, 0.0, 0.0, operations, stride=2),
FFCBlock(256, 512, 0.0, 0.75, operations, stride=2),
]
layers.extend(FFCResnetBlock(512, operations) for _ in range(18))
layers.append(ConcatTupleLayer())
for in_channels, out_channels in ((512, 256), (256, 128), (128, 64)):
layers.extend((
operations.ConvTranspose2d(
in_channels, out_channels, kernel_size=3, stride=2,
padding=1, output_padding=1),
operations.BatchNorm2d(out_channels),
nn.ReLU(inplace=True),
))
layers.extend((
nn.ReflectionPad2d(3),
operations.Conv2d(64, 3, kernel_size=7),
nn.Sigmoid(),
))
self.model = nn.Sequential(*layers)
def forward(self, image, mask):
masked = torch.cat((image * (1.0 - mask), mask), dim=1)
predicted = self.model(masked)
return mask * predicted + (1.0 - mask) * image
+25
View File
@@ -492,6 +492,31 @@ def calculate_weight(patches, weight, key, intermediate_dtype=torch.float32, ori
diff_weight = comfy.model_management.cast_to_device(target_weight, weight.device, intermediate_dtype) - \
comfy.model_management.cast_to_device(original_weights[key][0][0], weight.device, intermediate_dtype)
weight += function(strength * comfy.model_management.cast_to_device(diff_weight, weight.device, weight.dtype))
elif patch_type == "fooocus":
if (
not isinstance(v, (tuple, list))
or len(v) != 3
or not all(isinstance(item, torch.Tensor) for item in v)
):
logging.warning("invalid Fooocus patch for %s", key)
else:
quantized, minimum, maximum = v
if quantized.shape != weight.shape:
logging.warning(
"WARNING SHAPE MISMATCH %s WEIGHT NOT MERGED %s != %s",
key, quantized.shape, weight.shape)
elif strength != 0.0:
quantized = comfy.model_management.cast_to_device(
quantized, weight.device, torch.float32)
minimum = comfy.model_management.cast_to_device(
minimum, weight.device, torch.float32)
maximum = comfy.model_management.cast_to_device(
maximum, weight.device, torch.float32)
decoded = quantized.div(255.0).mul(
maximum - minimum).add(minimum)
weight += function(
strength * comfy.model_management.cast_to_device(
decoded, weight.device, weight.dtype))
else:
logging.warning("patch type not recognized {} {}".format(patch_type, key))
+5 -2
View File
@@ -466,7 +466,7 @@ class CLIP:
def get_key_patches(self):
return self.patcher.get_key_patches()
def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.95, min_p=0.0, repetition_penalty=1.0, seed=None, presence_penalty=0.0):
def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.95, min_p=0.0, repetition_penalty=1.0, seed=None, presence_penalty=0.0, num_beams=1):
self.cond_stage_model.reset_clip_options()
self.load_model(tokens)
@@ -475,7 +475,10 @@ class CLIP:
self.cond_stage_model.set_clip_options({"execution_device": device})
with model_management.cuda_device_context(device):
return self.cond_stage_model.generate(tokens, do_sample=do_sample, max_length=max_length, temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, seed=seed, presence_penalty=presence_penalty)
options = dict(do_sample=do_sample, max_length=max_length, temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, seed=seed, presence_penalty=presence_penalty)
if num_beams != 1:
options["num_beams"] = num_beams
return self.cond_stage_model.generate(tokens, **options)
def decode(self, token_ids, skip_special_tokens=True):
return self.tokenizer.decode(token_ids, skip_special_tokens=skip_special_tokens)
+5 -2
View File
@@ -746,5 +746,8 @@ class SD1ClipModel(torch.nn.Module):
def load_sd(self, sd):
return getattr(self, self.clip).load_sd(sd)
def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.95, min_p=0.0, repetition_penalty=1.0, seed=None, presence_penalty=0.0):
return getattr(self, self.clip).generate(tokens, do_sample=do_sample, max_length=max_length, temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, seed=seed, presence_penalty=presence_penalty)
def generate(self, tokens, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.95, min_p=0.0, repetition_penalty=1.0, seed=None, presence_penalty=0.0, num_beams=1):
options = dict(do_sample=do_sample, max_length=max_length, temperature=temperature, top_k=top_k, top_p=top_p, min_p=min_p, repetition_penalty=repetition_penalty, seed=seed, presence_penalty=presence_penalty)
if num_beams != 1:
options["num_beams"] = num_beams
return getattr(self, self.clip).generate(tokens, **options)
+185
View File
@@ -0,0 +1,185 @@
from __future__ import annotations
import copy
import math
import threading
import time
import psutil
import comfy.model_management as model_management
try:
import pynvml
except ImportError:
pynvml = None
SAMPLE_INTERVAL_SECONDS = 0.25
MAX_VOLUMES = 64
MAX_ACCELERATORS = 64
MAX_LABEL_LENGTH = 128
MAX_NAME_LENGTH = 256
MAX_BYTES = 2**63 - 1
_cache_lock = threading.Lock()
_cached_at = -math.inf
_cached_snapshot: dict | None = None
_nvml_ready: bool | None = None
def _text(value, limit: int, fallback: str) -> str:
text = " ".join(str(value).replace("\x00", "").split())[:limit]
return text or fallback
def _bytes(value) -> int:
return max(0, min(int(value), MAX_BYTES))
def _percent(value) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(number):
return None
return max(0.0, min(number, 100.0))
def _temperature(value) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
if not math.isfinite(number) or not -273.15 <= number <= 1000.0:
return None
return number
def _volume_label(mountpoint: str, index: int) -> str:
logical = str(mountpoint).replace("\\", "/").rstrip("/")
name = logical.rsplit("/", 1)[-1] if logical else "Root"
return _text(name, MAX_LABEL_LENGTH, f"Volume {index + 1}")
def _volumes(psutil_module=psutil) -> list[dict]:
partitions = sorted(
psutil_module.disk_partitions(all=False),
key=lambda item: str(item.mountpoint),
)[:MAX_VOLUMES]
result = []
for index, partition in enumerate(partitions):
try:
usage = psutil_module.disk_usage(partition.mountpoint)
except (OSError, PermissionError):
continue
total = _bytes(usage.total)
available = min(_bytes(usage.free), total)
result.append({
"id": f"volume-{index}",
"label": _volume_label(partition.mountpoint, index),
"total": total,
"available": available,
})
return result
def _nvml_available(nvml_module) -> bool:
global _nvml_ready
if nvml_module is None:
return False
if nvml_module is not pynvml:
return True
if _nvml_ready is None:
try:
nvml_module.nvmlInit()
_nvml_ready = True
except Exception:
_nvml_ready = False
return _nvml_ready
def _nvml_values(nvml_module, index: int) -> tuple:
if not _nvml_available(nvml_module):
return None, None, None, None, None
try:
handle = nvml_module.nvmlDeviceGetHandleByIndex(index)
memory = nvml_module.nvmlDeviceGetMemoryInfo(handle)
utilization = nvml_module.nvmlDeviceGetUtilizationRates(handle).gpu
temperature = nvml_module.nvmlDeviceGetTemperature(
handle, nvml_module.NVML_TEMPERATURE_GPU)
name = nvml_module.nvmlDeviceGetName(handle)
return memory.total, memory.free, utilization, temperature, name
except Exception:
return None, None, None, None, None
def _accelerators(model_management_module=model_management, nvml_module=pynvml) -> list[dict]:
primary = model_management_module.get_torch_device()
devices = list(model_management_module.get_all_torch_devices())
if primary in devices:
devices = [primary, *(device for device in devices if device != primary)]
else:
devices.insert(0, primary)
devices = [
device for device in devices
if getattr(device, "type", None) != "cpu"
][:MAX_ACCELERATORS]
result = []
for position, device in enumerate(devices):
total, _torch_total = model_management_module.get_total_memory(
device, torch_total_too=True)
available, _torch_available = model_management_module.get_free_memory(
device, torch_free_too=True)
utilization = None
temperature = None
nvml_name = None
if getattr(device, "type", None) == "cuda":
index = getattr(device, "index", None)
nvml_index = 0 if index is None else int(index)
nvml_total, nvml_free, utilization, temperature, nvml_name = (
_nvml_values(nvml_module, nvml_index))
if nvml_total is not None:
total, available = nvml_total, nvml_free
name = nvml_name or model_management_module.get_torch_device_name(device)
if isinstance(name, bytes):
name = name.decode("utf-8", errors="replace")
total = _bytes(total)
available = min(_bytes(available), total)
result.append({
"id": f"accelerator-{position}",
"name": _text(name, MAX_NAME_LENGTH, f"Accelerator {position + 1}"),
"memory_total": total,
"memory_available": available,
"utilization_percent": _percent(utilization),
"temperature_c": _temperature(temperature),
})
return result
def _collect_snapshot(
psutil_module=psutil,
model_management_module=model_management,
nvml_module=pynvml,
) -> dict:
memory = psutil_module.virtual_memory()
total = _bytes(memory.total)
available = min(_bytes(memory.available), total)
return {
"cpu": {"utilization_percent": _percent(psutil_module.cpu_percent())},
"memory": {"total": total, "available": available},
"volumes": _volumes(psutil_module),
"accelerators": _accelerators(model_management_module, nvml_module),
}
def get_system_monitor_snapshot() -> dict:
global _cached_at, _cached_snapshot
now = time.monotonic()
with _cache_lock:
if _cached_snapshot is None or now - _cached_at >= SAMPLE_INTERVAL_SECONDS:
_cached_snapshot = _collect_snapshot()
_cached_at = now
return copy.deepcopy(_cached_snapshot)
@@ -0,0 +1,9 @@
The fixed BERT vocabulary used by BLIP visual question answering is copied from
`Salesforce/blip-vqa-base` at revision
`787b3d35d57e49572baabd22884b3d5a05acf072`.
Source: https://huggingface.co/Salesforce/blip-vqa-base/blob/787b3d35d57e49572baabd22884b3d5a05acf072/vocab.txt
SHA-256: `07eced375cec144d27c900241f3e339478dec958f92fddbc551f295c992038a3`
The source model repository is licensed BSD-3-Clause.
File diff suppressed because it is too large Load Diff
+271 -1
View File
@@ -279,6 +279,15 @@ class Qwen3VL_8BConfig(Qwen3_8BConfig):
rope_dims = [24, 20, 20]
interleaved_mrope = True
@dataclass
class Qwen3VL_2BConfig(Qwen3VL_8BConfig):
hidden_size: int = 2048
intermediate_size: int = 6144
num_hidden_layers: int = 28
num_attention_heads: int = 16
num_key_value_heads: int = 8
lm_head: bool = False
@dataclass
class Qwen3VL_4BConfig(Qwen3VL_8BConfig):
hidden_size: int = 2560
@@ -340,8 +349,26 @@ class Qwen25_7BVLI_Config:
k_norm = None
rope_scale = None
final_norm: bool = True
lm_head: bool = True
@dataclass
class Qwen25_3BVLI_Config(Qwen25_7BVLI_Config):
hidden_size: int = 2048
intermediate_size: int = 11008
num_hidden_layers: int = 36
num_attention_heads: int = 16
num_key_value_heads: int = 2
lm_head: bool = False
@dataclass
class Qwen3_06BGenerationConfig(Qwen3_06BConfig):
max_position_embeddings: int = 40960
@dataclass
class Qwen3_4BGenerationConfig(Qwen3_4BConfig):
max_position_embeddings: int = 262144
rope_theta: float = 5000000.0
@dataclass
class Gemma2_2B_Config:
vocab_size: int = 256000
@@ -1014,7 +1041,23 @@ class BaseGenerate:
def init_kv_cache(self, batch, max_cache_len, device, execution_dtype):
return self.model.init_kv_cache(batch, max_cache_len, device, execution_dtype)
def generate(self, embeds=None, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.9, min_p=0.0, repetition_penalty=1.0, seed=42, stop_tokens=None, initial_tokens=[], execution_dtype=None, min_tokens=0, presence_penalty=0.0, initial_input_ids=None, position_ids=None, deepstack_embeds=None, visual_pos_masks=None, embeds_info=None):
def generate(self, embeds=None, do_sample=True, max_length=256, temperature=1.0, top_k=50, top_p=0.9, min_p=0.0, repetition_penalty=1.0, seed=42, stop_tokens=None, initial_tokens=[], execution_dtype=None, min_tokens=0, presence_penalty=0.0, initial_input_ids=None, position_ids=None, deepstack_embeds=None, visual_pos_masks=None, embeds_info=None, num_beams=1):
if num_beams != 1:
return self.generate_beam(
embeds=embeds,
max_length=max_length,
repetition_penalty=repetition_penalty,
stop_tokens=stop_tokens,
initial_tokens=initial_tokens,
execution_dtype=execution_dtype,
presence_penalty=presence_penalty,
initial_input_ids=initial_input_ids,
position_ids=position_ids,
deepstack_embeds=deepstack_embeds,
visual_pos_masks=visual_pos_masks,
embeds_info=embeds_info,
num_beams=num_beams,
)
device = embeds.device
if stop_tokens is None:
@@ -1067,6 +1110,148 @@ class BaseGenerate:
return generated_token_ids
@staticmethod
def _clone_kv_cache(past_key_values):
return [
(key.clone(), value.clone(), position)
for key, value, position in past_key_values
]
def generate_beam(
self, *, embeds, max_length, repetition_penalty, stop_tokens,
initial_tokens, execution_dtype, presence_penalty,
initial_input_ids, position_ids, deepstack_embeds,
visual_pos_masks, embeds_info, num_beams,
):
"""Bounded deterministic beam search for canonical language models."""
if isinstance(num_beams, bool) or not isinstance(num_beams, int):
raise TypeError("num_beams must be an integer")
if not 2 <= num_beams <= 8:
raise ValueError("num_beams must be in [2, 8]")
device = embeds.device
if stop_tokens is None:
stop_tokens = self.model.config.stop_tokens
if execution_dtype is None:
execution_dtype = (
torch.bfloat16
if comfy.model_management.should_use_bf16(device)
else torch.float32
)
embeds = embeds.to(execution_dtype)
if embeds.ndim == 2:
embeds = embeds.unsqueeze(0)
if embeds.shape[0] != 1:
raise ValueError("beam generation currently requires batch size 1")
max_cache_len = embeds.shape[1] + max_length
cache = self.init_kv_cache(1, max_cache_len, device, execution_dtype)
extra = {}
if deepstack_embeds is not None:
extra["deepstack_embeds"] = deepstack_embeds
extra["visual_pos_masks"] = visual_pos_masks
output, _, cache = self.model.forward(
None,
embeds=embeds,
attention_mask=None,
past_key_values=cache,
input_ids=initial_input_ids,
position_ids=position_ids,
**extra,
embeds_info=embeds_info,
)
logits = self.logits(output)[:, -1]
log_probs = torch.nn.functional.log_softmax(logits.float(), dim=-1)
scores, tokens = torch.topk(log_probs[0], num_beams)
next_position = (
int(position_ids[:, -1].max()) + 1
if position_ids is not None else None
)
beams = []
for score, token in zip(scores.tolist(), tokens.tolist()):
beams.append({
"tokens": [int(token)],
"score": float(score),
"cache": self._clone_kv_cache(cache),
"finished": int(token) in stop_tokens,
})
pbar = comfy.utils.ProgressBar(max_length)
pbar.update(1)
for step in tqdm(range(1, max_length), desc="Generating beam tokens"):
candidates = []
for beam in beams:
if beam["finished"]:
candidates.append(beam)
continue
token = torch.tensor(
[[beam["tokens"][-1]]], device=device, dtype=torch.long)
token_embed = self.model.embed_tokens(token).to(execution_dtype)
decode_position = None
if next_position is not None:
decode_position = torch.tensor(
[[next_position + step - 1]], device=device)
output, _, updated_cache = self.model.forward(
None,
embeds=token_embed,
attention_mask=None,
past_key_values=beam["cache"],
input_ids=token if initial_input_ids is not None else None,
position_ids=decode_position,
)
logits = self.logits(output)[:, -1].float()
history = initial_tokens + beam["tokens"]
if history and (
repetition_penalty != 1.0 or presence_penalty != 0.0
):
ids = torch.tensor(
list(set(history)), device=device, dtype=torch.long)
selected = logits[:, ids]
if repetition_penalty != 1.0:
selected = torch.where(
selected < 0,
selected * repetition_penalty,
selected / repetition_penalty,
)
if presence_penalty != 0.0:
selected = selected - presence_penalty
logits[:, ids] = selected
log_probs = torch.nn.functional.log_softmax(logits, dim=-1)
child_scores, child_tokens = torch.topk(
log_probs[0], num_beams)
for child_score, child_token in zip(
child_scores.tolist(), child_tokens.tolist()
):
child_token = int(child_token)
candidates.append({
"tokens": beam["tokens"] + [child_token],
"score": beam["score"] + float(child_score),
"cache": updated_cache,
"finished": child_token in stop_tokens,
})
def rank(candidate):
# Matches the common length-penalty=1 beam ranking while
# keeping the raw score for future expansion.
return candidate["score"] / max(1, len(candidate["tokens"]))
selected = sorted(candidates, key=rank, reverse=True)[:num_beams]
cache_counts = {}
for candidate in selected:
cache_id = id(candidate["cache"])
cache_counts[cache_id] = cache_counts.get(cache_id, 0) + 1
if cache_counts[cache_id] > 1:
candidate["cache"] = self._clone_kv_cache(
candidate["cache"])
beams = selected
pbar.update(1)
if all(beam["finished"] for beam in beams):
break
return max(
beams,
key=lambda beam: beam["score"] / max(1, len(beam["tokens"])),
)["tokens"]
def sample_token(self, logits, temperature, top_k, top_p, min_p, repetition_penalty, token_history, generator, do_sample=True, presence_penalty=0.0):
if not do_sample or temperature == 0.0:
@@ -1300,6 +1485,91 @@ class Qwen25_7BVLI(BaseLlama, BaseGenerate, torch.nn.Module):
return super().forward(x, attention_mask=attention_mask, embeds=embeds, num_tokens=num_tokens, intermediate_output=intermediate_output, final_layer_norm_intermediate=final_layer_norm_intermediate, dtype=dtype, position_ids=position_ids)
class Qwen25VLI(Qwen25_7BVLI):
"""Canonical Qwen2.5-VL generation model for the fixed 3B/7B families."""
model_type = "qwen2_5_vl_7b"
def __init__(self, config_dict, dtype, device, operations):
torch.nn.Module.__init__(self)
config_class = (
Qwen25_3BVLI_Config
if self.model_type == "qwen2_5_vl_3b"
else Qwen25_7BVLI_Config
)
config = config_class(**config_dict)
self.num_layers = config.num_hidden_layers
self.model = Llama2_(config, device=device, dtype=dtype, ops=operations)
self.visual = qwen_vl.Qwen2VLVisionTransformer(
hidden_size=1280,
output_hidden_size=config.hidden_size,
device=device,
dtype=dtype,
ops=operations,
)
self.dtype = dtype
def preprocess_embed(self, embed, device):
if embed["type"] in {"image", "video"}:
pixels, grid, mrope = qwen_vl.process_qwen_vl_media(
embed["data"], family=self.model_type)
merged = self.visual(
pixels.to(device, dtype=torch.float32), grid.to(device))
return merged, {
"grid": grid,
"mrope": mrope,
}
return None, None
def forward(
self, x, attention_mask=None, embeds=None, num_tokens=None,
intermediate_output=None, final_layer_norm_intermediate=True,
dtype=None, embeds_info=[], **kwargs,
):
position_ids = kwargs.pop("position_ids", None)
if embeds is not None and position_ids is None:
position_ids = qwen_vl.qwen2vl_mrope_position_ids(
embeds_info, embeds.shape[1], embeds.device)
return BaseLlama.forward(
self,
x,
attention_mask=attention_mask,
embeds=embeds,
num_tokens=num_tokens,
intermediate_output=intermediate_output,
final_layer_norm_intermediate=final_layer_norm_intermediate,
dtype=dtype,
position_ids=position_ids,
**kwargs,
)
def make_qwen25_vl_model(model_type):
class Qwen25VLI_(Qwen25VLI):
pass
Qwen25VLI_.model_type = model_type
return Qwen25VLI_
class Qwen3_06BGeneration(BaseLlama, BaseQwen3, BaseGenerate, torch.nn.Module):
def __init__(self, config_dict, dtype, device, operations):
super().__init__()
config = Qwen3_06BGenerationConfig(**config_dict)
self.num_layers = config.num_hidden_layers
self.model = Llama2_(config, device=device, dtype=dtype, ops=operations)
self.dtype = dtype
class Qwen3_4BGeneration(BaseLlama, BaseQwen3, BaseGenerate, torch.nn.Module):
def __init__(self, config_dict, dtype, device, operations):
super().__init__()
config = Qwen3_4BGenerationConfig(**config_dict)
self.num_layers = config.num_hidden_layers
self.model = Llama2_(config, device=device, dtype=dtype, ops=operations)
self.dtype = dtype
class Gemma2_2B(BaseLlama, BaseGenerate, torch.nn.Module):
def __init__(self, config_dict, dtype, device, operations):
super().__init__()
+149 -6
View File
@@ -8,10 +8,20 @@ from transformers import Qwen2Tokenizer
from comfy import sd1_clip
import comfy.text_encoders.qwen_vl
from .qwen35 import Qwen35VisionModel
from .llama import BaseLlama, BaseQwen3, BaseGenerate, Llama2_, Qwen3VL_4BConfig, Qwen3VL_8BConfig, Qwen3VL_32BConfig
from .llama import (
BaseLlama,
BaseQwen3,
BaseGenerate,
Llama2_,
Qwen3VL_2BConfig,
Qwen3VL_4BConfig,
Qwen3VL_8BConfig,
Qwen3VL_32BConfig,
)
QWEN3VL_VISION = {
"qwen3vl_2b": dict(hidden_size=1024, intermediate_size=4096, depth=24, deepstack_visual_indexes=[5, 11, 17]),
"qwen3vl_4b": dict(hidden_size=1024, intermediate_size=4096, depth=24, deepstack_visual_indexes=[5, 11, 17]),
"qwen3vl_8b": dict(hidden_size=1152, intermediate_size=4304, depth=27, deepstack_visual_indexes=[8, 16, 24]),
"qwen3vl_32b": dict(hidden_size=1152, intermediate_size=4304, depth=27, deepstack_visual_indexes=[8, 16, 24]),
@@ -19,7 +29,12 @@ QWEN3VL_VISION = {
QWEN3VL_VISION_COMMON = dict(num_heads=16, patch_size=16, temporal_patch_size=2, in_channels=3,
spatial_merge_size=2, num_position_embeddings=2304)
QWEN3VL_CONFIGS = {"qwen3vl_4b": Qwen3VL_4BConfig, "qwen3vl_8b": Qwen3VL_8BConfig, "qwen3vl_32b": Qwen3VL_32BConfig}
QWEN3VL_CONFIGS = {
"qwen3vl_2b": Qwen3VL_2BConfig,
"qwen3vl_4b": Qwen3VL_4BConfig,
"qwen3vl_8b": Qwen3VL_8BConfig,
"qwen3vl_32b": Qwen3VL_32BConfig,
}
class Qwen3VLDeepstackMerger(nn.Module):
@@ -60,7 +75,40 @@ class Qwen3VL(BaseLlama, BaseQwen3, BaseGenerate, torch.nn.Module):
self.dtype = dtype
def preprocess_embed(self, embed, device):
if embed["type"] == "video_segment":
state = embed["state"]
if state.get("cache") is None:
pixels, grid, mrope = comfy.text_encoders.qwen_vl.process_qwen_vl_media(
state["data"], family=self.model_type)
merged, deepstack = self.visual(
pixels.to(device, dtype=torch.float32), grid.to(device))
segment_size = (int(grid[0, 1]) // 2) * (int(grid[0, 2]) // 2)
state["cache"] = {
"merged": merged.split(segment_size, dim=0),
"deepstack": [value.split(segment_size, dim=0) for value in deepstack],
"mrope": mrope.split(segment_size, dim=1),
"grid": grid,
}
cache = state["cache"]
index = int(embed["segment"])
merged = cache["merged"][index]
deepstack = [value[index] for value in cache["deepstack"]]
return merged, {
"grid": cache["grid"][:, :].clone(),
"mrope": cache["mrope"][index],
"deepstack": deepstack,
}
if embed["type"] == "image":
if embed.get("canonical_family") == self.model_type:
image, grid, mrope = comfy.text_encoders.qwen_vl.process_qwen_vl_media(
embed["data"], family=self.model_type)
merged, deepstack = self.visual(
image.to(device, dtype=torch.float32), grid.to(device))
return merged, {
"grid": grid,
"mrope": mrope,
"deepstack": deepstack,
}
# Qwen3-VL normalizes to [-1, 1] (mean/std 0.5), unlike Qwen2.5-VL's CLIP normalization.
image, grid = comfy.text_encoders.qwen_vl.process_qwen2vl_images(embed["data"], patch_size=16, image_mean=[0.5, 0.5, 0.5], image_std=[0.5, 0.5, 0.5])
merged, deepstack = self.visual(image.to(device, dtype=torch.float32), grid)
@@ -69,7 +117,10 @@ class Qwen3VL(BaseLlama, BaseQwen3, BaseGenerate, torch.nn.Module):
def build_image_inputs(self, embeds, embeds_info):
# Returns (position_ids, visual_pos_masks, deepstack) for the prompt
images = sorted([e for e in embeds_info if e.get("type") == "image"], key=lambda e: e["index"])
images = sorted([
e for e in embeds_info
if e.get("type") in {"image", "video_segment"}
], key=lambda e: e["index"])
if len(images) == 0:
return None, None, None
@@ -127,7 +178,7 @@ class Qwen3VLClipModel(sd1_clip.SDClipModel):
model_class=_make_qwen3vl_model(model_type), enable_attention_masks=attention_mask,
return_attention_masks=attention_mask, model_options=model_options)
def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty=0.0):
def generate(self, tokens, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed, presence_penalty=0.0, num_beams=1):
if isinstance(tokens, dict):
tokens = next(iter(tokens.values()))
tokens_only = [[t[0] for t in b] for b in tokens]
@@ -135,7 +186,8 @@ class Qwen3VLClipModel(sd1_clip.SDClipModel):
position_ids, visual_pos_masks, deepstack = self.transformer.build_image_inputs(embeds, embeds_info)
return self.transformer.generate(embeds, do_sample, max_length, temperature, top_k, top_p, min_p, repetition_penalty, seed,
presence_penalty=presence_penalty, position_ids=position_ids,
visual_pos_masks=visual_pos_masks, deepstack_embeds=deepstack)
visual_pos_masks=visual_pos_masks, deepstack_embeds=deepstack,
num_beams=num_beams)
class Qwen3VLTEModel(sd1_clip.SD1ClipModel):
@@ -153,7 +205,12 @@ class Qwen3VLSDTokenizer(sd1_clip.SDTokenizer):
class Qwen3VLTokenizer(sd1_clip.SD1Tokenizer):
def __init__(self, embedding_directory=None, tokenizer_data={}, model_type="qwen3vl_8b"):
embedding_size = 2560 if model_type == "qwen3vl_4b" else 4096
embedding_size = {
"qwen3vl_2b": 2048,
"qwen3vl_4b": 2560,
"qwen3vl_8b": 4096,
"qwen3vl_32b": 5120,
}[model_type]
tokenizer = lambda *a, **kw: Qwen3VLSDTokenizer(*a, **kw, embedding_size=embedding_size, embedding_key=model_type)
super().__init__(embedding_directory=embedding_directory, tokenizer_data=tokenizer_data, name=model_type, tokenizer=tokenizer)
self.llama_template = "<|im_start|>user\n{}<|im_end|>\n<|im_start|>assistant\n"
@@ -196,6 +253,92 @@ class Qwen3VLTokenizer(sd1_clip.SD1Tokenizer):
return tokens
class Qwen3VLGenerationTokenizer(Qwen3VLTokenizer):
"""Static official chat/media template used by the secure VLM loader."""
def tokenize_with_weights(
self, text, return_word_ids=False, images=[], prevent_empty_text=False,
thinking=False, **kwargs,
):
image = kwargs.pop("image", None)
video = kwargs.pop("video", None)
skip_template = bool(kwargs.pop("skip_template", False))
if image is not None and len(images) == 0:
images = [image]
if len(images) > 1:
raise ValueError("canonical Qwen3-VL accepts at most one still image")
descriptors = []
if skip_template:
llama_text = text
else:
content = ""
if images:
content += "<|vision_start|><|image_pad|><|vision_end|>"
descriptors.append({
"type": "image",
"data": images[0],
"original_type": "image",
"canonical_family": self.clip_name,
})
if video is not None:
if not isinstance(video, torch.Tensor) or video.ndim != 4:
raise TypeError("Qwen3-VL video must be BHWC")
state = {"data": video, "cache": None}
frame_count = int(video.shape[0])
for segment in range((frame_count + 1) // 2):
first = segment * 2
second = min(first + 1, frame_count - 1)
timestamp = ((first + second) / 2.0) / 24.0
content += (
f"<{timestamp:.1f} seconds>"
"<|vision_start|><|image_pad|><|vision_end|>"
)
descriptors.append({
"type": "video_segment",
"state": state,
"segment": segment,
"original_type": "video",
})
content += text
llama_text = (
"<|im_start|>user\n" + content
+ "<|im_end|>\n<|im_start|>assistant\n"
)
if thinking:
llama_text += "<think>\n"
tokens = sd1_clip.SD1Tokenizer.tokenize_with_weights(
self,
llama_text,
return_word_ids=return_word_ids,
disable_weights=True,
**kwargs,
)
key_name = next(iter(tokens))
descriptor_index = 0
for row in tokens[key_name]:
for index, item in enumerate(row):
if item[0] == 151655 and descriptor_index < len(descriptors):
row[index] = (descriptors[descriptor_index],) + item[1:]
descriptor_index += 1
if descriptor_index != len(descriptors):
raise ValueError("Qwen3-VL template/media placeholder mismatch")
return tokens
def generation_tokenizer(model_type="qwen3vl_8b"):
class Qwen3VLGenerationTokenizer_(Qwen3VLGenerationTokenizer):
def __init__(self, embedding_directory=None, tokenizer_data={}):
super().__init__(
embedding_directory=embedding_directory,
tokenizer_data=tokenizer_data,
model_type=model_type,
)
return Qwen3VLGenerationTokenizer_
def tokenizer(model_type="qwen3vl_8b"):
class Qwen3VLTokenizer_(Qwen3VLTokenizer):
def __init__(self, embedding_directory=None, tokenizer_data={}):
+169
View File
@@ -0,0 +1,169 @@
import os
from transformers import Qwen2Tokenizer
from comfy import sd1_clip
import comfy.text_encoders.llama
_FAMILIES = {
"qwen3_0_6b": {
"embedding_size": 1024,
"model": comfy.text_encoders.llama.Qwen3_06BGeneration,
},
"qwen3_4b": {
"embedding_size": 2560,
"model": comfy.text_encoders.llama.Qwen3_4BGeneration,
},
}
class QwenGenerationSDTokenizer(sd1_clip.SDTokenizer):
def __init__(
self, embedding_directory=None, tokenizer_data={},
model_type="qwen3_4b",
):
details = _FAMILIES[model_type]
tokenizer_path = os.path.join(
os.path.dirname(os.path.realpath(__file__)), "qwen25_tokenizer")
super().__init__(
tokenizer_path,
pad_with_end=False,
embedding_directory=embedding_directory,
embedding_size=details["embedding_size"],
embedding_key=model_type,
tokenizer_class=Qwen2Tokenizer,
has_start_token=False,
has_end_token=False,
pad_to_max_length=False,
max_length=99999999,
min_length=1,
pad_token=151643,
tokenizer_data=tokenizer_data,
)
class QwenGenerationTokenizer(sd1_clip.SD1Tokenizer):
def __init__(
self, embedding_directory=None, tokenizer_data={},
model_type="qwen3_4b",
):
tokenizer = lambda *args, **kwargs: QwenGenerationSDTokenizer(
*args, **kwargs, model_type=model_type)
super().__init__(
embedding_directory=embedding_directory,
tokenizer_data=tokenizer_data,
name=model_type,
tokenizer=tokenizer,
)
def tokenize_with_weights(
self, text, return_word_ids=False, skip_template=False,
thinking=False, **kwargs,
):
if not skip_template:
text = (
"<|im_start|>user\n" + text
+ "<|im_end|>\n<|im_start|>assistant\n"
)
if thinking:
text += "<think>\n"
return super().tokenize_with_weights(
text,
return_word_ids=return_word_ids,
disable_weights=True,
**kwargs,
)
class QwenGenerationClipModel(sd1_clip.SDClipModel):
def __init__(
self, device="cpu", layer="last", layer_idx=None, dtype=None,
attention_mask=True, model_options={}, model_type="qwen3_4b",
):
super().__init__(
device=device,
layer=layer,
layer_idx=layer_idx,
textmodel_json_config={},
dtype=dtype,
special_tokens={"pad": 151643},
layer_norm_hidden_state=False,
model_class=_FAMILIES[model_type]["model"],
enable_attention_masks=attention_mask,
return_attention_masks=attention_mask,
model_options=model_options,
)
def generate(
self, tokens, do_sample, max_length, temperature, top_k, top_p,
min_p, repetition_penalty, seed, presence_penalty=0.0,
num_beams=1,
):
if isinstance(tokens, dict):
tokens = next(iter(tokens.values()))
tokens_only = [[item[0] for item in row] for row in tokens]
embeds = self.process_tokens(tokens_only, self.execution_device)[0]
return self.transformer.generate(
embeds,
do_sample,
max_length,
temperature,
top_k,
top_p,
min_p,
repetition_penalty,
seed,
presence_penalty=presence_penalty,
num_beams=num_beams,
)
class QwenGenerationTEModel(sd1_clip.SD1ClipModel):
def __init__(
self, device="cpu", dtype=None, model_options={},
model_type="qwen3_4b",
):
clip_model = lambda **kwargs: QwenGenerationClipModel(
**kwargs, model_type=model_type)
super().__init__(
device=device,
dtype=dtype,
name=model_type,
clip_model=clip_model,
model_options=model_options,
)
def tokenizer(model_type="qwen3_4b"):
class QwenGenerationTokenizer_(QwenGenerationTokenizer):
def __init__(self, embedding_directory=None, tokenizer_data={}):
super().__init__(
embedding_directory=embedding_directory,
tokenizer_data=tokenizer_data,
model_type=model_type,
)
return QwenGenerationTokenizer_
def te(
dtype_llama=None, llama_quantization_metadata=None,
model_type="qwen3_4b",
):
class QwenGenerationTEModel_(QwenGenerationTEModel):
def __init__(self, device="cpu", dtype=None, model_options={}):
if dtype_llama is not None:
dtype = dtype_llama
if llama_quantization_metadata is not None:
model_options = model_options.copy()
model_options["quantization_metadata"] = (
llama_quantization_metadata)
super().__init__(
device=device,
dtype=dtype,
model_options=model_options,
model_type=model_type,
)
return QwenGenerationTEModel_
+187
View File
@@ -1,6 +1,7 @@
from transformers import Qwen2Tokenizer
from comfy import sd1_clip
import comfy.text_encoders.llama
import comfy.text_encoders.qwen_vl
import os
import torch
import numbers
@@ -95,3 +96,189 @@ def te(dtype_llama=None, llama_quantization_metadata=None):
dtype = dtype_llama
super().__init__(device=device, dtype=dtype, model_options=model_options)
return QwenImageTEModel_
class Qwen25VLTokenizer(sd1_clip.SD1Tokenizer):
"""Static official Qwen2.5-VL tokenizer/template for generation."""
def __init__(
self, embedding_directory=None, tokenizer_data={},
model_type="qwen2_5_vl_7b",
):
embedding_size = 2048 if model_type == "qwen2_5_vl_3b" else 3584
class Tokenizer(Qwen25_7BVLITokenizer):
def __init__(inner_self, embedding_directory=None, tokenizer_data={}):
tokenizer_path = os.path.join(
os.path.dirname(os.path.realpath(__file__)),
"qwen25_tokenizer",
)
sd1_clip.SDTokenizer.__init__(
inner_self,
tokenizer_path,
pad_with_end=False,
embedding_directory=embedding_directory,
embedding_size=embedding_size,
embedding_key=model_type,
tokenizer_class=Qwen2Tokenizer,
has_start_token=False,
has_end_token=False,
pad_to_max_length=False,
max_length=99999999,
min_length=1,
pad_token=151643,
tokenizer_data=tokenizer_data,
)
super().__init__(
embedding_directory=embedding_directory,
tokenizer_data=tokenizer_data,
name=model_type,
tokenizer=Tokenizer,
)
self.model_type = model_type
def tokenize_with_weights(
self, text, return_word_ids=False, image=None, video=None,
skip_template=False, **kwargs,
):
descriptors = []
if skip_template:
llama_text = text
else:
content = ""
if image is not None:
content += "<|vision_start|><|image_pad|><|vision_end|>"
descriptors.append({
"type": "image",
"data": image,
"original_type": "image",
})
if video is not None:
content += "<|vision_start|><|video_pad|><|vision_end|>"
descriptors.append({
"type": "video",
"data": video,
"original_type": "video",
})
content += text
llama_text = (
"<|im_start|>system\nYou are a helpful assistant."
"<|im_end|>\n<|im_start|>user\n"
+ content
+ "<|im_end|>\n<|im_start|>assistant\n"
)
tokens = super().tokenize_with_weights(
llama_text,
return_word_ids=return_word_ids,
disable_weights=True,
**kwargs,
)
rows = tokens[self.clip_name]
descriptor_index = 0
for row in rows:
for index, item in enumerate(row):
if item[0] in {151655, 151656} and descriptor_index < len(descriptors):
row[index] = (descriptors[descriptor_index],) + item[1:]
descriptor_index += 1
if descriptor_index != len(descriptors):
raise ValueError("Qwen2.5-VL template/media placeholder mismatch")
return tokens
class Qwen25VLClipModel(sd1_clip.SDClipModel):
def __init__(
self, device="cpu", layer="last", layer_idx=None, dtype=None,
attention_mask=True, model_options={}, model_type="qwen2_5_vl_7b",
):
model_class = comfy.text_encoders.llama.make_qwen25_vl_model(model_type)
super().__init__(
device=device,
layer=layer,
layer_idx=layer_idx,
textmodel_json_config={},
dtype=dtype,
special_tokens={"pad": 151643},
layer_norm_hidden_state=False,
model_class=model_class,
enable_attention_masks=attention_mask,
return_attention_masks=attention_mask,
model_options=model_options,
)
def generate(
self, tokens, do_sample, max_length, temperature, top_k, top_p,
min_p, repetition_penalty, seed, presence_penalty=0.0,
num_beams=1,
):
if isinstance(tokens, dict):
tokens = next(iter(tokens.values()))
tokens_only = [[item[0] for item in row] for row in tokens]
embeds, _, _, embeds_info = self.process_tokens(
tokens_only, self.execution_device)
position_ids = comfy.text_encoders.qwen_vl.qwen2vl_mrope_position_ids(
embeds_info, embeds.shape[1], embeds.device)
return self.transformer.generate(
embeds,
do_sample,
max_length,
temperature,
top_k,
top_p,
min_p,
repetition_penalty,
seed,
presence_penalty=presence_penalty,
position_ids=position_ids,
num_beams=num_beams,
)
class Qwen25VLTEModel(sd1_clip.SD1ClipModel):
def __init__(
self, device="cpu", dtype=None, model_options={},
model_type="qwen2_5_vl_7b",
):
clip_model = lambda **kwargs: Qwen25VLClipModel(
**kwargs, model_type=model_type)
super().__init__(
device=device,
dtype=dtype,
name=model_type,
clip_model=clip_model,
model_options=model_options,
)
def vl_tokenizer(model_type="qwen2_5_vl_7b"):
class Qwen25VLTokenizer_(Qwen25VLTokenizer):
def __init__(self, embedding_directory=None, tokenizer_data={}):
super().__init__(
embedding_directory=embedding_directory,
tokenizer_data=tokenizer_data,
model_type=model_type,
)
return Qwen25VLTokenizer_
def vl_te(
dtype_llama=None, llama_quantization_metadata=None,
model_type="qwen2_5_vl_7b",
):
class Qwen25VLTEModel_(Qwen25VLTEModel):
def __init__(self, device="cpu", dtype=None, model_options={}):
if dtype_llama is not None:
dtype = dtype_llama
if llama_quantization_metadata is not None:
model_options = model_options.copy()
model_options["quantization_metadata"] = (
llama_quantization_metadata)
super().__init__(
device=device,
dtype=dtype,
model_options=model_options,
model_type=model_type,
)
return Qwen25VLTEModel_
+202
View File
@@ -88,9 +88,211 @@ def process_qwen2vl_images(
return flatten_patches, image_grid_thw
def _qwen_smart_resize(
height: int,
width: int,
*,
factor: int,
min_pixels: int,
max_pixels: int,
frames: int = 1,
padded_frames: Optional[int] = None,
):
"""Return the closed Qwen processor resize for one media item.
This intentionally contains only the geometry shared by the official
Qwen2.5-VL and Qwen3-VL processors. Conversation construction and frame
selection remain caller policy.
"""
if height <= 0 or width <= 0 or frames <= 0:
raise ValueError("Qwen media dimensions must be positive")
if padded_frames is None:
padded_frames = frames
if padded_frames <= 0:
raise ValueError("Qwen padded media length must be positive")
if max(height, width) / min(height, width) > 200:
raise ValueError("Qwen media aspect ratio must not exceed 200")
if factor <= 0 or min_pixels <= 0 or max_pixels < min_pixels:
raise ValueError("invalid Qwen resize bounds")
h_bar = max(factor, round(height / factor) * factor)
w_bar = max(factor, round(width / factor) * factor)
volume = padded_frames * h_bar * w_bar
if volume > max_pixels:
beta = math.sqrt((frames * height * width) / max_pixels)
h_bar = max(factor, math.floor(height / beta / factor) * factor)
w_bar = max(factor, math.floor(width / beta / factor) * factor)
elif volume < min_pixels:
beta = math.sqrt(min_pixels / (frames * height * width))
h_bar = max(factor, math.ceil(height * beta / factor) * factor)
w_bar = max(factor, math.ceil(width * beta / factor) * factor)
return h_bar, w_bar
def process_qwen_vl_media(
frames: torch.Tensor,
*,
family: str,
):
"""Patch one already-selected Qwen image/video batch.
``frames`` is BHWC RGB in [0, 1]. The return value is
``(patches, grid_thw, relative_mrope)``. ``relative_mrope`` describes the
merged visual tokens only; the language-model wrapper offsets it around
surrounding text. No temporal sampling occurs here.
"""
if not isinstance(frames, torch.Tensor) or frames.ndim != 4:
raise TypeError("Qwen media must be a BHWC tensor")
if frames.shape[0] < 1 or frames.shape[-1] < 3:
raise ValueError("Qwen media must contain RGB frames")
if frames.shape[0] > 64:
raise ValueError("Qwen video is limited to 64 selected frames")
frames = frames[..., :3]
count, height, width, _ = map(int, frames.shape)
is_qwen3 = family.startswith(("qwen3_vl_", "qwen3vl_"))
is_qwen25 = family.startswith("qwen2_5_vl_")
if not (is_qwen3 or is_qwen25):
raise ValueError(f"unsupported Qwen vision family {family!r}")
if is_qwen3:
patch_size = 16
factor = 32
min_pixels = 4096
max_pixels = 25_165_824
mean = (0.5, 0.5, 0.5)
std = (0.5, 0.5, 0.5)
# The video processor pads an odd final frame instead of dropping it,
# so the spatial budget must use that same padded temporal length.
resize_frames = count
padded_resize_frames = max(2, math.ceil(count / 2) * 2)
else:
patch_size = 14
factor = 28
min_pixels = 3136
max_pixels = 12_845_056
mean = (0.48145466, 0.4578275, 0.40821073)
std = (0.26862954, 0.26130258, 0.27577711)
resize_frames = 1
padded_resize_frames = 1
h_bar, w_bar = _qwen_smart_resize(
height,
width,
factor=factor,
min_pixels=min_pixels,
max_pixels=max_pixels,
frames=resize_frames,
padded_frames=padded_resize_frames,
)
pixels = frames.permute(0, 3, 1, 2)
pixels = F.interpolate(
pixels,
size=(h_bar, w_bar),
mode="bicubic",
align_corners=False,
)
mean_tensor = torch.tensor(mean, device=pixels.device, dtype=pixels.dtype)[None, :, None, None]
std_tensor = torch.tensor(std, device=pixels.device, dtype=pixels.dtype)[None, :, None, None]
pixels = (pixels - mean_tensor) / std_tensor
if count % 2:
pixels = torch.cat((pixels, pixels[-1:]), dim=0)
grid_t = pixels.shape[0] // 2
grid_h = h_bar // patch_size
grid_w = w_bar // patch_size
merge_size = 2
channels = pixels.shape[1]
patches = pixels.reshape(
grid_t,
2,
channels,
grid_h // merge_size,
merge_size,
patch_size,
grid_w // merge_size,
merge_size,
patch_size,
)
patches = patches.permute(0, 3, 6, 4, 7, 2, 1, 5, 8)
patches = patches.reshape(
grid_t * grid_h * grid_w,
channels * 2 * patch_size * patch_size,
)
grid = torch.tensor(
[[grid_t, grid_h, grid_w]], device=frames.device, dtype=torch.long)
merged_h = grid_h // merge_size
merged_w = grid_w // merge_size
spatial = merged_h * merged_w
if is_qwen25:
temporal = torch.arange(
grid_t, device=frames.device, dtype=torch.long) * 2
else:
# Qwen3-VL carries time in the timestamp text preceding each visual
# span. Each pair therefore starts its visual MRoPE at t=0.
temporal = torch.zeros(grid_t, device=frames.device, dtype=torch.long)
t_ids = temporal.repeat_interleave(spatial)
h_ids = torch.arange(
merged_h, device=frames.device, dtype=torch.long
).repeat_interleave(merged_w).repeat(grid_t)
w_ids = torch.arange(
merged_w, device=frames.device, dtype=torch.long
).repeat(merged_h).repeat(grid_t)
relative_mrope = torch.stack((t_ids, h_ids, w_ids), dim=0)
return patches, grid, relative_mrope
def qwen2vl_mrope_position_ids(embeds_info, seq_len, device):
# (3, seq_len) T/H/W MRoPE position ids: text runs sequentially, each image span gets its grid positions.
# Returns None when there are no image embeds. `extra` is the image grid_thw, or a dict carrying it under "grid".
media = [
e for e in embeds_info
if e.get("type") in {"image", "video", "video_segment"}
]
if not media:
return None
# New canonical media wrappers provide exact relative positions. Keep
# the legacy grid path below for existing image-generation encoders.
if all(isinstance(e.get("extra"), dict)
and e["extra"].get("mrope") is not None for e in media):
position_ids = torch.zeros(
(3, seq_len), device=device, dtype=torch.long)
cursor = 0
next_position = 0
for e in sorted(media, key=lambda item: item["index"]):
start = int(e["index"])
end = start + int(e["size"])
if start < cursor or end > seq_len:
raise ValueError("overlapping or invalid Qwen media span")
text_len = start - cursor
if text_len:
text = torch.arange(
next_position,
next_position + text_len,
device=device,
dtype=torch.long,
)
position_ids[:, cursor:start] = text
next_position += text_len
relative = e["extra"]["mrope"].to(
device=device, dtype=torch.long)
if relative.shape != (3, end - start):
raise ValueError("Qwen media MRoPE shape does not match span")
position_ids[:, start:end] = relative + next_position
next_position += int(relative.max().item()) + 1
cursor = end
if cursor < seq_len:
text = torch.arange(
next_position,
next_position + seq_len - cursor,
device=device,
dtype=torch.long,
)
position_ids[:, cursor:] = text
return position_ids
position_ids = None
offset = 0
for e in embeds_info:
+142
View File
@@ -0,0 +1,142 @@
from __future__ import annotations
import math
import os
from typing import Any
class InProcessAnima:
async def apply_lllite(
self,
model: Any,
weights: Any,
image: Any,
*,
strength: float = 1.0,
start_percent: float = 0.0,
end_percent: float = 1.0,
preserve_wrapper: bool = True,
) -> Any:
import torch
import comfy.ldm.anima.lllite
import comfy.model_base
import comfy.model_management
import comfy.model_patcher
import comfy.ops
import comfy.utils
import folder_paths
from . import _sdk
if not isinstance(model, _sdk.ModelRef) or model.kind != "MODEL":
raise TypeError("Anima LLLite needs a MODEL ref")
if not isinstance(weights, _sdk.AssetRef) or weights.kind != "ASSET":
raise TypeError("Anima LLLite weights must be an ASSET ref")
if not isinstance(image, _sdk.ImageRef) or image.kind != "IMAGE":
raise TypeError("Anima LLLite image must be an IMAGE ref")
checked = {}
for name, value, minimum, maximum in (
("strength", strength, -10.0, 10.0),
("start_percent", start_percent, 0.0, 1.0),
("end_percent", end_percent, 0.0, 1.0),
):
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise TypeError(f"{name} must be a number")
number = float(value)
if not math.isfinite(number) or not minimum <= number <= maximum:
raise ValueError(
f"{name} must be finite and in [{minimum}, {maximum}]")
checked[name] = number
if not isinstance(preserve_wrapper, bool):
raise TypeError("preserve_wrapper must be a boolean")
runtime = _sdk.current_runtime()
source_model = await runtime.refs.resolve(model)
source_image = await runtime.refs.resolve(image)
path = await runtime.refs.resolve(weights)
if not isinstance(path, (str, os.PathLike)):
raise TypeError("Anima LLLite ASSET ref does not contain a path")
path = _sdk._InProcessAssets._confined_resolved_path(
path, folder_paths.get_folder_paths("controlnet"), "controlnet")
if os.path.splitext(path)[1].lower() not in {".safetensors", ".sft"}:
raise ValueError("Anima LLLite weights must use SafeTensors")
size = os.path.getsize(path)
if not 0 < size <= 8 * 1024**3:
raise ValueError("Anima LLLite weights exceed the 8 GiB limit")
if not isinstance(source_image, torch.Tensor) or (
source_image.ndim != 4
or not 1 <= source_image.shape[0] <= 64
or source_image.shape[-1] < 3
or source_image.shape[1] < 1
or source_image.shape[2] < 1
or source_image.numel() > 268_435_456
):
raise ValueError("Anima LLLite needs a bounded BHWC image batch")
if not isinstance(
getattr(source_model, "model", None), comfy.model_base.Anima,
):
raise ValueError("Anima LLLite requires an Anima model")
state, metadata = comfy.utils.load_torch_file(
path, safe_load=True, return_metadata=True)
if (
not isinstance(state, dict)
or not state
or len(state) > 100_000
or any(
not isinstance(key, str) or not isinstance(value, torch.Tensor)
for key, value in state.items()
)
):
raise ValueError(
"Anima LLLite weights must be a bounded tensor-only state dict")
dtype = comfy.utils.weight_dtype(state)
lllite = comfy.ldm.anima.lllite.AnimaLLLite(
state,
metadata,
device=comfy.model_management.unet_offload_device(),
dtype=dtype,
operations=comfy.ops.manual_cast,
)
if lllite.cond_in_channels != 3:
raise ValueError(
"this Anima LLLite integration supports RGB control weights only")
model_patch = comfy.model_patcher.CoreModelPatcher(
lllite,
load_device=comfy.model_management.get_torch_device(),
offload_device=comfy.model_management.unet_offload_device(),
)
lllite.load_state_dict(state, assign=model_patch.is_dynamic())
sampling = source_model.get_model_object("model_sampling")
sigma_start = float(sampling.percent_to_sigma(checked["start_percent"]))
sigma_end = float(sampling.percent_to_sigma(checked["end_percent"]))
patch = comfy.ldm.anima.lllite.AnimaLLLitePatch(
model_patch,
source_image[..., :3],
None,
checked["strength"],
sigma_start,
sigma_end,
)
result = source_model.clone()
if not preserve_wrapper:
result.model_options.pop("model_function_wrapper", None)
result.set_model_post_input_patch(patch)
result.set_model_attn1_patch(
comfy.ldm.anima.lllite.AnimaLLLiteAttentionPatch(
patch,
{
"q": "self_attn_q_proj",
"k": "self_attn_k_proj",
"v": "self_attn_v_proj",
},
))
result.set_model_attn2_patch(
comfy.ldm.anima.lllite.AnimaLLLiteAttentionPatch(
patch, {"q": "cross_attn_q_proj"}))
result.set_model_patch(
comfy.ldm.anima.lllite.AnimaLLLiteMLPPatch(patch), "mlp_patch")
return _sdk.ModelRef._wrap(
await runtime.refs.create("MODEL", result))
+389
View File
@@ -0,0 +1,389 @@
"""Closed, read-only Civitai API projection for the Secure Nodes SDK.
This is deliberately not a general HTTP client. It can reach one fixed
vendor endpoint, accepts no credentials or caller-supplied URL, and returns a
small field projection rather than the vendor's unbounded response objects.
"""
from __future__ import annotations
import asyncio
import copy
import json
import math
import re
import threading
import time
import urllib.parse
import urllib.request
from collections import OrderedDict
from typing import Any
class InProcessCivitai:
_ORIGIN = "https://civitai.com"
_API_PREFIX = "/api/v1/"
_MAX_RESPONSE_BYTES = 4 * 1024 * 1024
_CACHE_TTL_SECONDS = 300.0
_CACHE_MAX_ENTRIES = 16
_CACHE: OrderedDict[str, tuple[float, dict[str, Any]]] = OrderedDict()
_CACHE_LOCK = threading.Lock()
_HASH = re.compile(r"[0-9A-Fa-f]{8,128}")
_HASH_NAME = re.compile(r"[A-Za-z0-9_-]{1,32}")
@staticmethod
def _bounded_text(value: Any, field: str, maximum: int) -> str:
if not isinstance(value, str):
raise ValueError(f"Civitai {field} must be a string")
value = value.strip()
if not value or len(value) > maximum or "\x00" in value:
raise ValueError(f"Civitai {field} is invalid")
return value
@staticmethod
def _bounded_id(value: Any, field: str) -> int:
if type(value) is not int or not 1 <= value <= 2**63 - 1:
raise ValueError(f"Civitai {field} must be a positive integer")
return value
@classmethod
def _fetch_json(cls, path: str, query: dict[str, Any] | None = None) -> dict:
if not isinstance(path, str) or not path.startswith(cls._API_PREFIX):
raise ValueError("Civitai request path is outside the fixed API")
encoded = urllib.parse.urlencode(query or {})
url = f"{cls._ORIGIN}{path}" + (f"?{encoded}" if encoded else "")
request = urllib.request.Request(
url,
headers={
"Accept": "application/json",
"User-Agent": "ComfyUI-Secure-Nodes/2",
},
method="GET",
)
with urllib.request.urlopen(request, timeout=15.0) as response:
final = urllib.parse.urlsplit(response.geturl())
if (final.scheme != "https" or final.hostname != "civitai.com"
or final.port not in (None, 443)
or not final.path.startswith(cls._API_PREFIX)):
raise RuntimeError("Civitai redirected outside its fixed API")
content_type = response.headers.get_content_type().lower()
if content_type not in {"application/json", "text/json"}:
raise RuntimeError("Civitai returned a non-JSON response")
declared = response.headers.get("Content-Length")
if declared is not None:
try:
declared_size = int(declared)
except ValueError as exc:
raise RuntimeError(
"Civitai returned an invalid response size") from exc
if not 0 <= declared_size <= cls._MAX_RESPONSE_BYTES:
raise RuntimeError("Civitai response exceeds the size limit")
payload = response.read(cls._MAX_RESPONSE_BYTES + 1)
if len(payload) > cls._MAX_RESPONSE_BYTES:
raise RuntimeError("Civitai response exceeds the size limit")
try:
value = json.loads(payload.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
raise RuntimeError("Civitai returned invalid JSON") from exc
if not isinstance(value, dict):
raise RuntimeError("Civitai returned an invalid response object")
return value
@classmethod
def _cached_fetch(
cls, path: str, query: dict[str, Any] | None = None,
refresh: bool = False,
) -> dict:
if type(refresh) is not bool:
raise TypeError("Civitai refresh must be a bool")
key = path + "?" + urllib.parse.urlencode(query or {})
now = time.monotonic()
if not refresh:
with cls._CACHE_LOCK:
cached = cls._CACHE.pop(key, None)
if cached is not None and now - cached[0] <= cls._CACHE_TTL_SECONDS:
cls._CACHE[key] = cached
return copy.deepcopy(cached[1])
value = cls._fetch_json(path, query)
with cls._CACHE_LOCK:
cls._CACHE[key] = (time.monotonic(), copy.deepcopy(value))
while len(cls._CACHE) > cls._CACHE_MAX_ENTRIES:
cls._CACHE.popitem(last=False)
return value
@classmethod
def _project_hashes(cls, value: Any) -> dict[str, str]:
if not isinstance(value, dict):
return {}
result: dict[str, str] = {}
for name, digest in list(value.items())[:16]:
if (isinstance(name, str) and cls._HASH_NAME.fullmatch(name)
and isinstance(digest, str)
and 1 <= len(digest) <= 256 and "\x00" not in digest):
result[name] = digest
return result
@classmethod
def _project_files(cls, value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
result = []
for item in value[:100]:
if not isinstance(item, dict):
continue
try:
name = cls._bounded_text(item.get("name"), "file name", 512)
except ValueError:
continue
result.append({
"name": name,
"hashes": cls._project_hashes(item.get("hashes")),
})
return result
@staticmethod
def _project_trained_words(value: Any) -> list[str]:
"""Return only the bounded public trigger-word projection.
Civitai responses are vendor-controlled, so the projection is capped
before it crosses the broker even when the upstream array is very
large or contains malformed entries.
"""
if not isinstance(value, list):
return []
result: list[str] = []
total_bytes = 0
for word in value[:2048]:
if not isinstance(word, str) or not word or "\x00" in word:
continue
encoded_size = len(word.encode("utf-8"))
if encoded_size > 512:
continue
if total_bytes + encoded_size > 1024 * 1024:
break
result.append(word)
total_bytes += encoded_size
return result
@classmethod
def _project_meta_value(
cls,
value: Any,
state: dict[str, int],
depth: int = 0,
) -> Any:
"""Project bounded image metadata without admitting vendor objects."""
if depth > 4 or state["items"] >= 256:
raise ValueError("Civitai image metadata exceeds its bounds")
state["items"] += 1
if value is None or isinstance(value, bool):
return value
if type(value) is int:
if not -(2**63) <= value <= 2**63 - 1:
raise ValueError("Civitai image metadata integer is invalid")
return value
if type(value) is float:
if not math.isfinite(value):
raise ValueError("Civitai image metadata number is invalid")
return value
if isinstance(value, str):
if "\x00" in value:
raise ValueError("Civitai image metadata text is invalid")
encoded = value.encode("utf-8")
if len(encoded) > 64 * 1024:
raise ValueError("Civitai image metadata text is too large")
state["bytes"] += len(encoded)
if state["bytes"] > 512 * 1024:
raise ValueError("Civitai image metadata exceeds its size limit")
return value
if isinstance(value, list):
if len(value) > 256:
raise ValueError("Civitai image metadata list is too large")
return [
cls._project_meta_value(item, state, depth + 1)
for item in value
]
if isinstance(value, dict):
if len(value) > 64:
raise ValueError("Civitai image metadata object is too large")
result = {}
for key, item in value.items():
if (
not isinstance(key, str)
or not key
or len(key.encode("utf-8")) > 128
or "\x00" in key
or key in {"__proto__", "constructor", "prototype"}
):
raise ValueError("Civitai image metadata key is invalid")
state["bytes"] += len(key.encode("utf-8"))
if state["bytes"] > 512 * 1024:
raise ValueError(
"Civitai image metadata exceeds its size limit")
result[key] = cls._project_meta_value(
item, state, depth + 1)
return result
raise ValueError("Civitai image metadata value is invalid")
@classmethod
def _project_images(cls, value: Any) -> list[dict[str, Any]]:
if not isinstance(value, list):
return []
result = []
state = {"items": 0, "bytes": 0}
for item in value[:32]:
if not isinstance(item, dict):
continue
try:
url = cls._bounded_text(item.get("url"), "image URL", 2048)
parsed = urllib.parse.urlsplit(url)
if (
parsed.scheme != "https"
or not parsed.hostname
or parsed.username is not None
or parsed.password is not None
or parsed.port not in (None, 443)
):
raise ValueError("Civitai image URL must be bounded HTTPS")
except (ValueError, TypeError):
continue
projected = {"url": url}
meta = item.get("meta")
if isinstance(meta, dict):
try:
projected["meta"] = cls._project_meta_value(
meta, state)
except ValueError:
# The image identity remains useful even when a malformed
# vendor metadata object is omitted.
pass
result.append(projected)
return result
@classmethod
def _project_version_summary(cls, value: Any) -> dict[str, Any] | None:
if not isinstance(value, dict):
return None
try:
return {
"id": cls._bounded_id(value.get("id"), "model version id"),
"name": cls._bounded_text(
value.get("name"), "model version name", 512),
}
except ValueError:
return None
@classmethod
def _project_search(cls, value: dict) -> dict[str, Any]:
items = value.get("items")
if not isinstance(items, list):
raise RuntimeError("Civitai model search has no items list")
result = []
for item in items[:100]:
if not isinstance(item, dict):
continue
try:
model_id = cls._bounded_id(item.get("id"), "model id")
name = cls._bounded_text(item.get("name"), "model name", 512)
except ValueError:
continue
versions = []
raw_versions = item.get("modelVersions")
if isinstance(raw_versions, list):
for version in raw_versions[:100]:
projected = cls._project_version_summary(version)
if projected is not None:
versions.append(projected)
result.append({
"id": model_id,
"name": name,
"modelVersions": versions,
})
return {"items": result}
@classmethod
def _project_version(cls, value: dict) -> dict[str, Any]:
return {
"id": cls._bounded_id(value.get("id"), "model version id"),
"name": cls._bounded_text(
value.get("name"), "model version name", 512),
"files": cls._project_files(value.get("files")),
}
@classmethod
def _project_version_by_hash(cls, value: dict) -> dict[str, Any]:
model = value.get("model")
if not isinstance(model, dict):
raise RuntimeError("Civitai model-version response has no model")
projected_model = {
"name": cls._bounded_text(model.get("name"), "model name", 512),
}
model_type = model.get("type")
if isinstance(model_type, str) and 1 <= len(model_type) <= 128:
projected_model["type"] = model_type
result: dict[str, Any] = {
"id": cls._bounded_id(value.get("id"), "model version id"),
"name": cls._bounded_text(
value.get("name"), "model version name", 512),
"modelId": cls._bounded_id(value.get("modelId"), "model id"),
"model": projected_model,
"files": cls._project_files(value.get("files")),
"trainedWords": cls._project_trained_words(
value.get("trainedWords")),
"images": cls._project_images(value.get("images")),
}
base_model = value.get("baseModel")
if isinstance(base_model, str):
try:
result["baseModel"] = cls._bounded_text(
base_model, "base model", 512)
except ValueError:
pass
air = value.get("air")
if isinstance(air, str) and 1 <= len(air) <= 512 and "\x00" not in air:
result["air"] = air
return result
async def search_models(
self, username: str, query: str | None = None,
limit: int = 20, nsfw: bool = False,
) -> dict[str, Any]:
username = self._bounded_text(username, "username", 128)
if query is not None:
query = self._bounded_text(query, "query", 512)
if type(limit) is not int or not 1 <= limit <= 100:
raise ValueError("Civitai search limit must be in [1, 100]")
if type(nsfw) is not bool:
raise TypeError("Civitai nsfw must be a bool")
params: dict[str, Any] = {
"username": username,
"limit": limit,
"nsfw": "true" if nsfw else "false",
}
if query is not None:
params["query"] = query
value = await asyncio.to_thread(
self._cached_fetch, "/api/v1/models", params)
return self._project_search(value)
async def model_version(self, model_version_id: int) -> dict[str, Any]:
model_version_id = self._bounded_id(
model_version_id, "model version id")
value = await asyncio.to_thread(
self._cached_fetch, f"/api/v1/model-versions/{model_version_id}")
return self._project_version(value)
async def model_version_by_hash(
self, hash_value: str, refresh: bool = False,
) -> dict[str, Any]:
if not isinstance(hash_value, str) or not self._HASH.fullmatch(hash_value):
raise ValueError("Civitai model hash must be 8-128 hex digits")
if type(refresh) is not bool:
raise TypeError("Civitai refresh must be a bool")
normalized = hash_value.upper()
value = await asyncio.to_thread(
self._cached_fetch,
f"/api/v1/model-versions/by-hash/{normalized}",
None,
refresh,
)
return self._project_version_by_hash(value)
+365
View File
@@ -0,0 +1,365 @@
"""Closed llama.cpp vendor integration for Secure Nodes V2.
The guest sees managed GGUF names and an opaque model ref only. Host paths,
chat-handler objects, model sessions, and encoded image bytes never cross the
boundary.
"""
from __future__ import annotations
import asyncio
import base64
from collections import OrderedDict
from dataclasses import dataclass, field
import inspect
import io
import math
import os
import threading
from typing import Any
@dataclass
class _Entry:
llm: Any
handler: Any
family: str
lock: threading.Lock = field(default_factory=threading.Lock)
def _classes():
try:
from llama_cpp import Llama
from llama_cpp import llama_chat_format
except ImportError as error:
raise RuntimeError(
"llama.cpp inference requires a host-managed llama-cpp-python "
"build with Qwen vision support") from error
return Llama, llama_chat_format
def _supported_kwargs(callable_value, values: dict[str, Any]) -> dict[str, Any]:
try:
parameters = inspect.signature(callable_value).parameters
except (TypeError, ValueError):
return values
if any(item.kind == inspect.Parameter.VAR_KEYWORD
for item in parameters.values()):
return values
return {key: value for key, value in values.items() if key in parameters}
def _validate_gguf(path: str) -> None:
# Reuse the host's closed GGUF header/count validation. A catalogue name
# is confinement, not proof that arbitrary local bytes are model weights.
from ._sdk import _InProcessModels
_InProcessModels._verify_weight_file(path, ".gguf")
def _load(
model_path: str,
mmproj_path: str | None,
family: str,
*,
device: str,
context_length: int,
batch_size: int,
gpu_layers: int,
image_max_tokens: int,
top_k: int,
pool_size: int,
) -> _Entry:
Llama, formats = _classes()
handler = None
if mmproj_path is not None:
handler_name = (
"Qwen3VLChatHandler"
if family == "qwen3_vl" else "Qwen25VLChatHandler"
)
handler_class = getattr(formats, handler_name, None)
if handler_class is None:
raise RuntimeError(
f"the host llama.cpp build lacks {handler_name}")
handler_options = _supported_kwargs(handler_class.__init__, {
"clip_model_path": mmproj_path,
"image_max_tokens": image_max_tokens,
"force_reasoning": False,
"verbose": False,
})
handler = handler_class(**handler_options)
import torch
# Preserve the pack's placement intent without trusting a requested
# accelerator that the host does not actually own. Its legacy backend
# offloaded layers only on CUDA; MPS and unavailable CUDA fell back to CPU.
wants_cuda = device == "auto" or device.startswith("cuda")
selected_gpu_layers = (
gpu_layers if wants_cuda and torch.cuda.is_available() else 0)
options = {
"model_path": model_path,
"n_ctx": context_length,
"n_batch": batch_size,
"n_gpu_layers": selected_gpu_layers,
"swa_full": True,
"verbose": False,
"pool_size": pool_size,
"top_k": top_k,
}
if handler is not None:
options.update({
"chat_handler": handler,
"image_min_tokens": 1024,
"image_max_tokens": image_max_tokens,
})
elif family == "qwen3":
options["chat_format"] = "qwen"
llm = Llama(**_supported_kwargs(Llama.__init__, options))
return _Entry(llm=llm, handler=handler, family=family)
class _Cache:
def __init__(self, maximum: int = 1):
self.maximum = maximum
self.entries: OrderedDict[tuple[Any, ...], _Entry] = OrderedDict()
self.lock = threading.Lock()
@staticmethod
def _file(path: str | None):
if path is None:
return None
status = os.stat(path)
return (
os.path.realpath(path), status.st_dev, status.st_ino,
status.st_size, status.st_mtime_ns, status.st_ctime_ns,
)
def get(self, model_path, mmproj_path, family, options, cache):
if not cache:
return _load(
model_path, mmproj_path, family, **options)
key = (
self._file(model_path), self._file(mmproj_path), family,
tuple(sorted(options.items())),
)
with self.lock:
entry = self.entries.pop(key, None)
if entry is not None:
self.entries[key] = entry
return entry
entry = _load(model_path, mmproj_path, family, **options)
while len(self.entries) >= self.maximum:
self.entries.popitem(last=False)
self.entries[key] = entry
return entry
def clear(self):
with self.lock:
count = len(self.entries)
self.entries.clear()
return count
_CACHE = _Cache()
class InProcessLlamaCpp:
_MAX_TEXT = 4 * 1024 * 1024
_MAX_PIXELS = 268_435_456
_MAX_IMAGE_BYTES = 64 * 1024 * 1024
@staticmethod
def _text(value, field, maximum):
if not isinstance(value, str) or "\x00" in value:
raise ValueError(f"llama.cpp {field} must be a string")
if len(value.encode("utf-8")) > maximum:
raise ValueError(f"llama.cpp {field} exceeds its size limit")
return value
async def load_chat_model(
self, model_weight: str, mmproj_weight: str | None = None, *,
family: str = "qwen3_vl", device: str = "auto",
context_length: int = 8192, batch_size: int = 512,
gpu_layers: int = -1, image_max_tokens: int = 4096,
top_k: int = 0, pool_size: int = 4_194_304,
cache: bool = True,
):
from ._sdk import LlamaCppModelRef, current_runtime
import folder_paths
if family not in {"qwen3_vl", "qwen2_5_vl", "qwen3"}:
raise ValueError("unknown llama.cpp Qwen family")
if device not in {"auto", "cpu", "mps", "cuda"} and not (
isinstance(device, str) and device.startswith("cuda:")
and device[5:].isdigit()
):
raise ValueError("invalid llama.cpp device")
bounds = {
"context_length": (context_length, 1024, 262144),
"batch_size": (batch_size, 64, 32768),
"gpu_layers": (gpu_layers, -1, 200),
"image_max_tokens": (image_max_tokens, 256, 1_024_000),
"top_k": (top_k, 0, 32768),
"pool_size": (pool_size, 1_048_576, 10_485_760),
}
checked = {}
for name, (value, minimum, maximum) in bounds.items():
if isinstance(value, bool) or not isinstance(value, int):
raise TypeError(f"llama.cpp {name} must be an integer")
if not minimum <= value <= maximum:
raise ValueError(f"llama.cpp {name} is outside its bounds")
checked[name] = value
if type(cache) is not bool:
raise TypeError("llama.cpp cache must be a boolean")
if (not isinstance(model_weight, str)
or not model_weight.lower().endswith(".gguf")):
raise ValueError("llama.cpp model weight must be managed GGUF")
model_path = folder_paths.get_full_path_or_raise(
"text_encoders", model_weight)
_validate_gguf(model_path)
mmproj_path = None
if mmproj_weight is not None:
if (not isinstance(mmproj_weight, str)
or not mmproj_weight.lower().endswith(".gguf")):
raise ValueError("llama.cpp projector weight must be managed GGUF")
mmproj_path = folder_paths.get_full_path_or_raise(
"text_encoders", mmproj_weight)
_validate_gguf(mmproj_path)
if family == "qwen3" and mmproj_path is not None:
raise ValueError("text-only Qwen must not receive an mmproj")
if family != "qwen3" and mmproj_path is None:
raise ValueError("Qwen vision models require an mmproj")
options = {
"device": device,
**checked,
}
entry = await asyncio.to_thread(
_CACHE.get,
model_path,
mmproj_path,
family,
options,
cache,
)
return LlamaCppModelRef._wrap(
await current_runtime().refs.create("LLAMA_CPP_MODEL", entry))
async def _images(self, image, video):
from ._sdk import ImageRef, current_runtime
import torch
from PIL import Image
batches = []
total_pixels = 0
for name, value, maximum in (
("image", image, 1), ("video", video, 64),
):
if value is None:
continue
if not isinstance(value, ImageRef):
raise TypeError(f"llama.cpp {name} must be an IMAGE ref")
pixels = await current_runtime().refs.resolve(value)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or not 1 <= int(pixels.shape[0]) <= maximum
or int(pixels.shape[-1]) < 3):
raise ValueError(f"llama.cpp {name} has an invalid shape")
if not torch.isfinite(pixels).all():
raise ValueError(f"llama.cpp {name} contains non-finite pixels")
batch, height, width = map(int, pixels.shape[:3])
if height <= 0 or width <= 0:
raise ValueError(f"llama.cpp {name} has an invalid shape")
total_pixels += batch * height * width
if total_pixels > self._MAX_PIXELS:
raise ValueError("llama.cpp media exceeds the pixel limit")
batches.append(pixels[..., :3])
if not batches:
return []
result = []
total = 0
for pixels in batches:
arrays = (pixels.detach().to("cpu").clamp(0, 1) * 255).to(
torch.uint8).numpy()
for array in arrays:
output = io.BytesIO()
Image.fromarray(array, mode="RGB").save(output, format="PNG")
encoded = base64.b64encode(output.getvalue()).decode("ascii")
total += len(encoded)
if total > self._MAX_IMAGE_BYTES:
raise ValueError(
"llama.cpp encoded media exceeds the size limit")
result.append(encoded)
return result
async def generate(
self, model, system: str, prompt: str,
image=None, video=None, max_tokens: int = 512,
temperature: float = 0.7, top_p: float = 0.9,
repetition_penalty: float = 1.0, seed: int = 1,
) -> str:
from ._sdk import LlamaCppModelRef, current_runtime
if not isinstance(model, LlamaCppModelRef):
raise TypeError("llama.cpp model must be an opaque model ref")
entry = await current_runtime().refs.resolve(model)
if not isinstance(entry, _Entry):
raise TypeError("invalid llama.cpp model ref")
system = self._text(system, "system prompt", 1_048_576)
prompt = self._text(prompt, "prompt", self._MAX_TEXT)
if (isinstance(max_tokens, bool) or not isinstance(max_tokens, int)
or not 1 <= max_tokens <= 4096):
raise ValueError("llama.cpp max_tokens must be in [1, 4096]")
numeric = {
"temperature": (temperature, 0.0, 2.0),
"top_p": (top_p, 0.0, 1.0),
"repetition_penalty": (repetition_penalty, 0.5, 2.0),
}
checked = {}
for name, (value, minimum, maximum) in numeric.items():
if isinstance(value, bool) or type(value) not in {int, float}:
raise TypeError(f"llama.cpp {name} must be numeric")
value = float(value)
if not math.isfinite(value) or not minimum <= value <= maximum:
raise ValueError(f"llama.cpp {name} is outside its bounds")
checked[name] = value
if (isinstance(seed, bool) or not isinstance(seed, int)
or not 0 <= seed <= 0xFFFFFFFF):
raise ValueError("llama.cpp seed must be a uint32")
images = await self._images(image, video)
if images and entry.handler is None:
raise ValueError("text-only llama.cpp model cannot receive media")
if images:
content = [{"type": "text", "text": prompt}]
content.extend({
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{encoded}"},
} for encoded in images)
else:
content = prompt
messages = [
{"role": "system", "content": system},
{"role": "user", "content": content},
]
def invoke():
with entry.lock:
return entry.llm.create_chat_completion(
messages=messages,
max_tokens=max_tokens,
temperature=checked["temperature"],
top_p=checked["top_p"],
repeat_penalty=checked["repetition_penalty"],
seed=seed,
stop=["<|im_end|>", "<|im_start|>"],
)
response = await asyncio.to_thread(invoke)
try:
text = response["choices"][0]["message"]["content"]
except (KeyError, IndexError, TypeError) as error:
raise RuntimeError("llama.cpp returned an invalid response") from error
return self._text(text, "response", self._MAX_TEXT).strip()
def clear(self):
return _CACHE.clear()
File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
"""Core-owned node-closure kinds — the closed table behind ``ctx.closures``.
A node closure is a pack-side function the host RETAINS past the dispatch that
registered it and invokes at a declared sampling phase. It exists for the case
`ModelRef.patch` cannot serve: nodes whose value IS pack-authored math running
during sampling (post-CFG rescaling, attention coupling, custom samplers), where
moving the algorithm into core is exactly what we refuse to do.
The design rule this table enforces (see `docs/design-node-closure-captures.md`
and D21 in `docs/v2-api-decisions.md`):
A closure closes over PACK-PLANE DATA ONLY. Its host-plane environment is
not captured, it is DECLARED named here, validated and resolved at
registration while the registering dispatch is still live, and retained
inside the closure's own registry entry so one release frees both.
So this module owns, per kind: the phase, the argument signature the host
supplies per invocation, the capture schema, the capability operations (usually
none), and the bounds. A guest supplies a function and declared captures; it
never supplies a phase implementation, an op name, or a module path.
Capture params reuse the `_model_transforms` `Param` family deliberately
`RefOf` there is already "declare a tensor the host callback retains, resolve at
patch time, kind-check before any implementation sees an object", which is the
same move at a different scope.
"""
from __future__ import annotations
from typing import Any
from ._model_transforms import Param, RefOf # noqa: F401 (RefOf: capture schemas)
class ClosureError(Exception):
"""A closure request the host refuses. Always says what was wrong."""
# --------------------------------------------------------------------------- #
# Bounds
#
# A closure pins its captures into PROMPT scope, which is longer than any other
# guest-reachable lifetime in the system. These caps are what stop a pack from
# turning that into unbounded host memory. They are per closure; the registry
# count cap (MAX_RETAINED_CLOSURES, transport/wire.py) bounds the other axis.
# --------------------------------------------------------------------------- #
MAX_CAPTURE_ENTRIES = 32
MAX_CAPTURE_BYTES = 512 * 1024 * 1024
# NOTE: a bounded list-of-refs capture type (for attention coupling's N
# masks) is deliberately NOT here. No shipped kind declares a list
# capture, and an unused param type is the same broken promise as an
# unimplemented kind. It arrives with the kind that needs it.
class ClosureKind:
"""One closed contract: when we call it, with what, and what it may hold."""
def __init__(
self,
*,
phase: str,
doc: str,
arguments: tuple[str, ...],
returns: str,
captures: dict[str, Param] | None = None,
capabilities: tuple[str, ...] = (),
stateful: bool = False,
) -> None:
self.phase = phase
self.doc = doc
self.arguments = arguments
self.returns = returns
self.captures = captures or {}
self.capabilities = capabilities
self.stateful = stateful
def validate_captures(self, supplied: dict | None) -> dict:
"""Check declared captures against this kind's schema.
Runs during the REGISTERING dispatch, while any ref token supplied is
still resolvable. Refusal here fails the registering node with a named
error, before anything is retained.
"""
supplied = dict(supplied or {})
if len(supplied) > MAX_CAPTURE_ENTRIES:
raise ClosureError(
f"{len(supplied)} captures exceeds the limit of "
f"{MAX_CAPTURE_ENTRIES}")
unknown = set(supplied) - set(self.captures)
if unknown:
raise ClosureError(
f"closure kind {self.phase!r} has no capture(s) "
f"{sorted(unknown)}; it declares {sorted(self.captures)}")
checked = {}
for name, spec in self.captures.items():
if name not in supplied:
if spec.required:
raise ClosureError(
f"closure kind {self.phase!r} requires capture {name!r}")
continue
checked[name] = spec.check(name, supplied[name])
return checked
def describe(self) -> dict:
return {
"phase": self.phase,
"doc": self.doc,
"arguments": list(self.arguments),
"returns": self.returns,
"captures": {n: s.describe() for n, s in self.captures.items()},
"capabilities": list(self.capabilities),
"stateful": self.stateful,
}
# --------------------------------------------------------------------------- #
# The table
#
# Only kinds whose delivery path is implemented and tested belong here. A kind
# listed but unimplemented would be an API promise the host cannot keep, so the
# remaining ComfyUI-ppm contracts (attention_couple,
# clip_token_weight_encoder, scheduler_provider) are deliberately absent until
# their delivery and, where applicable, capability plumbing lands.
# --------------------------------------------------------------------------- #
KINDS: dict[str, ClosureKind] = {
"post_cfg": ClosureKind(
phase="post_cfg",
doc=(
"Called after each guided denoise prediction, at most once per "
"model evaluation. Returns the adjusted guided prediction."
),
arguments=("guided", "cond", "uncond", "latent", "sigma", "cfg"),
returns="guided",
),
"pre_cfg": ClosureKind(
phase="pre_cfg",
doc=(
"Called after conditional model predictions and before CFG "
"combines them, at most once per model evaluation. Returns the "
"same prediction list with pack-authored adjustments."
),
arguments=("latent", "predictions", "presence", "sigma"),
returns="predictions",
),
"conditioning_selection": ClosureKind(
phase="conditioning_selection",
doc=(
"Called before the host evaluates a conditional batch. Receives "
"only branch-presence booleans and scalar sigma, and may disable "
"branches without seeing conditioning objects."
),
arguments=("presence", "sigma"),
returns="presence",
),
"conditioning_preprocess": ClosureKind(
phase="conditioning_preprocess",
doc=(
"Called before a host conditional batch is evaluated. Receives "
"only c_concat/c_crossattn tensor leaves, matching host-generated "
"noise tensors, and sigma; conditioning wrappers stay host-owned."
),
arguments=("conditioning_tensors", "noise_tensors", "sigma"),
returns="conditioning_tensors",
),
"latent_operation": ClosureKind(
phase="latent_operation",
doc=(
"Called when a downstream host node applies a LATENT_OPERATION. "
"Receives one latent tensor and returns one tensor with identical "
"shape, dtype, and device."
),
arguments=("latent",),
returns="latent",
),
"model_input_block": ClosureKind(
phase="model_input_block",
doc=(
"Called after one canonical 2D UNet input block and control "
"application, before the host saves its skip activation."
),
arguments=("hidden", "sigmas", "block_index"),
returns="hidden",
),
"model_middle_block": ClosureKind(
phase="model_middle_block",
doc=(
"Called after the canonical 2D UNet middle block and control "
"application."
),
arguments=("hidden", "sigmas", "block_index"),
returns="hidden",
),
"model_output_block": ClosureKind(
phase="model_output_block",
doc=(
"Called after the host retrieves and applies control to one "
"canonical 2D UNet skip, before hidden/skip concatenation."
),
arguments=("hidden", "skip", "sigmas", "block_index"),
returns="hidden_skip",
),
"model_sigma": ClosureKind(
phase="model_sigma",
doc=(
"Called by a wrapped sampler immediately before each model "
"evaluation. Returns the sigma tensor passed to that evaluation; "
"the underlying sampler and model call remain host-owned."
),
arguments=(
"sigma", "sigmas", "cfg", "start_sigma", "end_sigma",
),
returns="sigma",
),
"custom_sampler": ClosureKind(
phase="custom_sampler",
doc=(
"Called once for a complete sampling run. The pack owns the "
"integration loop and invocation-local history; an invocation-only "
"broker exposes bounded denoise, noise, preview, and schedule "
"operations while models and conditioning stay host-owned."
),
arguments=("broker", "latent", "sigmas"),
returns="latent",
capabilities=(
"denoise", "noise_like", "preview", "schedule_parameters",
),
stateful=True,
),
}
def get_kind(name: str) -> ClosureKind:
"""Resolve a kind name, refusing anything not in the closed table."""
if not isinstance(name, str):
raise ClosureError("closure kind must be a string")
kind = KINDS.get(name)
if kind is None:
raise ClosureError(
f"unknown closure kind {name!r}; supported kinds are "
f"{sorted(KINDS)}")
return kind
def describe_kinds() -> dict:
"""The generated-documentation view of the whole table."""
return {name: kind.describe() for name, kind in sorted(KINDS.items())}
+626
View File
@@ -0,0 +1,626 @@
"""Closed Ollama vendor integration for Secure Nodes V2.
This is intentionally not a general HTTP client. Direct node-supplied origins
are the default Ollama service on loopback only; any other deployment must be
named in host-admin configuration and nodes receive only that profile name.
Requests and responses are projected onto the small Ollama fields used by the
public node pack.
"""
from __future__ import annotations
import asyncio
import base64
import io
import ipaddress
import json
import math
import os
import re
import socket
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
class _NoRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
return None
class InProcessOllama:
_PROFILE = re.compile(r"[a-z0-9][a-z0-9._-]{0,63}")
_LOOPBACK = {"localhost", "127.0.0.1", "::1"}
_MAX_REQUEST_BYTES = 64 * 1024 * 1024
_MAX_RESPONSE_BYTES = 32 * 1024 * 1024
_MAX_TEXT_BYTES = 4 * 1024 * 1024
_MAX_CONTEXT_TOKENS = 262_144
_MAX_IMAGES = 16
_MAX_IMAGE_PIXELS = 67_108_864
_MAX_IMAGE_BYTES = 48 * 1024 * 1024
_OPTIONS = {
"mirostat": (int, 0, 2),
"mirostat_eta": (float, 0.0, 1000.0),
"mirostat_tau": (float, 0.0, 1000.0),
"num_ctx": (int, 0, 2**31),
"repeat_last_n": (int, -1, 64),
"repeat_penalty": (float, 0.0, 2.0),
"temperature": (float, -10.0, 10.0),
"seed": (int, 0, 2**31),
"tfs_z": (float, 1.0, 1000.0),
"num_predict": (int, -2, 32_768),
"top_k": (int, 0, 100),
"top_p": (float, 0.0, 1.0),
"min_p": (float, 0.0, 1.0),
"main_gpu": (int, 0, 0),
}
_BOOLEAN_OPTIONS = {"low_vram"}
_TOOL_NAME = re.compile(r"[A-Za-z_][A-Za-z0-9_]{0,63}")
@staticmethod
def _text(
value: Any, field: str, *, maximum: int,
allow_empty: bool = True, strip: bool = False,
) -> str:
if not isinstance(value, str) or "\x00" in value:
raise ValueError(f"Ollama {field} must be a string")
result = value.strip() if strip else value
size = len(result.encode("utf-8"))
if size > maximum or (not allow_empty and not result):
raise ValueError(f"Ollama {field} is invalid or too large")
return result
@classmethod
def _validate_origin(cls, value: str, *, direct: bool) -> str:
parts = urllib.parse.urlsplit(value)
if (parts.scheme not in ({"http"} if direct else {"http", "https"})
or not parts.hostname or parts.username is not None
or parts.password is not None or parts.query or parts.fragment
or parts.path not in {"", "/"}):
raise ValueError("Ollama endpoint must be an origin only")
try:
port = parts.port
except ValueError as error:
raise ValueError("Ollama endpoint has an invalid port") from error
if direct:
if parts.hostname.lower() not in cls._LOOPBACK or port != 11434:
raise ValueError(
"direct Ollama endpoint must be loopback port 11434")
if parts.hostname.lower() == "localhost":
try:
addresses = socket.getaddrinfo(
"localhost", 11434, type=socket.SOCK_STREAM)
except OSError as error:
raise ValueError("localhost did not resolve") from error
if not addresses or any(
not ipaddress.ip_address(item[4][0]).is_loopback
for item in addresses
):
raise ValueError("localhost must resolve only to loopback")
default_port = 80 if parts.scheme == "http" else 443
resolved_port = port or default_port
host = parts.hostname.lower()
rendered_host = f"[{host}]" if ":" in host else host
rendered_port = "" if resolved_port == default_port else f":{resolved_port}"
return f"{parts.scheme}://{rendered_host}{rendered_port}"
@classmethod
def _profiles(cls) -> dict[str, str]:
raw = os.environ.get("COMFY_SECURE_OLLAMA_PROFILES", "{}")
try:
value = json.loads(raw)
except json.JSONDecodeError as error:
raise RuntimeError(
"COMFY_SECURE_OLLAMA_PROFILES is invalid JSON") from error
if not isinstance(value, dict) or len(value) > 64:
raise RuntimeError("Ollama profile configuration must be an object")
result = {}
for name, origin in value.items():
if (not isinstance(name, str) or not cls._PROFILE.fullmatch(name)
or not isinstance(origin, str)):
raise RuntimeError("Ollama profile configuration is invalid")
result[name] = cls._validate_origin(origin, direct=False)
return result
@classmethod
def _origin(cls, endpoint: Any) -> str:
endpoint = cls._text(
endpoint, "endpoint", maximum=2048,
allow_empty=False, strip=True)
if endpoint.startswith("ollama://"):
name = endpoint.removeprefix("ollama://")
if not cls._PROFILE.fullmatch(name):
raise ValueError("Ollama profile name is invalid")
origin = cls._profiles().get(name)
if origin is None:
raise ValueError(f"Ollama profile {name!r} is not configured")
return origin
return cls._validate_origin(endpoint, direct=True)
@staticmethod
def _json_body(value: dict[str, Any]) -> bytes:
try:
body = json.dumps(
value, ensure_ascii=False, separators=(",", ":"),
).encode("utf-8")
except (TypeError, ValueError) as error:
raise ValueError("Ollama request is not JSON-safe") from error
if len(body) > InProcessOllama._MAX_REQUEST_BYTES:
raise ValueError("Ollama request exceeds the size limit")
return body
@classmethod
def _format(cls, value: Any) -> str | dict[str, Any]:
if isinstance(value, str):
if value not in {"", "json"}:
raise ValueError("Ollama format must be text, json, or a schema")
return value
if not isinstance(value, dict):
raise TypeError("Ollama format must be text, json, or a schema")
entries = 0
def validate(item: Any, depth: int) -> None:
nonlocal entries
if depth > 16 or entries > 4096:
raise ValueError("Ollama response schema exceeds its bounds")
entries += 1
if item is None or isinstance(item, (str, bool, int)):
return
if isinstance(item, float):
if not math.isfinite(item):
raise ValueError("Ollama response schema must be finite")
return
if isinstance(item, list):
if len(item) > 1024:
raise ValueError("Ollama response schema exceeds its bounds")
for child in item:
validate(child, depth + 1)
return
if isinstance(item, dict):
for key, child in item.items():
if (
not isinstance(key, str) or "\x00" in key
or len(key.encode("utf-8")) > 512
):
raise ValueError("Ollama response schema has an invalid key")
validate(child, depth + 1)
return
raise TypeError("Ollama response schema must be JSON data")
validate(value, 0)
try:
encoded = json.dumps(
value, ensure_ascii=False, allow_nan=False,
separators=(",", ":"),
).encode("utf-8")
except (TypeError, ValueError) as error:
raise ValueError("Ollama response schema is not JSON-safe") from error
if len(encoded) > 64 * 1024:
raise ValueError("Ollama response schema exceeds its size limit")
return json.loads(encoded.decode("utf-8"))
@classmethod
def _json_object(
cls, value: Any, field: str, *, maximum: int = 64 * 1024,
) -> dict[str, Any]:
if not isinstance(value, dict):
raise TypeError(f"Ollama {field} must be a JSON object")
entries = 0
def validate(item: Any, depth: int) -> None:
nonlocal entries
entries += 1
if depth > 16 or entries > 4096:
raise ValueError(f"Ollama {field} exceeds its bounds")
if item is None or isinstance(item, (str, bool, int)):
return
if isinstance(item, float):
if not math.isfinite(item):
raise ValueError(f"Ollama {field} must be finite")
return
if isinstance(item, list):
if len(item) > 1024:
raise ValueError(f"Ollama {field} exceeds its bounds")
for child in item:
validate(child, depth + 1)
return
if isinstance(item, dict):
for key, child in item.items():
if (
not isinstance(key, str) or "\x00" in key
or len(key.encode("utf-8")) > 512
):
raise ValueError(f"Ollama {field} has an invalid key")
validate(child, depth + 1)
return
raise TypeError(f"Ollama {field} must contain JSON data")
validate(value, 0)
try:
encoded = json.dumps(
value, ensure_ascii=False, allow_nan=False,
separators=(",", ":"),
).encode("utf-8")
except (TypeError, ValueError) as error:
raise ValueError(f"Ollama {field} is not JSON-safe") from error
if len(encoded) > maximum:
raise ValueError(f"Ollama {field} exceeds its size limit")
return json.loads(encoded.decode("utf-8"))
@classmethod
def _tool_name(cls, value: Any) -> str:
if not isinstance(value, str) or not cls._TOOL_NAME.fullmatch(value):
raise ValueError("Ollama tool name is invalid")
return value
@classmethod
def _tool_calls(cls, value: Any) -> list[dict[str, Any]]:
if value is None:
return []
if not isinstance(value, list) or len(value) > 32:
raise ValueError("Ollama tool calls must be a bounded list")
result = []
for call in value:
if not isinstance(call, dict):
raise ValueError("Ollama tool call has an invalid shape")
function = call.get("function")
if (set(call) != {"function"} or not isinstance(function, dict)
or set(function) != {"name", "arguments"}):
raise ValueError("Ollama tool call has an invalid shape")
result.append({
"name": cls._tool_name(function["name"]),
"arguments": cls._json_object(
function["arguments"], "tool arguments"),
})
return result
@classmethod
def _tools(cls, value: Any) -> list[dict[str, Any]] | None:
if value is None:
return None
if not isinstance(value, list) or not 1 <= len(value) <= 32:
raise ValueError("Ollama tools must contain 1 to 32 entries")
result = []
for tool in value:
if not isinstance(tool, dict) or set(tool) != {
"name", "description", "parameters",
}:
raise ValueError("Ollama tool has an invalid shape")
parameters = cls._json_object(
tool["parameters"], "tool parameters")
if parameters.get("type") != "object":
raise ValueError("Ollama tool parameters must describe an object")
result.append({
"type": "function",
"function": {
"name": cls._tool_name(tool["name"]),
"description": cls._text(
tool["description"], "tool description",
maximum=4096),
"parameters": parameters,
},
})
return result
@staticmethod
def _timeout(value: Any) -> float:
if isinstance(value, bool) or type(value) not in {int, float}:
raise TypeError("Ollama timeout_seconds must be numeric")
result = float(value)
if not math.isfinite(result) or not 1.0 <= result <= 600.0:
raise ValueError("Ollama timeout_seconds must be in [1, 600]")
return result
@classmethod
def _request_json(
cls, origin: str, path: str, payload: dict[str, Any] | None,
timeout: float,
) -> dict[str, Any]:
if path not in {"/api/tags", "/api/generate", "/api/chat"}:
raise ValueError("Ollama request path is not permitted")
data = None if payload is None else cls._json_body(payload)
request = urllib.request.Request(
origin + path,
data=data,
headers={
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "ComfyUI-Secure-Nodes/2",
},
method="GET" if data is None else "POST",
)
opener = urllib.request.build_opener(_NoRedirect())
try:
with opener.open(request, timeout=timeout) as response:
final = urllib.parse.urlsplit(response.geturl())
expected = urllib.parse.urlsplit(origin + path)
if (final.scheme, final.hostname, final.port, final.path) != (
expected.scheme, expected.hostname, expected.port,
expected.path,
) or final.query or final.fragment:
raise RuntimeError("Ollama redirected outside its fixed origin")
content_type = response.headers.get_content_type().lower()
if content_type not in {"application/json", "text/json"}:
raise RuntimeError("Ollama returned a non-JSON response")
declared = response.headers.get("Content-Length")
if declared is not None:
try:
declared_size = int(declared)
except ValueError as error:
raise RuntimeError(
"Ollama returned an invalid response size") from error
if not 0 <= declared_size <= cls._MAX_RESPONSE_BYTES:
raise RuntimeError("Ollama response exceeds the size limit")
body = response.read(cls._MAX_RESPONSE_BYTES + 1)
except urllib.error.HTTPError as error:
raise RuntimeError(f"Ollama request failed with HTTP {error.code}") from error
if len(body) > cls._MAX_RESPONSE_BYTES:
raise RuntimeError("Ollama response exceeds the size limit")
try:
value = json.loads(body.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError) as error:
raise RuntimeError("Ollama returned invalid JSON") from error
if not isinstance(value, dict):
raise RuntimeError("Ollama returned an invalid response object")
return value
@classmethod
def _options(cls, value: Any) -> dict[str, Any] | None:
if value is None:
return None
if not isinstance(value, dict) or not set(value) <= (
set(cls._OPTIONS) | cls._BOOLEAN_OPTIONS | {"stop"}
):
raise ValueError("Ollama options contain an unsupported field")
result: dict[str, Any] = {}
for name, item in value.items():
if name == "stop":
result[name] = cls._text(
item, "stop option", maximum=4096, strip=False)
continue
if name in cls._BOOLEAN_OPTIONS:
if type(item) is not bool:
raise TypeError(f"Ollama option {name} must be a boolean")
result[name] = item
continue
kind, minimum, maximum = cls._OPTIONS[name]
if kind is int:
if isinstance(item, bool) or not isinstance(item, int):
raise TypeError(f"Ollama option {name} must be an integer")
normalized: int | float = item
else:
if isinstance(item, bool) or type(item) not in (int, float):
raise TypeError(f"Ollama option {name} must be numeric")
normalized = float(item)
if not math.isfinite(normalized):
raise ValueError(f"Ollama option {name} must be finite")
if not minimum <= normalized <= maximum:
raise ValueError(f"Ollama option {name} is out of range")
result[name] = normalized
return result
@classmethod
def _context(cls, value: Any, *, required: bool = False) -> list[int] | None:
if value is None and not required:
return None
if (not isinstance(value, list)
or len(value) > cls._MAX_CONTEXT_TOKENS):
raise ValueError("Ollama context must be a bounded integer list")
result = []
for token in value:
if (isinstance(token, bool) or not isinstance(token, int)
or not 0 <= token <= 2**31 - 1):
raise ValueError("Ollama context contains an invalid token")
result.append(token)
return result
@classmethod
def _keep_alive(cls, value: Any, unit: Any) -> str:
if isinstance(value, bool) or not isinstance(value, int) or not -1 <= value <= 120:
raise ValueError("Ollama keep_alive must be in [-1, 120]")
if unit not in {"minutes", "hours"}:
raise ValueError("Ollama keep_alive_unit must be minutes or hours")
return f"{value}{'m' if unit == 'minutes' else 'h'}"
@classmethod
async def _images(cls, value: Any) -> list[str] | None:
if value is None:
return None
from ._sdk import ImageRef, current_runtime
import torch
from PIL import Image
if not isinstance(value, ImageRef):
raise TypeError("Ollama images must be an IMAGE ref")
pixels = await current_runtime().refs.resolve(value)
if (not isinstance(pixels, torch.Tensor) or pixels.ndim != 4
or not 1 <= int(pixels.shape[0]) <= cls._MAX_IMAGES
or int(pixels.shape[-1]) < 3):
raise ValueError("Ollama images require a bounded BHWC RGB batch")
batch, height, width = map(int, pixels.shape[:3])
if (height < 1 or width < 1
or batch * height * width > cls._MAX_IMAGE_PIXELS):
raise ValueError("Ollama image dimensions exceed the limit")
if not torch.isfinite(pixels).all():
raise ValueError("Ollama images must contain finite pixels")
rgb = (pixels[..., :3].detach().to("cpu").clamp(0.0, 1.0) * 255.0)
rgb = rgb.to(torch.uint8).numpy()
result = []
total = 0
for frame in rgb:
buffer = io.BytesIO()
Image.fromarray(frame, mode="RGB").save(buffer, format="PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
total += len(encoded)
if total > cls._MAX_IMAGE_BYTES:
raise ValueError("Ollama encoded images exceed the size limit")
result.append(encoded)
return result
@classmethod
def _response_text(cls, value: Any, field: str) -> str:
return cls._text(
value, f"response {field}", maximum=cls._MAX_TEXT_BYTES)
async def list_models(self, endpoint: str) -> list[str]:
origin = self._origin(endpoint)
value = await asyncio.to_thread(
self._request_json, origin, "/api/tags", None, 10.0)
raw_models = value.get("models")
if not isinstance(raw_models, list):
raise RuntimeError("Ollama model list is missing")
models = []
for item in raw_models[:512]:
if not isinstance(item, dict):
continue
name = item.get("name", item.get("model"))
try:
models.append(self._text(
name, "model name", maximum=512,
allow_empty=False, strip=True))
except ValueError:
continue
return list(dict.fromkeys(models))
async def generate(
self, endpoint: str, model: str, system: str, prompt: str,
images=None, context: list[int] | None = None, think: bool = False,
options: dict[str, Any] | None = None, keep_alive: int = 5,
keep_alive_unit: str = "minutes", format: str | dict[str, Any] = "",
timeout_seconds: float = 600.0,
) -> dict[str, Any]:
if not isinstance(think, bool):
raise TypeError("Ollama think must be a bool")
format_value = self._format(format)
timeout_value = self._timeout(timeout_seconds)
payload: dict[str, Any] = {
"model": self._text(
model, "model", maximum=512, allow_empty=False, strip=True),
"system": self._text(system, "system", maximum=1_048_576),
"prompt": self._text(prompt, "prompt", maximum=4_194_304),
"stream": False,
"think": think,
"keep_alive": self._keep_alive(keep_alive, keep_alive_unit),
"format": format_value,
}
image_data = await self._images(images)
context_data = self._context(context)
option_data = self._options(options)
if image_data is not None:
payload["images"] = image_data
if context_data is not None:
payload["context"] = context_data
if option_data is not None:
payload["options"] = option_data
origin = self._origin(endpoint)
response = await asyncio.to_thread(
self._request_json, origin, "/api/generate", payload, timeout_value)
result: dict[str, Any] = {
"response": self._response_text(response.get("response"), "text"),
"context": self._context(response.get("context"), required=True),
}
thinking = response.get("thinking")
if think and thinking is not None:
result["thinking"] = self._response_text(thinking, "thinking")
return result
async def chat(
self, endpoint: str, model: str,
messages: list[dict[str, Any]], images=None, think: bool = False,
options: dict[str, Any] | None = None, keep_alive: int = 5,
keep_alive_unit: str = "minutes", format: str | dict[str, Any] = "",
timeout_seconds: float = 600.0,
tools: list[dict[str, Any]] | None = None,
) -> dict[str, Any]:
if not isinstance(think, bool):
raise TypeError("Ollama think must be a bool")
format_value = self._format(format)
timeout_value = self._timeout(timeout_seconds)
if not isinstance(messages, list) or not 1 <= len(messages) <= 256:
raise ValueError("Ollama messages must contain 1 to 256 entries")
projected = []
total = 0
for message in messages:
if not isinstance(message, dict):
raise ValueError("Ollama message has an invalid shape")
role = message.get("role")
allowed = {"role", "content"}
if role == "assistant":
allowed |= {"thinking", "tool_calls"}
elif role == "tool":
allowed |= {"tool_name"}
elif role not in {"system", "user"}:
raise ValueError("Ollama message has an invalid shape")
if not {"role", "content"}.issubset(message) or not set(message) <= allowed:
raise ValueError("Ollama message has an invalid shape")
if role == "tool" and "tool_name" not in message:
raise ValueError("Ollama tool message requires tool_name")
content = self._text(
message["content"], "message content", maximum=1_048_576)
total += len(content.encode("utf-8"))
if total > 4_194_304:
raise ValueError("Ollama message history exceeds the size limit")
item: dict[str, Any] = {"role": role, "content": content}
if role == "assistant":
if "thinking" in message:
item["thinking"] = self._text(
message["thinking"], "message thinking",
maximum=1_048_576)
if "tool_calls" in message:
calls = message["tool_calls"]
if not isinstance(calls, list) or len(calls) > 32:
raise ValueError(
"Ollama message tool calls must be a bounded list")
native_calls = []
for call in calls:
if not isinstance(call, dict) or set(call) != {
"name", "arguments",
}:
raise ValueError(
"Ollama message tool call has an invalid shape")
native_calls.append({"function": {
"name": self._tool_name(call["name"]),
"arguments": self._json_object(
call["arguments"], "tool arguments"),
}})
item["tool_calls"] = native_calls
elif role == "tool":
item["tool_name"] = self._tool_name(message["tool_name"])
projected.append(item)
image_data = await self._images(images)
if image_data is not None:
user = next((item for item in reversed(projected)
if item["role"] == "user"), None)
if user is None:
raise ValueError("Ollama images require a user message")
user["images"] = image_data
payload: dict[str, Any] = {
"model": self._text(
model, "model", maximum=512, allow_empty=False, strip=True),
"messages": projected,
"stream": False,
"think": think,
"keep_alive": self._keep_alive(keep_alive, keep_alive_unit),
"format": format_value,
}
option_data = self._options(options)
if option_data is not None:
payload["options"] = option_data
tool_data = self._tools(tools)
if tool_data is not None:
payload["tools"] = tool_data
origin = self._origin(endpoint)
response = await asyncio.to_thread(
self._request_json, origin, "/api/chat", payload, timeout_value)
message = response.get("message")
if not isinstance(message, dict):
raise RuntimeError("Ollama chat response has no message")
result = {
"response": self._response_text(message.get("content"), "text"),
}
thinking = message.get("thinking")
if think and thinking is not None:
result["thinking"] = self._response_text(thinking, "thinking")
if message.get("tool_calls") is not None:
result["tool_calls"] = self._tool_calls(message["tool_calls"])
return result
+11558 -216
View File
File diff suppressed because it is too large Load Diff
+74
View File
@@ -2,8 +2,12 @@
custom nodes import from ``comfy_api.latest.sdk`` (or a pinned version), never
from ``_sdk`` directly."""
from ._sdk import ( # noqa: F401
AnimaDomain,
AssetRef,
AssetsDomain,
AudioRef,
BackgroundRemovalModelRef,
BrushNetRef,
ClipRef,
ClipSegRef,
ClipVisionOutputRef,
@@ -12,26 +16,59 @@ from ._sdk import ( # noqa: F401
ControlNetRef,
ControlNetWeightsRef,
Context,
CivitaiDomain,
ClassifierScoresRef,
ClosureRef,
ClosuresDomain,
ExecutionDomain,
ExecutionBackend,
ExecutionPlan,
GligenRef,
GraphDomain,
GuiderRef,
HuggingFaceWeight,
ImageClassifierRef,
InpaintModelRef,
ImagePreprocessorRef,
ImageRef,
IpAdapterEmbedsRef,
IpAdapterRef,
InteractionDomain,
IntegrationsDomain,
InterpolationStatesRef,
LatentOperationRef,
LatentRef,
LlmDomain,
LlamaCppDomain,
LlamaCppModelRef,
MaskRef,
MattingModelRef,
ModelRef,
ModelsDomain,
OnnxDetectorRef,
OllamaDomain,
ObjectDetectorRef,
OpNotSupported,
OutputDomain,
PowerPaintRef,
Ref,
RefResolver,
SamModelRef,
SamplerRef,
SigmasRef,
SemanticSegmentationRef,
StyleModelRef,
SystemDomain,
TensorRef,
TimestepKeyframeRef,
TransparentVaeDecoderRef,
UpscaleModelRef,
ValueRef,
VaeRef,
VideoRef,
VqaModelRef,
WanVideoDomain,
WebSearchDomain,
WeightDiffCursorRef,
current_context,
providers,
@@ -40,18 +77,41 @@ from ._sdk import current_context as ctx # `sdk.ctx()` -> active Context
__all__ = [
"Ref",
"ClosureRef",
"ClosuresDomain",
"AnimaDomain",
"TensorRef",
"UpscaleModelRef",
"ValueRef",
"ImageRef",
"ImageClassifierRef",
"ClassifierScoresRef",
"InpaintModelRef",
"ImagePreprocessorRef",
"IpAdapterEmbedsRef",
"IpAdapterRef",
"InteractionDomain",
"InterpolationStatesRef",
"MaskRef",
"MattingModelRef",
"LatentOperationRef",
"LatentRef",
"LlmDomain",
"LlamaCppDomain",
"LlamaCppModelRef",
"CondRef",
"GligenRef",
"GuiderRef",
"SamplerRef",
"SigmasRef",
"SamModelRef",
"SemanticSegmentationRef",
"HuggingFaceWeight",
"ModelRef",
"ModelsDomain",
"OnnxDetectorRef",
"ObjectDetectorRef",
"PowerPaintRef",
"ClipRef",
"ClipSegRef",
"ClipVisionRef",
@@ -59,13 +119,27 @@ __all__ = [
"ControlNetRef",
"ControlNetWeightsRef",
"StyleModelRef",
"SystemDomain",
"VaeRef",
"AudioRef",
"BackgroundRemovalModelRef",
"BrushNetRef",
"VideoRef",
"VqaModelRef",
"TimestepKeyframeRef",
"TransparentVaeDecoderRef",
"WeightDiffCursorRef",
"AssetRef",
"AssetsDomain",
"OutputDomain",
"GraphDomain",
"Context",
"CivitaiDomain",
"OllamaDomain",
"WanVideoDomain",
"WebSearchDomain",
"IntegrationsDomain",
"ExecutionDomain",
"ctx",
"current_context",
"RefResolver",
File diff suppressed because it is too large Load Diff
+9
View File
@@ -38,6 +38,15 @@ def validate_node_input(
if isinstance(received_type, list) and input_type == IO.Combo.io_type:
return True
# Combo sockets use their option lists as types. Independently extended
# combo lists are compatible when they share at least one value, just as
# non-strict union-string types are compatible when they overlap. Strict
# validation retains the corresponding subset rule.
if isinstance(received_type, list) and isinstance(input_type, list):
if strict:
return all(value in input_type for value in received_type)
return any(value in input_type for value in received_type)
# Not equal, and not strings
if not isinstance(received_type, str) or not isinstance(input_type, str):
return False
+101
View File
@@ -380,6 +380,70 @@ class AnimaLLLiteApply:
return (model_patched,)
def _spatial_crop_bounds(region, source_width, source_height, width, height):
left, top, right, bottom = region
x1 = max(0, min(width - 1, left * width // source_width))
y1 = max(0, min(height - 1, top * height // source_height))
x2 = max(x1 + 1, min(
width, (right * width + source_width - 1) // source_width))
y2 = max(y1 + 1, min(
height, (bottom * height + source_height - 1) // source_height))
return x1, y1, x2, y2
def _resize_spatial_tensor(value, target_width, target_height):
"""Resize the last two axes while preserving arbitrary leading axes."""
original_dtype = value.dtype
leading = value.shape[:-2]
flat = value.reshape(-1, 1, value.shape[-2], value.shape[-1]).float()
resized = torch.nn.functional.interpolate(
flat, size=(target_height, target_width), mode="bilinear",
align_corners=False,
).reshape(*leading, target_height, target_width)
if original_dtype == torch.bool:
return resized >= 0.5
return resized.to(dtype=original_dtype)
def _crop_bhwc_regions(
value, regions, source_width, source_height, target_width, target_height,
):
if value is None:
return None
if value.ndim != 4:
raise ValueError("model spatial images must use BHWC layout")
height, width = value.shape[1:3]
cropped = []
for region in regions:
x1, y1, x2, y2 = _spatial_crop_bounds(
region, source_width, source_height, width, height)
tile = value[:, y1:y2, x1:x2, :].movedim(-1, 1)
tile = torch.nn.functional.interpolate(
tile, size=(target_height, target_width), mode="bilinear",
align_corners=False,
).movedim(1, -1)
cropped.append(tile)
return torch.cat(cropped, dim=0)
def _crop_mask_regions(
value, regions, source_width, source_height, target_width, target_height,
):
if value is None:
return None
if value.ndim < 3:
raise ValueError("model spatial masks must have at least three axes")
height, width = value.shape[-2:]
cropped = []
for region in regions:
x1, y1, x2, y2 = _spatial_crop_bounds(
region, source_width, source_height, width, height)
tile = value[..., y1:y2, x1:x2]
cropped.append(_resize_spatial_tensor(
tile, target_width, target_height))
return torch.cat(cropped, dim=0)
class DiffSynthCnetPatch:
def __init__(self, model_patch, vae, image, strength, mask=None):
self.model_patch = model_patch
@@ -426,6 +490,23 @@ class DiffSynthCnetPatch:
def models(self):
return [self.model_patch]
def spatial_crop_inputs(
self, *, regions, source_width, source_height,
target_width, target_height,
):
"""Return an independent patch whose guidance matches tile regions."""
return type(self)(
self.model_patch,
self.vae,
_crop_bhwc_regions(
self.image, regions, source_width, source_height,
target_width, target_height),
self.strength,
mask=_crop_mask_regions(
self.mask, regions, source_width, source_height,
target_width, target_height),
)
class ZImageControlPatch:
def __init__(self, model_patch, vae, image, strength, inpaint_image=None, mask=None):
self.model_patch = model_patch
@@ -549,6 +630,26 @@ class ZImageControlPatch:
def models(self):
return [self.model_patch]
def spatial_crop_inputs(
self, *, regions, source_width, source_height,
target_width, target_height,
):
"""Return an independent patch whose guidance matches tile regions."""
return type(self)(
self.model_patch,
self.vae,
_crop_bhwc_regions(
self.image, regions, source_width, source_height,
target_width, target_height),
self.strength,
inpaint_image=_crop_bhwc_regions(
self.inpaint_image, regions, source_width, source_height,
target_width, target_height),
mask=_crop_mask_regions(
self.mask, regions, source_width, source_height,
target_width, target_height),
)
class QwenImageDiffsynthControlnet:
@classmethod
def INPUT_TYPES(s):
+35 -33
View File
@@ -9,6 +9,39 @@ from torchvision.transforms import ToPILImage, ToTensor
from PIL import ImageDraw, ImageFont
def detect(model, image, threshold, class_name, max_detections):
B, H, W, C = image.shape
comfy.model_management.load_model_gpu(model)
results = []
for i in range(0, B, 32):
batch = image[i:i + 32]
image_in = comfy.utils.common_upscale(batch.movedim(-1, 1), 640, 640, "bilinear", crop="disabled")
results.extend(model.model.diffusion_model(image_in, (W, H)))
all_bbox_dicts = []
for det in results:
keep = det['scores'] > threshold
boxes = det['boxes'][keep].cpu()
labels = det['labels'][keep].cpu()
scores = det['scores'][keep].cpu()
bbox_dicts = [
{
"x": float(box[0]),
"y": float(box[1]),
"width": float(box[2] - box[0]),
"height": float(box[3] - box[1]),
"label": COCO_CLASSES[int(label)],
"score": float(score),
}
for box, label, score in zip(boxes, labels, scores)
if class_name == "all" or COCO_CLASSES[int(label)] == class_name
]
bbox_dicts.sort(key=lambda d: d["score"], reverse=True)
all_bbox_dicts.append(bbox_dicts[:max_detections])
return all_bbox_dicts
class RTDETR_detect(io.ComfyNode):
@classmethod
def define_schema(cls):
@@ -30,39 +63,8 @@ class RTDETR_detect(io.ComfyNode):
@classmethod
def execute(cls, model, image, threshold, class_name, max_detections) -> io.NodeOutput:
B, H, W, C = image.shape
comfy.model_management.load_model_gpu(model)
results = []
for i in range(0, B, 32):
batch = image[i:i + 32]
image_in = comfy.utils.common_upscale(batch.movedim(-1, 1), 640, 640, "bilinear", crop="disabled")
results.extend(model.model.diffusion_model(image_in, (W, H)))
all_bbox_dicts = []
for det in results:
keep = det['scores'] > threshold
boxes = det['boxes'][keep].cpu()
labels = det['labels'][keep].cpu()
scores = det['scores'][keep].cpu()
bbox_dicts = [
{
"x": float(box[0]),
"y": float(box[1]),
"width": float(box[2] - box[0]),
"height": float(box[3] - box[1]),
"label": COCO_CLASSES[int(label)],
"score": float(score)
}
for box, label, score in zip(boxes, labels, scores)
if class_name == "all" or COCO_CLASSES[int(label)] == class_name
]
bbox_dicts.sort(key=lambda d: d["score"], reverse=True)
all_bbox_dicts.append(bbox_dicts[:max_detections])
return io.NodeOutput(all_bbox_dicts)
return io.NodeOutput(detect(
model, image, threshold, class_name, max_detections))
class DrawBBoxes(io.ComfyNode):
+155 -8
View File
@@ -154,7 +154,57 @@ class CacheSet:
}
return result
SENSITIVE_EXTRA_DATA_KEYS = ("auth_token_comfy_org", "api_key_comfy_org")
SENSITIVE_EXTRA_DATA_KEYS = (
"auth_token_comfy_org",
"api_key_comfy_org",
"comfy_secure_tenant_id",
)
async def _notify_execution_backend_lifecycle(
event: str,
prompt_id: str,
extra_data: dict,
) -> None:
from comfy_api.latest import _sdk as _comfy_sdk
backend = _comfy_sdk.providers.execution_backend
hook = getattr(backend, f"on_prompt_{event}", None)
if hook is None and event == "abort":
hook = getattr(backend, "on_prompt_end", None)
if hook is not None:
await hook(prompt_id, extra_data)
async def _shutdown_execution_backend() -> None:
from comfy_api.latest import _sdk as _comfy_sdk
hook = getattr(_comfy_sdk.providers.execution_backend, "shutdown", None)
if hook is not None:
await hook()
async def _maintain_execution_backend() -> None:
from comfy_api.latest import _sdk as _comfy_sdk
hook = getattr(_comfy_sdk.providers.execution_backend, "maintenance", None)
if hook is not None:
await hook()
def _execution_backend_maintenance_interval() -> float | None:
from comfy_api.latest import _sdk as _comfy_sdk
value = getattr(
_comfy_sdk.providers.execution_backend,
"maintenance_interval_seconds",
None,
)
if value is None:
return None
if not isinstance(value, (int, float)) or value <= 0:
raise ValueError("execution backend maintenance interval must be positive")
return float(value)
def get_input_data(inputs, class_def, unique_id, execution_list=None, dynprompt=None, extra_data={}):
is_v3 = issubclass(class_def, _ComfyNodeInternal)
@@ -301,6 +351,8 @@ async def _async_map_node_over_list(prompt_id, unique_id, obj, input_data_all, f
# registry manifest can narrow the set further. Nodes that
# declare nothing (the overwhelming majority) get nothing.
_sdk_perms = getattr(type_obj, "SDK_PERMISSIONS", ()) or ()
_sdk_required_weights = getattr(
type_obj, "SDK_REQUIRED_WEIGHTS", ()) or ()
_sdk_refs_mode = bool(getattr(type_obj, "SDK_REFS", False))
_sdk_plan = _comfy_sdk.ExecutionPlan(
prompt_id=str(prompt_id),
@@ -311,9 +363,12 @@ async def _async_map_node_over_list(prompt_id, unique_id, obj, input_data_all, f
input_mode="refs" if _sdk_refs_mode else "values",
method=func,
permissions=tuple(_sdk_perms),
required_weights=tuple(_sdk_required_weights),
prompt=getattr(class_clone.hidden, "prompt", None),
extra_pnginfo=getattr(
class_clone.hidden, "extra_pnginfo", None),
dynamic_prompt=getattr(
class_clone.hidden, "dynprompt", None),
)
_sdk_refs = _comfy_sdk.providers.ref_resolver_factory()
_sdk_runtime = _comfy_sdk.bind_runtime(
@@ -354,6 +409,8 @@ async def _async_map_node_over_list(prompt_id, unique_id, obj, input_data_all, f
method=_legacy_method,
permissions=tuple(
getattr(type_obj, "SDK_PERMISSIONS", ()) or ()),
required_weights=tuple(getattr(
type_obj, "SDK_REQUIRED_WEIGHTS", ()) or ()),
)
_sdk_refs = _comfy_sdk.providers.ref_resolver_factory()
_sdk_runtime = _comfy_sdk.bind_runtime(
@@ -765,6 +822,10 @@ class PromptExecutor:
self.cache_type = cache_type
self.server = server
self.prompt_model_tracker = comfy.model_patcher.PromptModelTracker()
self._runner = asyncio.Runner()
self._active_loop = None
self._active_task = None
self._active_lock = threading.RLock()
self.reset()
def reset(self):
@@ -823,7 +884,71 @@ class PromptExecutor:
_cache_logger.warning(f"Cache provider {provider.__class__.__name__} error on {event}: {e}")
def execute(self, prompt, prompt_id, extra_data={}, execute_outputs=[]):
asyncio.run(self.execute_async(prompt, prompt_id, extra_data, execute_outputs))
self._runner.run(
self._run_owned_prompt(
prompt,
prompt_id,
extra_data,
execute_outputs,
)
)
async def _run_owned_prompt(
self,
prompt,
prompt_id,
extra_data,
execute_outputs,
):
task = asyncio.current_task()
loop = asyncio.get_running_loop()
with self._active_lock:
self._active_loop = loop
self._active_task = task
try:
return await self.execute_async(
prompt,
prompt_id,
extra_data,
execute_outputs,
)
finally:
with self._active_lock:
if self._active_task is task:
self._active_loop = None
self._active_task = None
def request_shutdown(self):
nodes.interrupt_processing(True)
with self._active_lock:
loop = self._active_loop
task = self._active_task
if loop is None or task is None or task.done():
return
try:
loop.call_soon_threadsafe(task.cancel)
except RuntimeError:
pass
def close(self):
runner = self._runner
if runner is None:
return
self._runner = None
try:
runner.run(_shutdown_execution_backend())
finally:
runner.close()
def execution_backend_maintenance_interval(self) -> float | None:
return _execution_backend_maintenance_interval()
def maintain_execution_backend(self) -> None:
if self._runner is not None:
try:
self._runner.run(_maintain_execution_backend())
except Exception:
logging.exception("execution backend maintenance failed")
async def execute_async(self, prompt, prompt_id, extra_data={}, execute_outputs=[]):
set_preview_method(extra_data.get("preview_method"))
@@ -840,12 +965,17 @@ class PromptExecutor:
self.add_message("execution_start", { "prompt_id": prompt_id}, broadcast=False)
self._notify_prompt_lifecycle("start", prompt_id)
ram_headroom = int(self.cache_args["ram"] * (1024 ** 3))
ram_inactive_headroom = int(self.cache_args["ram_inactive"] * (1024 ** 3))
ram_release_callback = self.caches.outputs.ram_release if self.cache_type == CacheType.RAM_PRESSURE else None
comfy.memory_management.set_ram_cache_release_state(ram_release_callback, ram_headroom)
backend_reusable = False
try:
await _notify_execution_backend_lifecycle(
"start",
prompt_id,
extra_data,
)
ram_headroom = int(self.cache_args["ram"] * (1024 ** 3))
ram_inactive_headroom = int(self.cache_args["ram_inactive"] * (1024 ** 3))
ram_release_callback = self.caches.outputs.ram_release if self.cache_type == CacheType.RAM_PRESSURE else None
comfy.memory_management.set_ram_cache_release_state(ram_release_callback, ram_headroom)
with torch.inference_mode():
dynamic_prompt = DynamicPrompt(prompt)
reset_progress_state(prompt_id, dynamic_prompt)
@@ -874,12 +1004,14 @@ class PromptExecutor:
executed = set()
execution_list = ExecutionList(dynamic_prompt, self.caches.outputs, self.prompt_model_tracker.add)
current_outputs = self.caches.outputs.all_node_ids()
execution_failed = False
for node_id in list(execute_outputs):
execution_list.add_node(node_id)
while not execution_list.is_empty():
node_id, error, ex = await execution_list.stage_node_execution()
if error is not None:
execution_failed = True
self.handle_execution_error(prompt_id, dynamic_prompt.original_prompt, current_outputs, executed, error, ex)
break
@@ -887,6 +1019,7 @@ class PromptExecutor:
result, error, ex = await execute(self.server, dynamic_prompt, self.caches, node_id, extra_data, executed, prompt_id, execution_list, pending_subgraph_results, pending_async_nodes, ui_node_outputs)
self.success = result != ExecutionResult.FAILURE
if result == ExecutionResult.FAILURE:
execution_failed = True
self.handle_execution_error(prompt_id, dynamic_prompt.original_prompt, current_outputs, executed, error, ex)
break
elif result == ExecutionResult.PENDING:
@@ -933,12 +1066,26 @@ class PromptExecutor:
self.server.last_node_id = None
if comfy.model_management.DISABLE_SMART_MEMORY:
comfy.model_management.unload_all_models()
backend_reusable = not execution_failed
finally:
if self.cache_type == CacheType.RAM_PRESSURE:
detail("RAM cache evictions: prompt=%s active=%s full=%s", prompt_id, self.caches.outputs.active_evictions, self.caches.outputs.full_evictions)
comfy.memory_management.set_ram_cache_release_state(None, 0)
self.prompt_model_tracker.end()
self._notify_prompt_lifecycle("end", prompt_id)
try:
try:
await _notify_execution_backend_lifecycle(
"end" if backend_reusable else "abort",
prompt_id,
extra_data,
)
except Exception:
self.success = False
logging.exception(
"execution backend prompt cleanup failed"
)
finally:
self._notify_prompt_lifecycle("end", prompt_id)
async def validate_inputs(prompt_id, prompt, item, validated, visiting=None):
+5
View File
@@ -66,6 +66,11 @@ folder_names_and_paths["optical_flow"] = ([os.path.join(models_dir, "optical_flo
folder_names_and_paths["detection"] = ([os.path.join(models_dir, "detection")], supported_pt_extensions)
folder_names_and_paths["semantic_segmentation"] = ([os.path.join(models_dir, "semantic_segmentation")], supported_pt_extensions)
folder_names_and_paths["sams"] = ([os.path.join(models_dir, "sams")], {".safetensors", ".sft"})
folder_names_and_paths["onnx"] = ([os.path.join(models_dir, "onnx")], {".onnx"})
folder_names_and_paths["inpaint"] = ([os.path.join(models_dir, "inpaint")], supported_pt_extensions | {".patch"})
output_directory = os.path.join(base_path, "output")
temp_directory = os.path.join(base_path, "temp")
input_directory = os.path.join(base_path, "input")
+103 -64
View File
@@ -241,6 +241,10 @@ import asyncio
import threading
import gc
_prompt_worker_shutdown = threading.Event()
_prompt_worker_thread = None
_prompt_executor = None
if 'torch' in sys.modules:
logging.warning("WARNING: Potential Error in code: Torch already imported, torch should never be imported before this point.")
@@ -348,6 +352,7 @@ def _collect_output_absolute_paths(history_result: dict) -> list[str]:
def prompt_worker(q, server_instance):
global _prompt_executor
current_time: float = 0.0
cache_ram = 0
cache_ram_inactive = 0
@@ -368,81 +373,96 @@ def prompt_worker(q, server_instance):
cache_type = execution.CacheType.NONE
e = execution.PromptExecutor(server_instance, cache_type=cache_type, cache_args={ "lru" : args.cache_lru, "ram" : cache_ram, "ram_inactive" : cache_ram_inactive } )
_prompt_executor = e
maintenance_interval = e.execution_backend_maintenance_interval()
last_gc_collect = 0
need_gc = False
gc_collect_interval = 10.0
while True:
timeout = 1000.0
if need_gc:
timeout = max(gc_collect_interval - (current_time - last_gc_collect), 0.0)
try:
while not _prompt_worker_shutdown.is_set():
timeout = 1000.0
if need_gc:
timeout = max(gc_collect_interval - (current_time - last_gc_collect), 0.0)
if maintenance_interval is not None:
timeout = min(timeout, maintenance_interval)
queue_item = q.get(timeout=timeout)
if queue_item is not None:
item, item_id = queue_item
execution_start_time = time.perf_counter()
prompt_id = item[1]
server_instance.last_prompt_id = prompt_id
queue_item = q.get(timeout=timeout)
if queue_item is not None:
item, item_id = queue_item
execution_start_time = time.perf_counter()
prompt_id = item[1]
server_instance.last_prompt_id = prompt_id
sensitive = item[5]
extra_data = item[3].copy()
for k in sensitive:
extra_data[k] = sensitive[k]
sensitive = item[5]
extra_data = item[3].copy()
for k in sensitive:
extra_data[k] = sensitive[k]
asset_seeder.pause()
e.execute(item[2], prompt_id, extra_data, item[4])
asset_seeder.pause()
try:
e.execute(item[2], prompt_id, extra_data, item[4])
except asyncio.CancelledError:
if _prompt_worker_shutdown.is_set():
break
raise
need_gc = True
need_gc = True
remove_sensitive = lambda prompt: prompt[:5] + prompt[6:]
q.task_done(item_id,
e.history_result,
status=execution.PromptQueue.ExecutionStatus(
status_str='success' if e.success else 'error',
completed=e.success,
messages=e.status_messages), process_item=remove_sensitive)
if server_instance.client_id is not None:
server_instance.send_sync("executing", {"node": None, "prompt_id": prompt_id}, server_instance.client_id)
remove_sensitive = lambda prompt: prompt[:5] + prompt[6:]
q.task_done(item_id,
e.history_result,
status=execution.PromptQueue.ExecutionStatus(
status_str='success' if e.success else 'error',
completed=e.success,
messages=e.status_messages), process_item=remove_sensitive)
if server_instance.client_id is not None:
server_instance.send_sync("executing", {"node": None, "prompt_id": prompt_id}, server_instance.client_id)
current_time = time.perf_counter()
execution_time = current_time - execution_start_time
current_time = time.perf_counter()
execution_time = current_time - execution_start_time
# Log Time in a more readable way after 10 minutes
if execution_time > 600:
execution_time = time.strftime("%H:%M:%S", time.gmtime(execution_time))
logging.info(f"Prompt executed in {execution_time}", extra={'color': 'green'})
else:
logging.info("Prompt executed in {:.2f} seconds".format(execution_time), extra={'color': 'green'})
if not asset_seeder.is_disabled():
paths = _collect_output_absolute_paths(e.history_result)
register_output_files(paths, job_id=prompt_id)
flags = q.get_flags()
free_memory = flags.get("free_memory", False)
if flags.get("unload_models", free_memory):
comfy.model_management.unload_all_models()
need_gc = True
last_gc_collect = 0
if free_memory:
e.reset()
need_gc = True
last_gc_collect = 0
if need_gc:
current_time = time.perf_counter()
if (current_time - last_gc_collect) > gc_collect_interval:
gc.collect()
comfy.model_management.soft_empty_cache()
last_gc_collect = current_time
need_gc = False
hook_breaker_ac10a0.restore_functions()
# Log Time in a more readable way after 10 minutes
if execution_time > 600:
execution_time = time.strftime("%H:%M:%S", time.gmtime(execution_time))
logging.info(f"Prompt executed in {execution_time}", extra={'color': 'green'})
else:
logging.info("Prompt executed in {:.2f} seconds".format(execution_time), extra={'color': 'green'})
if not asset_seeder.is_disabled():
asset_seeder.enqueue_enrich(roots=("output",), compute_hashes=args.enable_asset_hashing)
asset_seeder.resume()
paths = _collect_output_absolute_paths(e.history_result)
register_output_files(paths, job_id=prompt_id)
e.maintain_execution_backend()
flags = q.get_flags()
free_memory = flags.get("free_memory", False)
if flags.get("unload_models", free_memory):
comfy.model_management.unload_all_models()
need_gc = True
last_gc_collect = 0
if free_memory:
e.reset()
need_gc = True
last_gc_collect = 0
if need_gc:
current_time = time.perf_counter()
if (current_time - last_gc_collect) > gc_collect_interval:
gc.collect()
comfy.model_management.soft_empty_cache()
last_gc_collect = current_time
need_gc = False
hook_breaker_ac10a0.restore_functions()
if not asset_seeder.is_disabled():
asset_seeder.enqueue_enrich(roots=("output",), compute_hashes=args.enable_asset_hashing)
asset_seeder.resume()
finally:
_prompt_executor = None
e.close()
async def run(server_instance, address='', port=8188, verbose=True, call_on_start=None):
@@ -567,7 +587,14 @@ def start_comfyui(asyncio_loop=None):
prompt_server.add_routes()
hijack_progress(prompt_server)
threading.Thread(target=prompt_worker, daemon=True, args=(prompt_server.prompt_queue, prompt_server,)).start()
global _prompt_worker_thread
_prompt_worker_shutdown.clear()
_prompt_worker_thread = threading.Thread(
target=prompt_worker,
daemon=True,
args=(prompt_server.prompt_queue, prompt_server,),
)
_prompt_worker_thread.start()
if args.quick_test_for_ci:
exit(0)
@@ -615,7 +642,11 @@ if __name__ == "__main__":
"dynamic vram enabled and using native ComfyUI model formats instead. "
"ComfyUI native formats like fp8, int8 and w4a8 will be faster even if they are larger than your memory."
)
event_loop, _, start_all_func = start_comfyui()
def stop_on_sigterm(_signum, _frame):
raise KeyboardInterrupt
signal.signal(signal.SIGTERM, stop_on_sigterm)
event_loop, prompt_server, start_all_func = start_comfyui()
try:
x = start_all_func()
app.logger.print_startup_warnings()
@@ -623,5 +654,13 @@ if __name__ == "__main__":
except KeyboardInterrupt:
logging.info("\nStopped server")
finally:
_prompt_worker_shutdown.set()
if _prompt_executor is not None:
_prompt_executor.request_shutdown()
prompt_server.prompt_queue.set_flag("shutdown", True)
if _prompt_worker_thread is not None:
_prompt_worker_thread.join(timeout=5)
if _prompt_worker_thread.is_alive():
logging.error("Prompt worker did not stop within five seconds")
asset_seeder.shutdown()
cleanup_temp()
+2
View File
@@ -27,6 +27,8 @@ comfy-aimdo==0.4.15
requests
simpleeval>=1.0.0
blake3
onnx>=1.17.0
onnxruntime>=1.20.0
#non essential dependencies:
kornia>=0.7.1
+6
View File
@@ -808,6 +808,12 @@ class PromptServer():
}
return web.json_response(system_stats)
@routes.get("/system_monitor")
async def system_monitor(request):
from comfy.system_monitor import get_system_monitor_snapshot
return web.json_response(get_system_monitor_snapshot())
@routes.get("/features")
async def get_features(request):
features = feature_flags.get_server_features()
@@ -0,0 +1,148 @@
import asyncio
from copy import deepcopy
import pytest
import torch
import comfy.ldm.anima.lllite
import comfy.model_base
import comfy.model_patcher
import comfy.utils
import folder_paths
from comfy_api.latest import _sdk
class _Sampling:
@staticmethod
def percent_to_sigma(value):
return 1.0 - value
class _PatchedModel:
def __init__(self, model, model_options):
self.model = model
self.model_options = deepcopy(model_options)
def set_model_post_input_patch(self, patch):
self.set_model_patch(patch, "post_input")
def set_model_attn1_patch(self, patch):
self.set_model_patch(patch, "attn1_patch")
def set_model_attn2_patch(self, patch):
self.set_model_patch(patch, "attn2_patch")
def set_model_patch(self, patch, name):
patches = self.model_options.setdefault(
"transformer_options", {}).setdefault("patches", {})
patches.setdefault(name, []).append(patch)
def _plan():
return _sdk.ExecutionPlan(
prompt_id="anima",
node_id="1",
node_type="anima-test",
prompt={"1": {"class_type": "anima-test"}},
extra_pnginfo={},
)
def test_anima_lllite_uses_confined_rgb_weights_and_preserves_patch_stacking(
tmp_path, monkeypatch,
):
class BaseAnima:
pass
class SourceModel:
def __init__(self):
self.model = BaseAnima()
self.model_options = {
"model_function_wrapper": object(),
"transformer_options": {"patches": {"existing": [object()]}},
}
def get_model_object(self, name):
assert name == "model_sampling"
return _Sampling()
def clone(self):
return _PatchedModel(self.model, self.model_options)
class LLLite:
cond_in_channels = 3
def __init__(self, state, metadata, **kwargs):
assert set(state) == {"weight"}
assert metadata == {"lllite.version": "2"}
self.loaded = False
def load_state_dict(self, state, assign=False):
self.loaded = True
class CoreModelPatcher:
def __init__(self, model, **kwargs):
self.model = model
@staticmethod
def is_dynamic():
return False
weights_path = tmp_path / "anima.safetensors"
weights_path.write_bytes(b"safe")
monkeypatch.setattr(comfy.model_base, "Anima", BaseAnima)
monkeypatch.setattr(
comfy.ldm.anima.lllite, "AnimaLLLite", LLLite)
monkeypatch.setattr(
comfy.model_patcher, "CoreModelPatcher", CoreModelPatcher)
monkeypatch.setattr(
comfy.utils, "load_torch_file",
lambda *args, **kwargs: (
{"weight": torch.ones(1)}, {"lllite.version": "2"}),
)
monkeypatch.setattr(comfy.utils, "weight_dtype", lambda state: torch.float32)
monkeypatch.setattr(folder_paths, "get_folder_paths", lambda name: [str(tmp_path)])
async def run():
refs = _sdk.InProcessRefResolver()
context = _sdk.InProcessCtxProvider().build(_plan())
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
model = _sdk.ModelRef._wrap(
await refs.create("MODEL", SourceModel()))
weights = _sdk.AssetRef._wrap(
await refs.create("ASSET", str(weights_path)))
image = _sdk.ImageRef._wrap(
await refs.create("IMAGE", torch.zeros(1, 32, 48, 3)))
result_ref = await context.integrations.anima.apply_lllite(
model, weights, image, strength=0.75,
start_percent=0.2, end_percent=0.8,
preserve_wrapper=False,
)
result = await refs.resolve(result_ref)
assert "model_function_wrapper" not in result.model_options
patches = result.model_options["transformer_options"]["patches"]
assert len(patches["existing"]) == 1
assert {"post_input", "attn1_patch", "attn2_patch", "mlp_patch"} <= set(patches)
outside = tmp_path.parent / "outside.safetensors"
outside.write_bytes(b"safe")
outside_ref = _sdk.AssetRef._wrap(
await refs.create("ASSET", str(outside)))
with pytest.raises(ValueError, match="escapes the controlnet"):
await context.integrations.anima.apply_lllite(
model, outside_ref, image)
asyncio.run(run())
def test_anima_lllite_spatial_tile_crops_the_control_image_exactly():
image = torch.arange(1 * 32 * 48 * 3).reshape(1, 32, 48, 3)
tile = comfy.ldm.anima.lllite._spatial_tile_image(image, {
"top": 1,
"bottom": 3,
"left": 2,
"right": 5,
"source_height": 4,
"source_width": 6,
})
assert torch.equal(tile, image[:, 8:24, 16:40, :])
@@ -0,0 +1,561 @@
import asyncio
import hashlib
import os
import pytest
import torch
from PIL import Image, UnidentifiedImageError
from comfy_api.latest._sdk import (
AssetRef,
ExecutionPlan,
ImageRef,
InProcessCtxProvider,
InProcessOps,
InProcessRefResolver,
bind_runtime,
)
def _plan(*, workflow=None):
return ExecutionPlan(
prompt_id="asset-output",
node_id="1",
node_type="asset-output-test",
prompt={"1": {"class_type": "asset-output-test"}},
extra_pnginfo={"workflow": workflow or {"nodes": [{"id": 1}]}},
)
def test_image_batch_size_is_bounded_scalar_metadata():
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
with bind_runtime(refs, context, InProcessOps()):
hwc = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((2, 3, 3))))
bhwc = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((2, 2, 3, 4))))
oversized = ImageRef._wrap(await refs.create(
"IMAGE", torch.empty((4097, 0, 0, 3))))
assert await hwc.batch_size() == 1
assert await bhwc.batch_size() == 2
with pytest.raises(ValueError, match="batch size"):
await oversized.batch_size()
asyncio.run(run())
def test_asset_digest_streams_sha256_and_invalidates_on_change(tmp_path):
asset_path = tmp_path / "weights.safetensors"
asset_path.write_bytes(b"first weights")
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
asset = AssetRef._wrap(await refs.create("ASSET", str(asset_path)))
with bind_runtime(refs, context, InProcessOps()):
first = await context.assets.digest(asset)
again = await context.assets.digest(asset)
assert first == again == hashlib.sha256(b"first weights").hexdigest()
asset_path.write_bytes(b"second weights with another size")
second = await context.assets.digest(asset)
assert second == hashlib.sha256(
b"second weights with another size").hexdigest()
assert second != first
with pytest.raises(ValueError, match="sha256"):
await context.assets.digest(asset, "md5")
asyncio.run(run())
def test_asset_load_image_decodes_one_bounded_rgb_image(tmp_path):
image_path = tmp_path / "source.png"
Image.new("RGBA", (5, 4), (64, 128, 255, 17)).save(image_path)
invalid_path = tmp_path / "invalid.png"
invalid_path.write_bytes(b"not an image")
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
image_asset = AssetRef._wrap(await refs.create(
"ASSET", str(image_path)))
invalid_asset = AssetRef._wrap(await refs.create(
"ASSET", str(invalid_path)))
with bind_runtime(refs, context, InProcessOps()):
image = await context.assets.load_image(image_asset)
pixels = await refs.resolve(image)
assert pixels.shape == (1, 4, 5, 3)
assert torch.allclose(
pixels[0, 0, 0], torch.tensor([64, 128, 255]) / 255)
with pytest.raises(UnidentifiedImageError):
await context.assets.load_image(invalid_asset)
context.assets._IMAGE_PIXELS_MAX = 19
with pytest.raises(ValueError, match="dimensions"):
await context.assets.load_image(image_asset)
asyncio.run(run())
def test_managed_asset_list_treats_a_new_confined_prefix_as_empty(
tmp_path, monkeypatch,
):
import folder_paths
for folder in ("input", "output", "temp"):
(tmp_path / folder).mkdir()
monkeypatch.setattr(
folder_paths, "get_input_directory", lambda: str(tmp_path / "input"))
monkeypatch.setattr(
folder_paths, "get_output_directory", lambda: str(tmp_path / "output"))
monkeypatch.setattr(
folder_paths, "get_temp_directory", lambda: str(tmp_path / "temp"))
async def run():
context = InProcessCtxProvider().build(_plan())
assert await context.assets.list(
"output", "new/subfolder", recursive=False) == []
with pytest.raises(ValueError, match="escapes"):
await context.assets.list("output", "../outside", recursive=False)
asyncio.run(run())
def test_exact_image_names_closed_codecs_metadata_and_workflow_sidecar(
tmp_path, monkeypatch,
):
import folder_paths
output = tmp_path / "output"
output.mkdir()
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(output))
expected_formats = {
"png": ("sample.png", "PNG"),
"jpg": ("sample.jpg", "JPEG"),
"jpeg": ("sample.jpeg", "JPEG"),
"webp": ("sample.webp", "WEBP"),
"j2k": ("sample.j2k", "JPEG2000"),
"jp2": ("sample.jp2", "JPEG2000"),
"gif": ("sample.gif", "GIF"),
"tiff": ("sample.tiff", "TIFF"),
"bmp": ("sample.bmp", "BMP"),
"avif": ("sample.avif", "AVIF"),
}
async def run():
refs = InProcessRefResolver()
workflow = {"nodes": [{"id": 7, "type": "Saved"}]}
context = InProcessCtxProvider().build(_plan(workflow=workflow))
image = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 3, 4, 3), dtype=torch.float32)))
responses = {}
with bind_runtime(refs, context, InProcessOps()):
assert await image.batch_size() == 1
for image_format, (filename, _) in expected_formats.items():
responses[image_format] = await context.output.save_images(
image,
filenames=[f"nested/{image_format}/{filename}"],
image_format=image_format,
quality=100,
lossless=True,
optimize=True,
)
sidecar = await context.output.save_workflow_json(
"nested/workflow.json")
return workflow, responses, sidecar
workflow, responses, sidecar = asyncio.run(run())
for image_format, (filename, pillow_format) in expected_formats.items():
record = responses[image_format]["images"][0]
assert record["filename"] == filename
assert record["subfolder"] == f"nested/{image_format}"
saved = Image.open(output / record["subfolder"] / filename)
assert saved.format == pillow_format
if image_format in {"jpg", "jpeg", "webp", "avif"}:
values = [value for value in saved.getexif().values()
if isinstance(value, str)]
assert any(value.lower().startswith("workflow:") for value in values)
assert sidecar == "nested/workflow.json"
import json
assert json.loads((output / sidecar).read_text()) == workflow
def test_exact_image_names_fail_closed_and_never_overwrite(tmp_path, monkeypatch):
import folder_paths
output = tmp_path / "output"
outside = tmp_path / "outside"
output.mkdir()
outside.mkdir()
(output / "escape").symlink_to(outside, target_is_directory=True)
existing = output / "existing.png"
existing.write_bytes(b"keep me")
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(output))
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
image = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 2, 2, 3), dtype=torch.float32)))
with bind_runtime(refs, context, InProcessOps()):
for filename, image_format, error in (
("../outside.png", "png", ValueError),
("escape/out.png", "png", ValueError),
("wrong.jpg", "png", ValueError),
("existing.png", "png", FileExistsError),
):
with pytest.raises(error):
await context.output.save_images(
image, filenames=[filename], image_format=image_format)
with pytest.raises(ValueError, match="length"):
await context.output.save_images(
image, filenames=[], image_format="png")
with pytest.raises(ValueError, match="not supported"):
await context.output.save_images(
image, filenames=["bad.jxl"], image_format="jxl")
asyncio.run(run())
assert existing.read_bytes() == b"keep me"
assert not (outside / "out.png").exists()
def test_jpeg_large_broker_metadata_degrades_without_losing_the_image(
tmp_path, monkeypatch,
):
import folder_paths
output = tmp_path / "output"
output.mkdir()
monkeypatch.setattr(folder_paths, "get_output_directory", lambda: str(output))
async def run():
refs = InProcessRefResolver()
plan = _plan(workflow={"blob": "w" * 100_000})
context = InProcessCtxProvider().build(plan)
image = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 2, 2, 3), dtype=torch.float32)))
with bind_runtime(refs, context, InProcessOps()):
kept = await context.output.save_images(
image,
filenames=["kept.jpg"],
image_format="jpg",
extra_metadata={"pack_key": "kept"},
)
dropped = await context.output.save_images(
image,
filenames=["dropped.jpg"],
image_format="jpg",
extra_metadata={"pack_blob": "p" * 100_000},
)
return kept, dropped
kept, dropped = asyncio.run(run())
assert kept["images"][0]["filename"] == "kept.jpg"
assert dropped["images"][0]["filename"] == "dropped.jpg"
kept_values = [
value for value in Image.open(output / "kept.jpg").getexif().values()
if isinstance(value, str)
]
assert any(value.startswith("pack_key:") for value in kept_values)
assert not any("w" * 100 in value for value in kept_values)
assert len(Image.open(output / "dropped.jpg").getexif()) == 0
def test_empty_latent_can_declare_canonical_spatial_ratio():
from comfy_api.latest._sdk import LatentRef
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
with bind_runtime(refs, context, InProcessOps()):
latent = await LatentRef.empty(
128, 64, spatial_downscale_ratio=8)
value = await refs.resolve(latent)
assert tuple(value["samples"].shape) == (1, 4, 8, 16)
assert value["downscale_ratio_spacial"] == 8
flux2 = await LatentRef.empty(
1024, 768, channels=128, spatial_downscale_ratio=16)
flux2_value = await refs.resolve(flux2)
assert tuple(flux2_value["samples"].shape) == (1, 128, 48, 64)
assert flux2_value["downscale_ratio_spacial"] == 16
with pytest.raises(ValueError, match="bounded range"):
await LatentRef.empty(
1024, 768, channels=129, spatial_downscale_ratio=16)
with pytest.raises(ValueError, match="bounded range"):
await LatentRef.empty(
128, 64, spatial_downscale_ratio=7)
asyncio.run(run())
def test_civitai_vendor_projection_is_closed_bounded_and_cached(monkeypatch):
from comfy_api.latest._civitai import InProcessCivitai
calls = []
def fetch(_cls, path, query=None):
calls.append((path, query))
if path == "/api/v1/models":
return {
"items": [{
"id": 7,
"name": "Model",
"description": "must not cross the broker",
"modelVersions": [{
"id": 9,
"name": "Version",
"downloadUrl": "must not cross",
}],
}],
"metadata": {"nextPage": "must not cross"},
}
if path == "/api/v1/model-versions/9":
return {
"id": 9,
"name": "Version",
"files": [{
"name": "model.safetensors",
"downloadUrl": "must not cross",
"hashes": {"AutoV3": "ABC", "bad key!": "hidden"},
}],
}
return {
"id": 9,
"name": "Version",
"modelId": 7,
"baseModel": "SDXL 1.0",
"trainedWords": ["trigger one", "trigger two", 3, "x" * 513],
"air": "urn:air:test",
"model": {
"name": "Model", "type": "Checkpoint", "nsfw": True,
},
"files": [],
"images": [
{
"url": "https://image.civitai.com/example.webp",
"meta": {
"prompt": "a useful example prompt",
"steps": 24,
"nested": {"sampler": "Euler"},
},
"width": "must not cross",
},
{"url": "http://private.invalid/image.png", "meta": {}},
{
"url": "https://image.civitai.com/bad-meta.webp",
"meta": {"__proto__": {"bad": True}},
},
],
}
monkeypatch.setattr(
InProcessCivitai, "_fetch_json", classmethod(fetch))
InProcessCivitai._CACHE.clear()
async def run():
context = InProcessCtxProvider().build(_plan())
first = await context.integrations.civitai.search_models(
"alice", "Model", limit=20, nsfw=True)
again = await context.integrations.civitai.search_models(
"alice", "Model", limit=20, nsfw=True)
version = await context.integrations.civitai.model_version(9)
by_hash = await context.integrations.civitai.model_version_by_hash(
"a" * 64)
refreshed = await context.integrations.civitai.model_version_by_hash(
"a" * 64, refresh=True)
with pytest.raises(ValueError, match="limit"):
await context.integrations.civitai.search_models(
"alice", limit=101)
with pytest.raises(ValueError, match="hash"):
await context.integrations.civitai.model_version_by_hash("../x")
return first, again, version, by_hash, refreshed
first, again, version, by_hash, refreshed = asyncio.run(run())
assert first == again == {"items": [{
"id": 7,
"name": "Model",
"modelVersions": [{"id": 9, "name": "Version"}],
}]}
assert version == {
"id": 9,
"name": "Version",
"files": [{
"name": "model.safetensors", "hashes": {"AutoV3": "ABC"},
}],
}
assert by_hash == {
"id": 9,
"name": "Version",
"modelId": 7,
"baseModel": "SDXL 1.0",
"air": "urn:air:test",
"model": {"name": "Model", "type": "Checkpoint"},
"files": [],
"trainedWords": ["trigger one", "trigger two"],
"images": [
{
"url": "https://image.civitai.com/example.webp",
"meta": {
"prompt": "a useful example prompt",
"steps": 24,
"nested": {"sampler": "Euler"},
},
},
{"url": "https://image.civitai.com/bad-meta.webp"},
],
}
assert refreshed == by_hash
assert [path for path, _query in calls].count("/api/v1/models") == 1
def test_civitai_image_metadata_projection_is_closed_and_bounded():
from comfy_api.latest._civitai import InProcessCivitai
images = [
{
"url": f"https://image.civitai.com/{index}.webp",
"meta": {"prompt": f"prompt {index}"},
}
for index in range(40)
]
images[1] = {
"url": "https://image.civitai.com/deep.webp",
"meta": {"a": {"b": {"c": {"d": {"e": "too deep"}}}}},
}
images[2] = {
"url": "http://127.0.0.1/private.png",
"meta": {"prompt": "must not cross"},
}
projected = InProcessCivitai._project_version_by_hash({
"id": 9,
"name": "Version",
"modelId": 7,
"baseModel": "SDXL",
"model": {"name": "Model"},
"files": [],
"images": images,
})
assert projected["baseModel"] == "SDXL"
assert len(projected["images"]) == 31
assert projected["images"][0]["meta"] == {"prompt": "prompt 0"}
assert projected["images"][1] == {
"url": "https://image.civitai.com/deep.webp",
}
assert all(
"127.0.0.1" not in item["url"] for item in projected["images"])
assert all(set(item) <= {"url", "meta"} for item in projected["images"])
def test_onnx_multilabel_classifier_keeps_scores_opaque_and_pages_matches(
tmp_path, monkeypatch,
):
import numpy as np
import onnx
from onnx import TensorProto, helper, numpy_helper
import folder_paths
from comfy_api.latest import _sdk
input_info = helper.make_tensor_value_info(
"image", TensorProto.FLOAT, [None, 4, 4, 3])
output_info = helper.make_tensor_value_info(
"scores", TensorProto.FLOAT, [None, 4])
weights = helper.make_tensor(
"weights", TensorProto.FLOAT, [48, 4],
np.zeros((48, 4), dtype=np.float32).ravel())
bias = helper.make_tensor(
"bias", TensorProto.FLOAT, [4], [0.1, 0.7, 0.3, 0.9])
graph = helper.make_graph([
helper.make_node("Flatten", ["image"], ["flat"], axis=1),
helper.make_node(
"Gemm", ["flat", "weights", "bias"], ["scores"]),
], "classifier", [input_info], [output_info], [weights, bias])
model = helper.make_model(
graph, opset_imports=[
helper.make_opsetid("", 17),
# Several real WD exporters retain unused provider opsets. The
# validator confines domains on executable nodes, not dead imports.
helper.make_opsetid("com.microsoft", 1),
])
model.ir_version = 8
model_path = tmp_path / "classifier.onnx"
onnx.save(model, model_path)
monkeypatch.setitem(
folder_paths.folder_names_and_paths,
"onnx", ([str(tmp_path)], {".onnx"}),
)
_sdk._ONNX_IMAGE_CLASSIFIER_CACHE.clear()
with pytest.raises(ValueError, match="sha256"):
_sdk.HuggingFaceWeight(
"owner/model", "model.onnx", "onnx", revision="abc123")
declaration = _sdk.HuggingFaceWeight(
"owner/model", "model.onnx", "onnx", revision="abc123",
sha256="a" * 64,
)
assert declaration.catalogue_name.endswith("/model.onnx")
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
with bind_runtime(refs, context, InProcessOps()):
classifier = await context.models.load_onnx_image_classifier(
"classifier.onnx",
input_layout="NHWC",
channel_order="BGR",
resize_mode="fit_pad",
input_scale=255.0,
activation="identity",
)
# A second bind reuses the validated runtime session.
await context.models.load_onnx_image_classifier(
"classifier.onnx", activation="identity")
images = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((2, 2, 4, 3), dtype=torch.float32)))
scores = await classifier.predict_scores(images)
shape = await scores.shape()
first = await scores.select_above(
0, 0, 4, 0.25, offset=0, limit=2)
second = await scores.select_above(
0, 0, 4, 0.25, offset=first["next_offset"], limit=2)
with pytest.raises(ValueError, match="class range"):
await scores.select_above(0, 0, 5, 0.0)
return shape, first, second
shape, first, second = asyncio.run(run())
assert shape == (2, 4)
assert [item["index"] for item in first["items"]] == [1, 2]
assert first["next_offset"] == 2
assert [item["index"] for item in second["items"]] == [3]
assert second["next_offset"] is None
assert _sdk._ONNX_IMAGE_CLASSIFIER_CACHE.loads == 1
def test_onnx_validation_rejects_external_tensor_data(tmp_path):
import numpy as np
import onnx
from onnx import TensorProto, helper, numpy_helper
from comfy_api.latest import _sdk
input_info = helper.make_tensor_value_info(
"input", TensorProto.FLOAT, [None, 1])
output_info = helper.make_tensor_value_info(
"output", TensorProto.FLOAT, [None, 1])
weight = numpy_helper.from_array(
np.ones((1, 1), dtype=np.float32), name="weight")
graph = helper.make_graph([
helper.make_node("MatMul", ["input", "weight"], ["output"]),
], "external", [input_info], [output_info], [weight])
model = helper.make_model(
graph, opset_imports=[helper.make_opsetid("", 17)])
model.ir_version = 8
model_path = tmp_path / "external.onnx"
onnx.save_model(
model, model_path,
save_as_external_data=True,
all_tensors_to_one_file=True,
location="external.data",
size_threshold=0,
)
with pytest.raises(ValueError, match="external ONNX tensor"):
_sdk._validate_onnx_weight_file(str(model_path))
@@ -0,0 +1,241 @@
"""In-process half of the closed node-closure author contract (D21)."""
from __future__ import annotations
import asyncio
import pytest
import torch
from comfy_api.latest import _sdk
class _FakeModel:
def __init__(self, parent=None):
self.parent = parent
self.post_cfg = None
self.disable_cfg1 = None
def clone(self):
return _FakeModel(self)
def set_model_sampler_post_cfg_function(
self, function, disable_cfg1_optimization=False,
):
self.post_cfg = function
self.disable_cfg1 = bool(disable_cfg1_optimization)
def _context():
return _sdk.InProcessCtxProvider().build(_sdk.ExecutionPlan(
prompt_id="closure-core",
node_id="1",
node_type="closure-core",
))
def test_post_cfg_closure_clones_model_and_preserves_tensor_contract():
async def run():
refs = _sdk.InProcessRefResolver()
context = _context()
original = _FakeModel()
model = _sdk.ModelRef._wrap(await refs.create("MODEL", original))
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
closure = await context.closures.retain(
"post_cfg", lambda guided, *_args: guided * 1.5)
patched_ref = await closure.attach_model(model)
patched = await refs.resolve(patched_ref)
guided = torch.full((1, 4, 2, 3), 2.0)
result = patched.post_cfg({
"denoised": guided,
"cond_denoised": torch.ones_like(guided),
"uncond_denoised": torch.zeros_like(guided),
"input": torch.full_like(guided, 3.0),
"sigma": torch.tensor([1.0]),
"cond_scale": 7.5,
})
return original, patched, guided, result
original, patched, guided, result = asyncio.run(run())
assert patched is not original
assert patched.parent is original
assert patched.disable_cfg1 is True
assert torch.equal(result, guided * 1.5)
def test_only_a_shipped_phase_can_be_retained():
async def run():
refs = _sdk.InProcessRefResolver()
context = _context()
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
await context.closures.retain(
"attention_couple", lambda value: value)
with pytest.raises(
Exception, match="unknown closure kind 'attention_couple'"
):
asyncio.run(run())
def test_post_cfg_closure_cannot_change_shape_dtype_or_device():
async def run():
refs = _sdk.InProcessRefResolver()
context = _context()
model = _sdk.ModelRef._wrap(
await refs.create("MODEL", _FakeModel()))
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
closure = await context.closures.retain(
"post_cfg", lambda guided, *_args: guided[..., :1])
patched = await refs.resolve(await closure.attach_model(model))
guided = torch.ones((1, 4, 2, 3))
return patched.post_cfg, guided
callback, guided = asyncio.run(run())
with pytest.raises(TypeError, match="preserve shape, dtype, and device"):
callback({
"denoised": guided,
"cond_denoised": guided,
"uncond_denoised": guided,
"input": guided,
"sigma": torch.tensor([1.0]),
"cond_scale": 7.5,
})
def test_model_sigma_closure_wraps_sampler_without_owning_model_calls():
from comfy.samplers import KSAMPLER
class Sampling:
@staticmethod
def percent_to_sigma(percent):
return 10.0 * (1.0 - percent)
class ModelCall:
def __init__(self):
self.inner_model = type("Guider", (), {
"cfg": 4.0,
"inner_model": type("Inner", (), {
"model_sampling": Sampling(),
})(),
})()
self.seen = None
def __call__(self, latent, sigma, **kwargs):
self.seen = sigma
return sigma
def source_sampler(model, x, sigmas, *, marker):
assert marker == "kept"
return model(x, torch.tensor([5.0]))
async def run():
refs = _sdk.InProcessRefResolver()
context = _context()
sampler = _sdk.SamplerRef._wrap(await refs.create(
"SAMPLER", KSAMPLER(source_sampler, {"marker": "kept"})))
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
closure = await context.closures.retain(
"model_sigma",
lambda sigma, sigmas, cfg, start_sigma, end_sigma:
sigma * 2.0
if end_sigma <= float(sigma.max()) <= start_sigma
else sigma,
)
wrapped = await refs.resolve(await closure.wrap_sampler(
sampler, start_percent=0.1, end_percent=0.9))
model = ModelCall()
result = wrapped.sampler_function(
model,
torch.zeros((1, 4, 2, 3)),
torch.tensor([9.0, 5.0, 1.0, 0.0]),
)
return model, result
model, result = asyncio.run(run())
assert torch.equal(result, torch.tensor([10.0]))
assert torch.equal(model.seen, torch.tensor([10.0]))
def test_custom_sampler_closure_owns_the_loop_but_not_the_model_call():
class Sampling:
noise_scale = 1.0
class ModelPatcher:
@staticmethod
def get_model_object(name):
assert name == "model_sampling"
return Sampling()
class ModelCall:
def __init__(self):
self.inner_model = type("Inner", (), {
"model_patcher": ModelPatcher(),
})()
self.seen = []
def __call__(
self, latent, sigma, denoise_mask=None, model_options=None,
seed=None,
):
self.seen.append((latent.clone(), sigma.clone(), seed))
return latent + 2.0
async def program(broker, latent, sigmas):
schedule = await broker.schedule_parameters()
assert schedule["parameterization"] == "sigma"
denoised, uncond = await broker.denoise(latent, sigmas[0])
assert uncond is None
await broker.preview(
0, latent, sigmas[0], sigmas[0], denoised)
return denoised
async def build():
refs = _sdk.InProcessRefResolver()
context = _context()
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
closure = await context.closures.retain(
"custom_sampler", program)
sampler_ref = await closure.as_sampler()
return await refs.resolve(sampler_ref)
sampler = asyncio.run(build())
model = ModelCall()
latent = torch.zeros((1, 4, 2, 3))
previews = []
result = sampler.sampler_function(
model,
latent,
torch.tensor([2.0, 1.0, 0.0]),
extra_args={"seed": 7},
callback=previews.append,
)
assert torch.equal(result, latent + 2.0)
assert len(model.seen) == 1
assert model.seen[0][2] == 7
assert len(previews) == 1
assert previews[0]["i"] == 0
with pytest.raises(ValueError, match="floating-point"):
sampler.sampler_function(
ModelCall(), latent, torch.tensor([2, 1, 0]))
with pytest.raises(ValueError, match="unsigned 64-bit"):
sampler.sampler_function(
ModelCall(), latent, torch.tensor([2.0, 1.0, 0.0]),
extra_args={"seed": -1},
)
async def bad_program(_broker, value, _sigmas):
return value[..., :-1]
async def build_bad():
refs = _sdk.InProcessRefResolver()
context = _context()
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
closure = await context.closures.retain(
"custom_sampler", bad_program)
sampler_ref = await closure.as_sampler()
return await refs.resolve(sampler_ref)
bad_sampler = asyncio.run(build_bad())
with pytest.raises(ValueError, match="temporary resize"):
bad_sampler.sampler_function(
ModelCall(), latent, torch.tensor([2.0, 1.0, 0.0]))
@@ -0,0 +1,179 @@
"""Closed GGUF model-loading primitives exposed to secure packs."""
from __future__ import annotations
import asyncio
from types import SimpleNamespace
import pytest
import folder_paths
from comfy_api.latest import _sdk
def _context():
return _sdk.InProcessCtxProvider().build(_sdk.ExecutionPlan(
prompt_id="gguf-test",
node_id="1",
node_type="gguf-test",
))
def test_load_gguf_text_encoders_mixes_closed_catalogue_weights(monkeypatch):
import comfy.model_management as model_management
import comfy.sd
import comfy.utils
names = ("encoder.gguf", "encoder.safetensors")
paths = {
("clip_gguf", names[0]): "/models/clip/encoder.gguf",
("text_encoders", names[1]): "/models/clip/encoder.safetensors",
}
monkeypatch.setattr(
folder_paths,
"get_filename_list",
lambda folder: list(names) if folder == "text_encoders" else [],
)
monkeypatch.setattr(
folder_paths,
"get_full_path_or_raise",
lambda folder, name: paths[(folder, name)],
)
monkeypatch.setattr(
folder_paths,
"get_folder_paths",
lambda folder: [f"/models/{folder}"],
)
monkeypatch.setattr(
comfy.utils,
"load_torch_file",
lambda path, safe_load=True: {"safe": path},
)
monkeypatch.setattr(
model_management,
"text_encoder_offload_device",
lambda: "offload-device",
)
original_patcher = object()
loaded = SimpleNamespace(patcher=original_patcher)
load_call = {}
def load_text_encoder_state_dicts(**kwargs):
load_call.update(kwargs)
return loaded
monkeypatch.setattr(
comfy.sd,
"load_text_encoder_state_dicts",
load_text_encoder_state_dicts,
)
gguf_ops = object()
gguf_module = SimpleNamespace(
GGMLOps=gguf_ops,
gguf_clip_loader=lambda path: {"gguf": path},
GGUFModelPatcher=SimpleNamespace(
clone=lambda patcher: ("gguf-patcher", patcher)),
)
monkeypatch.setattr(_sdk, "_fixed_gguf_node_module", lambda: gguf_module)
async def run():
refs = _sdk.InProcessRefResolver()
context = _context()
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
clip_ref = await context.models.load_gguf_text_encoders(
names, "stable_diffusion")
return clip_ref, await refs.resolve(clip_ref)
clip_ref, value = asyncio.run(run())
assert isinstance(clip_ref, _sdk.ClipRef)
assert value is loaded
assert load_call["state_dicts"] == [
{"gguf": paths[("clip_gguf", names[0])]},
{"safe": paths[("text_encoders", names[1])]},
]
assert load_call["clip_type"] is comfy.sd.CLIPType.STABLE_DIFFUSION
assert load_call["model_options"] == {
"custom_operations": gguf_ops,
"initial_device": "offload-device",
}
assert load_call["embedding_directory"] == ["/models/embeddings"]
assert loaded.patcher == ("gguf-patcher", original_patcher)
def test_load_gguf_text_encoders_rejects_unsafe_or_ambiguous_inputs(
monkeypatch,
):
import comfy.sd
import comfy.utils
monkeypatch.setattr(
folder_paths,
"get_filename_list",
lambda folder: ["scaled.safetensors"]
if folder == "text_encoders" else [],
)
monkeypatch.setattr(
folder_paths,
"get_full_path_or_raise",
lambda folder, name: f"/models/{folder}/{name}",
)
monkeypatch.setattr(
comfy.utils,
"load_torch_file",
lambda path, safe_load=True: {"scaled_fp8": object()},
)
async def invoke(names, clip_type):
refs = _sdk.InProcessRefResolver()
context = _context()
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
return await context.models.load_gguf_text_encoders(
names, clip_type)
with pytest.raises(TypeError, match="sequence"):
asyncio.run(invoke("scaled.safetensors", "stable_diffusion"))
with pytest.raises(ValueError, match="1 to 4"):
asyncio.run(invoke([], "stable_diffusion"))
with pytest.raises(ValueError, match="1 to 4"):
asyncio.run(invoke(["scaled.safetensors"] * 5, "stable_diffusion"))
with pytest.raises(ValueError, match="unknown CLIP type"):
asyncio.run(invoke(["scaled.safetensors"], "not-a-family"))
with pytest.raises(ValueError, match="unknown text encoder"):
asyncio.run(invoke(["missing.safetensors"], "stable_diffusion"))
with pytest.raises(ValueError, match="scaled FP8"):
asyncio.run(invoke(["scaled.safetensors"], "stable_diffusion"))
def test_load_gguf_text_encoders_requires_compatible_gguf_extension(
monkeypatch,
):
import comfy.sd
monkeypatch.setattr(
folder_paths,
"get_filename_list",
lambda folder: ["encoder.gguf"] if folder == "text_encoders" else [],
)
monkeypatch.setattr(
folder_paths,
"get_full_path_or_raise",
lambda folder, name: f"/models/{folder}/{name}",
)
monkeypatch.setattr(
_sdk,
"_fixed_gguf_node_module",
lambda: SimpleNamespace(
GGMLOps=object(),
GGUFModelPatcher=SimpleNamespace(clone=lambda value: value),
),
)
async def run():
refs = _sdk.InProcessRefResolver()
context = _context()
with _sdk.bind_runtime(refs, context, _sdk.InProcessOps()):
return await context.models.load_gguf_text_encoders(
["encoder.gguf"], "stable_diffusion")
with pytest.raises(RuntimeError, match="missing gguf_clip_loader"):
asyncio.run(run())
@@ -0,0 +1,125 @@
import asyncio
from types import SimpleNamespace
import pytest
import torch
from comfy_api.latest._sdk import (
CondRef,
ExecutionPlan,
ImageRef,
InProcessCtxProvider,
InProcessOps,
InProcessRefResolver,
ModelRef,
SigmasRef,
bind_runtime,
)
def _plan():
return ExecutionPlan(
prompt_id="grounding",
node_id="1",
node_type="grounding-test",
prompt={"1": {"class_type": "grounding-test"}},
extra_pnginfo={},
)
def test_sigmas_steps_is_bounded_scalar_metadata():
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
with bind_runtime(refs, context, InProcessOps()):
sigmas = SigmasRef._wrap(await refs.create(
"SIGMAS", torch.tensor([1.0, 0.5, 0.1, 0.0])))
invalid = SigmasRef._wrap(await refs.create(
"SIGMAS", torch.tensor([float("nan"), 0.0])))
assert await sigmas.steps() == 3
with pytest.raises(ValueError, match="finite"):
await invalid.steps()
asyncio.run(run())
def test_model_ground_image_delegates_to_official_sam3_and_bounds_results(
monkeypatch,
):
from comfy_extras.nodes_sam3 import SAM3_Detect
diffusion_type = type("SAM3Model", (), {})
diffusion_type.__module__ = "comfy.ldm.sam3.detector"
diffusion = diffusion_type()
base = SimpleNamespace(
diffusion_model=diffusion,
model_config=SimpleNamespace(unet_config={"image_model": "SAM31"}),
)
patcher = SimpleNamespace(model=base)
calls = []
def fake_execute(
cls, model, image, conditioning=None, threshold=0.5,
refine_iterations=2, individual_masks=False, **kwargs,
):
calls.append({
"model": model,
"image": image,
"conditioning": conditioning,
"threshold": threshold,
"refine_iterations": refine_iterations,
"individual_masks": individual_masks,
})
return SimpleNamespace(result=(
torch.stack([
torch.full((4, 5), 1.0),
torch.full((4, 5), 2.0),
]),
[[
{"x": 1, "y": 1, "width": 2, "height": 2, "score": 0.9},
{"x": 2, "y": 1, "width": 2, "height": 2, "score": 0.8},
]],
))
monkeypatch.setattr(SAM3_Detect, "execute", classmethod(fake_execute))
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
with bind_runtime(refs, context, InProcessOps()):
model = ModelRef._wrap(await refs.create("MODEL", patcher))
image = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 4, 5, 3))))
conditioning_value = [[torch.zeros((1, 2, 3)), {}]]
conditioning = CondRef._wrap(await refs.create(
"CONDITIONING", conditioning_value))
masks, boxes = await model.ground_image(
image,
conditioning,
threshold=0.6,
refine_iterations=1,
individual_masks=True,
max_detections=1,
)
assert torch.equal(
await refs.resolve(masks), torch.full((1, 4, 5), 1.0))
assert boxes == [[{
"x": 1.0,
"y": 1.0,
"width": 2.0,
"height": 2.0,
"score": 0.9,
}]]
wrong = ModelRef._wrap(await refs.create(
"MODEL", SimpleNamespace(model=SimpleNamespace())))
with pytest.raises(TypeError, match="official SAM3"):
await wrong.ground_image(image, conditioning)
asyncio.run(run())
assert len(calls) == 1
assert calls[0]["model"] is patcher
assert calls[0]["threshold"] == 0.6
assert calls[0]["refine_iterations"] == 1
assert calls[0]["individual_masks"] is True
@@ -0,0 +1,259 @@
import asyncio
import base64
import io
import pytest
import torch
from PIL import Image
from comfy_api.latest._ollama import InProcessOllama
from comfy_api.latest._sdk import (
ExecutionPlan,
ImageRef,
InProcessCtxProvider,
InProcessOps,
InProcessRefResolver,
bind_runtime,
)
def _plan():
return ExecutionPlan(
prompt_id="ollama",
node_id="1",
node_type="ollama-test",
prompt={"1": {"class_type": "ollama-test"}},
extra_pnginfo={},
)
def test_ollama_vendor_projection_is_closed_bounded_and_encodes_images(
monkeypatch,
):
calls = []
def request(cls, origin, path, payload, timeout):
calls.append((origin, path, payload, timeout))
if path == "/api/tags":
return {"models": [{"name": "vision:latest"}, {"model": "qwen"}]}
if path == "/api/generate":
return {
"response": "generated",
"thinking": "considered",
"context": [2, 3, 5],
}
if path == "/api/chat":
return {"message": {"content": "chatted", "thinking": "reasoned"}}
raise AssertionError(path)
monkeypatch.setattr(InProcessOllama, "_request_json", classmethod(request))
monkeypatch.setenv(
"COMFY_SECURE_OLLAMA_PROFILES",
'{"studio":"https://ollama.example.test"}',
)
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
integration = context.integrations.ollama
with bind_runtime(refs, context, InProcessOps()):
image = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((2, 3, 4, 3))))
assert await integration.list_models(
"http://127.0.0.1:11434") == ["vision:latest", "qwen"]
generated = await integration.generate(
endpoint="http://127.0.0.1:11434",
model="vision:latest",
system="be precise",
prompt="describe",
images=image,
context=[1, 2],
think=True,
options={
"temperature": 0.4,
"top_k": 20,
"stop": "END",
"low_vram": True,
"main_gpu": 0,
},
keep_alive=7,
keep_alive_unit="minutes",
format={
"type": "object",
"properties": {"caption": {"type": "string"}},
},
timeout_seconds=42,
)
assert generated == {
"response": "generated",
"thinking": "considered",
"context": [2, 3, 5],
}
chatted = await integration.chat(
endpoint="ollama://studio",
model="qwen",
messages=[{"role": "user", "content": "hello"}],
images=image,
think=True,
keep_alive=1,
keep_alive_unit="hours",
)
assert chatted == {"response": "chatted", "thinking": "reasoned"}
with pytest.raises(ValueError, match="loopback"):
await integration.list_models("http://example.com:11434")
with pytest.raises(ValueError, match="unsupported"):
await integration.generate(
"http://127.0.0.1:11434", "qwen", "", "x",
options={"arbitrary": True})
asyncio.run(run())
assert calls[0][:2] == (
"http://127.0.0.1:11434", "/api/tags")
generate_payload = calls[1][2]
assert generate_payload["stream"] is False
assert generate_payload["think"] is True
assert generate_payload["keep_alive"] == "7m"
assert generate_payload["format"] == {
"type": "object",
"properties": {"caption": {"type": "string"}},
}
assert generate_payload["context"] == [1, 2]
assert generate_payload["options"] == {
"temperature": 0.4,
"top_k": 20,
"stop": "END",
"low_vram": True,
"main_gpu": 0,
}
assert calls[1][3] == 42.0
assert len(generate_payload["images"]) == 2
for encoded in generate_payload["images"]:
with Image.open(io.BytesIO(base64.b64decode(encoded))) as decoded:
assert decoded.size == (4, 3)
assert decoded.mode == "RGB"
assert calls[2][0] == "https://ollama.example.test"
chat_payload = calls[2][2]
assert chat_payload["keep_alive"] == "1h"
assert chat_payload["think"] is True
assert chat_payload["messages"][0]["role"] == "user"
assert len(chat_payload["messages"][0]["images"]) == 2
def test_generic_llm_tool_chat_normalizes_messages_and_calls(monkeypatch):
calls = []
def request(cls, origin, path, payload, timeout):
calls.append((origin, path, payload, timeout))
return {
"message": {
"content": "",
"thinking": "I should search",
"tool_calls": [{
"function": {
"name": "search_internet",
"arguments": {"query": "current time"},
},
}],
},
}
monkeypatch.setattr(InProcessOllama, "_request_json", classmethod(request))
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
with bind_runtime(refs, context, InProcessOps()):
result = await context.integrations.llm.chat(
provider="ollama",
profile="http://127.0.0.1:11434",
model="qwen",
messages=[
{"role": "system", "content": "Use tools."},
{"role": "user", "content": "What time is it?"},
{
"role": "assistant",
"content": "",
"tool_calls": [{
"name": "search_internet",
"arguments": {"query": "time"},
}],
},
{
"role": "tool",
"name": "search_internet",
"content": "12:34",
},
],
tools=[{
"name": "search_internet",
"description": "Search the web",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
}],
temperature=0.2,
max_tokens=2048,
thinking=True,
response_format="json",
timeout_seconds=120,
vendor_options={
"ollama": {
"keep_alive": 2,
"keep_alive_unit": "hours",
},
},
)
assert result == {
"content": "",
"thinking": "I should search",
"tool_calls": [{
"name": "search_internet",
"arguments": {"query": "current time"},
}],
}
with pytest.raises(ValueError, match="provider"):
await context.integrations.llm.chat(
"unknown", "profile", "model",
[{"role": "user", "content": "x"}],
)
asyncio.run(run())
_, path, payload, timeout = calls[0]
assert path == "/api/chat"
assert timeout == 120.0
assert payload["options"] == {
"temperature": 0.2,
"num_predict": 2048,
}
assert payload["keep_alive"] == "2h"
assert payload["format"] == "json"
assert payload["messages"][2]["tool_calls"] == [{
"function": {
"name": "search_internet",
"arguments": {"query": "time"},
},
}]
assert payload["messages"][3] == {
"role": "tool",
"tool_name": "search_internet",
"content": "12:34",
}
assert payload["tools"] == [{
"type": "function",
"function": {
"name": "search_internet",
"description": "Search the web",
"parameters": {
"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"],
},
},
}]
@@ -0,0 +1,145 @@
import asyncio
from types import SimpleNamespace
import pytest
import torch
from comfy.sd1_clip import SDTokenizer
from comfy_api.latest._sdk import (
ClipRef,
CondRef,
InProcessOps,
InProcessRefResolver,
ModelRef,
SamplerRef,
SigmasRef,
bind_runtime,
)
class _Sampling:
sigma_max = torch.tensor(14.0)
sigma_min = torch.tensor(0.03)
@staticmethod
def percent_to_sigma(percent):
if percent == 0.0:
return 999_999_999.9
if percent == 1.0:
return 0.0
return 10.0 * (1.0 - percent)
class _Model:
def get_model_object(self, name):
if name != "model_sampling":
raise KeyError(name)
return _Sampling()
class _ClipPatcher:
def __init__(self):
self.model_options = {"transformer_options": {"stable": True}}
class _Clip:
def __init__(self, tokenizer):
self.tokenizer = tokenizer
self.patcher = _ClipPatcher()
def clone(self):
return _Clip(self.tokenizer)
def _tokenizer():
tokenizer = object.__new__(SDTokenizer)
tokenizer.embedding_key = "clip_l"
tokenizer.start_token = 1
tokenizer.end_token = 2
tokenizer.pad_token = 0
tokenizer.inv_vocab = {0: "<pad>", 1: "<start>", 2: "<end>", 7: "word"}
return SimpleNamespace(clip_l=tokenizer)
def test_ppm_scalar_conditioning_token_and_sampler_primitives():
async def run():
refs = InProcessRefResolver()
conditioning_value = [[
torch.ones((1, 2, 3)),
{"pooled_output": torch.ones((1, 3)), "stable": "yes"},
]]
conditioning = CondRef._wrap(await refs.create(
"CONDITIONING", conditioning_value))
sigmas = SigmasRef._wrap(await refs.create(
"SIGMAS", torch.tensor([4.0, 2.0, 0.0])))
model = ModelRef._wrap(await refs.create("MODEL", _Model()))
clip = ClipRef._wrap(await refs.create("CLIP", _Clip(_tokenizer())))
with bind_runtime(refs, None, InProcessOps()):
metadata = await conditioning.with_metadata(
width=1024, height=768, crop_w=4, crop_h=8,
target_width=896, target_height=640,
)
ranged = await conditioning.with_timestep_range(0.2, 0.8)
zeroed = await conditioning.zero_out()
sigma = await sigmas.value_at(-2)
normal_endpoint = await model.sigma_for_percent(0.0)
actual_endpoint = await model.sigma_for_percent(
0.0, actual_endpoints=True)
sampler = await SamplerRef.named(
"gradient_estimation", ge_gamma=3.25)
descriptions = await clip.describe_tokens({
"l": [[(1, 1.0), (7, -0.5), (2, 1.0)]],
})
selected_clip = await clip.with_attention_impl("optimized")
with pytest.raises(ValueError, match="does not accept"):
await SamplerRef.named("euler", eta=1.0)
with pytest.raises(ValueError, match="start <= end"):
await conditioning.with_timestep_range(0.8, 0.2)
with pytest.raises(IndexError, match="outside"):
await sigmas.value_at(9)
return {
"metadata": await refs.resolve(metadata),
"ranged": await refs.resolve(ranged),
"zeroed": await refs.resolve(zeroed),
"sigma": sigma,
"normal_endpoint": normal_endpoint,
"actual_endpoint": actual_endpoint,
"sampler": await refs.resolve(sampler),
"descriptions": descriptions,
"selected_clip": await refs.resolve(selected_clip),
}
result = asyncio.run(run())
assert result["metadata"][0][1] == {
"pooled_output": result["metadata"][0][1]["pooled_output"],
"stable": "yes",
"width": 1024,
"height": 768,
"crop_w": 4,
"crop_h": 8,
"target_width": 896,
"target_height": 640,
}
assert result["ranged"][0][1]["start_percent"] == 0.2
assert result["ranged"][0][1]["end_percent"] == 0.8
assert torch.count_nonzero(result["zeroed"][0][0]) == 0
assert torch.count_nonzero(
result["zeroed"][0][1]["pooled_output"]) == 0
assert result["sigma"] == 2.0
assert result["normal_endpoint"] == pytest.approx(999_999_999.9)
assert result["actual_endpoint"] == pytest.approx(14.0)
assert result["sampler"].extra_options == {"ge_gamma": 3.25}
assert result["descriptions"] == {
"l": [[
{"id": 1, "text": "<start>", "special": True},
{"id": 7, "text": "word", "special": False},
{"id": 2, "text": "<end>", "special": True},
]],
}
transformer_options = result["selected_clip"].patcher.model_options[
"transformer_options"]
assert transformer_options["stable"] is True
assert callable(transformer_options["optimized_attention_override"])
@@ -0,0 +1,357 @@
import asyncio
import base64
import io
import struct
from types import SimpleNamespace
import pytest
import torch
from PIL import Image
from comfy.text_encoders import qwen_vl
from comfy.text_encoders import qwen3vl, qwen_image
from comfy.text_encoders.llama import BaseGenerate
from comfy_api.latest import _llama_cpp, _sdk
from comfy_api.latest._sdk import (
ClipRef,
ExecutionPlan,
ImageRef,
InProcessCtxProvider,
InProcessOps,
InProcessRefResolver,
LlamaCppModelRef,
bind_runtime,
)
def _plan():
return ExecutionPlan(
prompt_id="qwen",
node_id="1",
node_type="qwen-test",
prompt={"1": {"class_type": "qwen-test"}},
extra_pnginfo={},
)
def test_qwen_media_preprocessing_keeps_selected_frames_and_exact_mrope():
frames = torch.arange(3 * 64 * 96 * 3, dtype=torch.float32).reshape(
3, 64, 96, 3)
frames = frames / frames.max()
qwen25_patches, qwen25_grid, qwen25_mrope = (
qwen_vl.process_qwen_vl_media(
frames, family="qwen2_5_vl_7b"))
qwen3_patches, qwen3_grid, qwen3_mrope = (
qwen_vl.process_qwen_vl_media(
frames, family="qwen3vl_4b"))
assert qwen25_grid.tolist() == [[2, 4, 6]]
assert qwen25_patches.shape == (48, 3 * 2 * 14 * 14)
assert qwen25_mrope.shape == (3, 12)
assert qwen25_mrope[0].tolist() == [0] * 6 + [2] * 6
assert qwen3_grid.tolist() == [[2, 4, 6]]
assert qwen3_patches.shape == (48, 3 * 2 * 16 * 16)
assert qwen3_mrope[0].tolist() == [0] * 12
# Qwen3 budgets spatial resize using the temporally padded length while
# retaining the original frame count in the official beta calculation.
assert qwen_vl._qwen_smart_resize(
2080,
2080,
factor=32,
min_pixels=4096,
max_pixels=25_165_824,
frames=5,
padded_frames=6,
) == (2240, 2240)
def test_qwen_bounded_beam_generation_is_deterministic():
class FakeModel:
config = SimpleNamespace(stop_tokens=[3])
@staticmethod
def embed_tokens(tokens):
return tokens.to(dtype=torch.float32).unsqueeze(-1)
@staticmethod
def forward(
_unused, *, embeds, attention_mask, past_key_values,
input_ids, position_ids, **kwargs,
):
return embeds, None, past_key_values
class FakeGenerator(BaseGenerate):
model = FakeModel()
@staticmethod
def init_kv_cache(batch, max_cache_len, device, execution_dtype):
return []
@staticmethod
def logits(value):
marker = int(value[0, -1, 0].item())
if marker == 9: # prefill: token 1 narrowly beats token 2
return torch.tensor([[[0.0, 4.0, 3.8, -10.0]]])
if marker == 1: # the best complete branch
return torch.tensor([[[0.0, -2.0, -2.0, 5.0]]])
return torch.tensor([[[0.0, -2.0, 4.0, 1.0]]])
generated = FakeGenerator().generate(
embeds=torch.tensor([[[9.0]]]),
do_sample=False,
max_length=3,
num_beams=2,
)
assert generated == [1, 3]
def test_qwen_static_templates_keep_distinct_still_and_video_media():
image = torch.zeros((1, 8, 8, 3))
video = torch.zeros((3, 8, 8, 3))
qwen3 = qwen3vl.generation_tokenizer(
"qwen3vl_4b")(embedding_directory=[])
qwen3_tokens = qwen3.tokenize_with_weights(
"hello", image=image, video=video)
qwen3_descriptors = [
item[0]
for row in qwen3_tokens[qwen3.clip_name]
for item in row
if isinstance(item[0], dict)
]
assert [(item["type"], item.get("segment")) for item in qwen3_descriptors] == [
("image", None),
("video_segment", 0),
("video_segment", 1),
]
plain = qwen3.tokenize_with_weights("hello", thinking=False)
thinking = qwen3.tokenize_with_weights("hello", thinking=True)
def decode(tokenizer, rows):
ids = [item[0] for row in rows[tokenizer.clip_name] for item in row]
return getattr(tokenizer, tokenizer.clip).decode(
ids, skip_special_tokens=False)
assert decode(qwen3, plain).endswith("<|im_start|>assistant\n")
assert decode(qwen3, thinking).endswith(
"<|im_start|>assistant\n<think>\n")
qwen25 = qwen_image.vl_tokenizer(
"qwen2_5_vl_7b")(embedding_directory=[])
qwen25_tokens = qwen25.tokenize_with_weights(
"hello", image=image, video=video)
qwen25_descriptors = [
item[0]
for row in qwen25_tokens[qwen25.clip_name]
for item in row
if isinstance(item[0], dict)
]
assert [item["type"] for item in qwen25_descriptors] == [
"image", "video",
]
def test_clip_generation_accepts_still_and_video_with_family_defaults():
class FakeClip:
_secure_language_family = "qwen3_vl_4b"
def __init__(self):
self.calls = []
def tokenize(self, prompt, **kwargs):
self.calls.append(("tokenize", prompt, kwargs))
return {"qwen": [[(1, 1.0)]]}
def generate(self, tokens, **kwargs):
self.calls.append(("generate", tokens, kwargs))
return [7, 8]
@staticmethod
def decode(tokens):
return " generated "
async def run():
refs = InProcessRefResolver()
value = FakeClip()
clip = ClipRef._wrap(await refs.create("CLIP", value))
image = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 8, 8, 3))))
video = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((3, 8, 8, 3))))
with bind_runtime(refs, None, InProcessOps()):
result = await clip.generate_text(
"describe",
image=image,
video=video,
top_k=None,
num_beams=2,
)
with pytest.raises(ValueError, match="cannot also enable sampling"):
await clip.generate_text(
"x", do_sample=True, num_beams=2)
return result, value.calls
result, calls = asyncio.run(run())
assert result == "generated"
assert calls[0][2]["image"].shape == (1, 8, 8, 3)
assert calls[0][2]["video"].shape == (3, 8, 8, 3)
assert calls[1][2]["top_k"] == 20
assert calls[1][2]["num_beams"] == 2
def test_qwen_shards_merge_remap_and_dequantize_once(monkeypatch, tmp_path):
import comfy.sd
import comfy.text_encoders.hunyuan_video
import comfy.utils
import folder_paths
shard_a = tmp_path / "a.safetensors"
shard_b = tmp_path / "b.safetensors"
shard_a.write_bytes(b"a")
shard_b.write_bytes(b"b")
states = {
str(shard_a): {
"model.language_model.embed_tokens.weight": torch.ones((2, 3)),
"model.language_model.layers.0.mlp.weight": torch.full((2, 3), 2.0),
"model.language_model.layers.0.mlp.weight_scale_inv": torch.full((1, 1), 3.0),
},
str(shard_b): {
"model.visual.patch_embed.weight": torch.full((1,), 4.0),
"lm_head.weight": torch.full((2, 3), 5.0),
},
}
captured = {}
monkeypatch.setattr(
comfy.utils,
"load_torch_file",
lambda path, **kwargs: (dict(states[path]), {}),
)
monkeypatch.setattr(
comfy.utils, "convert_old_quants", lambda state, **kwargs: (state, {}))
monkeypatch.setattr(
comfy.utils,
"calculate_parameters",
lambda state: sum(value.numel() for value in state.values()),
)
monkeypatch.setattr(
comfy.text_encoders.hunyuan_video, "llama_detect", lambda state: {})
monkeypatch.setattr(folder_paths, "get_folder_paths", lambda kind: [])
class FakeClip:
def __init__(self, target, **kwargs):
captured["target"] = target
captured.update(kwargs)
@staticmethod
def generate(*args, **kwargs):
return []
monkeypatch.setattr(comfy.sd, "CLIP", FakeClip)
entry = _sdk._load_qwen_language_model(
(str(shard_a), str(shard_b)), "qwen3_vl_4b", "cpu")
state = captured["state_dict"][0]
assert isinstance(entry.clip, FakeClip)
assert set(state) == {
"model.embed_tokens.weight",
"model.layers.0.mlp.weight",
"visual.patch_embed.weight",
"model.lm_head.weight",
}
assert torch.equal(
state["model.layers.0.mlp.weight"],
torch.full((2, 3), 6.0, dtype=torch.bfloat16),
)
assert captured["model_options"]["qwen3vl_4b_model_config"] == {
"lm_head": True,
}
# The public family uses the stable SDK spelling; the native Comfy class
# keeps its existing internal model key.
captured["target"].tokenizer(embedding_directory=[])
def test_llama_cpp_vendor_ref_hides_paths_and_encodes_media(
monkeypatch, tmp_path,
):
calls = []
class FakeHandler:
def __init__(self, **kwargs):
calls.append(("handler", kwargs))
class FakeLlama:
def __init__(self, **kwargs):
calls.append(("load", kwargs))
def create_chat_completion(self, **kwargs):
calls.append(("generate", kwargs))
return {"choices": [{"message": {"content": " described "}}]}
formats = SimpleNamespace(
Qwen3VLChatHandler=FakeHandler,
Qwen25VLChatHandler=FakeHandler,
)
monkeypatch.setattr(
_llama_cpp, "_classes", lambda: (FakeLlama, formats))
_llama_cpp._CACHE.clear()
model_path = tmp_path / "model.gguf"
mmproj_path = tmp_path / "mmproj.gguf"
gguf_header = struct.pack("<4sIQQ", b"GGUF", 3, 1, 0)
model_path.write_bytes(gguf_header)
mmproj_path.write_bytes(gguf_header)
import folder_paths
def resolve(folder, logical):
assert folder == "text_encoders"
return str(model_path if logical == "model.gguf" else mmproj_path)
monkeypatch.setattr(folder_paths, "get_full_path_or_raise", resolve)
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
with bind_runtime(refs, context, InProcessOps()):
model = await context.integrations.llama_cpp.load_chat_model(
"model.gguf",
"mmproj.gguf",
family="qwen3_vl",
device="cpu",
)
assert isinstance(model, LlamaCppModelRef)
image = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 4, 5, 3))))
video = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((2, 6, 7, 3))))
result = await model.generate(
"system", "prompt", image=image, video=video,
max_tokens=32, seed=9)
with pytest.raises(ValueError, match="require an mmproj"):
await context.integrations.llama_cpp.load_chat_model(
"model.gguf", family="qwen3_vl")
return result
assert asyncio.run(run()) == "described"
load = next(value for kind, value in calls if kind == "load")
assert load["model_path"] == str(model_path)
assert load["n_gpu_layers"] == 0
generated = next(value for kind, value in calls if kind == "generate")
content = generated["messages"][1]["content"]
assert content[0] == {"type": "text", "text": "prompt"}
assert len(content) == 4
assert all(
item["image_url"]["url"].startswith("data:image/png;base64,")
for item in content[1:]
)
sizes = []
for item in content[1:]:
encoded = item["image_url"]["url"].split(",", 1)[1]
with Image.open(io.BytesIO(base64.b64decode(encoded))) as media:
sizes.append(media.size)
assert sizes == [(5, 4), (7, 6), (7, 6)]
assert "model_path" not in generated
_llama_cpp._CACHE.clear()
@@ -0,0 +1,109 @@
from __future__ import annotations
import asyncio
import types
import pytest
import torch
from comfy_api.latest import _sdk
def test_ref_describe_projects_tensor_metadata_without_values():
async def run():
refs = _sdk.InProcessRefResolver()
token = await refs.create(
"IMAGE", torch.arange(24, dtype=torch.float32).reshape(1, 2, 4, 3))
image = _sdk.ImageRef._wrap(token)
with _sdk.bind_runtime(
refs, types.SimpleNamespace(), _sdk.InProcessOps(),
):
return await image.describe()
assert asyncio.run(run()) == {
"kind": "IMAGE",
"type": "Tensor",
"length": 1,
"first": "<redacted tensor slice shape=[2, 4, 3]>",
"shape": [1, 2, 4, 3],
"summary": "<IMAGE tensor shape=[1, 2, 4, 3] dtype=torch.float32 device=cpu>",
"truncated": False,
}
def test_ref_describe_never_invokes_opaque_object_behavior():
touched = []
class Trap:
def __len__(self):
touched.append("len")
raise AssertionError
def __iter__(self):
touched.append("iter")
raise AssertionError
def __repr__(self):
touched.append("repr")
raise AssertionError
@property
def shape(self):
touched.append("shape")
raise AssertionError
async def run():
refs = _sdk.InProcessRefResolver()
model = _sdk.ModelRef._wrap(await refs.create("MODEL", Trap()))
with _sdk.bind_runtime(
refs, types.SimpleNamespace(), _sdk.InProcessOps(),
):
return await model.describe()
assert asyncio.run(run()) == {
"kind": "MODEL",
"type": "opaque MODEL",
"length": None,
"first": None,
"shape": None,
"summary": "<opaque MODEL>",
"truncated": False,
}
assert touched == []
def test_ref_describe_never_exposes_an_asset_path():
async def run():
refs = _sdk.InProcessRefResolver()
asset = _sdk.AssetRef._wrap(await refs.create(
"ASSET", "/tenant/private/models/secret.safetensors"))
with _sdk.bind_runtime(
refs, types.SimpleNamespace(), _sdk.InProcessOps(),
):
return await asset.describe()
description = asyncio.run(run())
assert description["kind"] == "ASSET"
assert description["summary"] == "<opaque ASSET>"
assert "tenant" not in repr(description)
assert "secret.safetensors" not in repr(description)
def test_ref_describe_bounds_and_truncates_its_projection():
async def run(limit):
refs = _sdk.InProcessRefResolver()
kind = "DIAGNOSTIC_" + "X" * 80
value = _sdk.Ref(kind=kind, id=(await refs.create(kind, object())).id)
with _sdk.bind_runtime(
refs, types.SimpleNamespace(), _sdk.InProcessOps(),
):
return await value.describe(limit)
description = asyncio.run(run(32))
assert description["truncated"] is True
assert len(description["summary"]) == 32
assert description["summary"].endswith("")
with pytest.raises(ValueError, match=r"\[32, 32768\]"):
asyncio.run(run(31))
with pytest.raises(TypeError, match="must be an integer"):
asyncio.run(run(True))
@@ -11,11 +11,29 @@ async execute forms, and verifies:
swap), while output stays correct.
"""
import asyncio
import json
import pathlib
import threading
import pytest
import torch
from comfy_api.latest import sdk
from comfy_api.latest._sdk import InProcessExecutionBackend
from comfy_api.latest._sdk import (
BackgroundRemovalModelRef,
CondRef,
GuiderRef,
ImageRef,
InProcessCtxProvider,
InpaintModelRef,
InProcessExecutionBackend,
InProcessOps,
InProcessRefResolver,
MaskRef,
ModelRef,
bind_runtime,
ExecutionPlan,
)
from comfy_api.v0_0_3 import io
@@ -74,6 +92,22 @@ class _InvertWithUi(io.ComfyNode):
return io.NodeOutput(out, ui={"text": ["hello"]})
class _ProgressWithPreview(io.ComfyNode):
SDK_REFS = True
@classmethod
def define_schema(cls):
return io.Schema(
node_id="_TestProgressWithPreview", category="test",
inputs=[io.Image.Input("image")], outputs=[io.Image.Output()],
)
@classmethod
async def execute(cls, image):
await sdk.ctx().progress.update(0.5, 1.0, preview=image)
return io.NodeOutput(image)
async def _run_full(node_cls, image):
import execution
@@ -104,6 +138,878 @@ def test_sdk_node_keeps_its_ui_output():
assert out.ui == {"text": ["hello"]}, f"ui was dropped: {out.ui!r}"
def test_progress_preview_resolves_image_ref_for_comfy(monkeypatch):
import comfy.utils
updates = []
class RecordingProgressBar:
def __init__(self, total, node_id=None):
self.total = total
self.node_id = node_id
def update_absolute(self, value, total=None, preview=None):
updates.append((self.node_id, value, total, preview))
monkeypatch.setattr(comfy.utils, "ProgressBar", RecordingProgressBar)
image = torch.zeros((1, 5, 7, 3), dtype=torch.float32)
got = _output_of(_ProgressWithPreview, image)
assert torch.equal(got, image)
assert len(updates) == 1
node_id, value, total, preview = updates[0]
assert (node_id, value, total) == ("1", 0.5, 1.0)
assert preview[0] == "PNG"
assert preview[1].size == (7, 5)
def test_image_brokers_control_execution_and_extra_metadata(tmp_path):
import folder_paths
from PIL import Image
old_output = folder_paths.get_output_directory()
old_temp = folder_paths.get_temp_directory()
output_dir = tmp_path / "output"
temp_dir = tmp_path / "temp"
output_dir.mkdir()
temp_dir.mkdir()
folder_paths.set_output_directory(str(output_dir))
folder_paths.set_temp_directory(str(temp_dir))
async def run():
refs = InProcessRefResolver()
plan = ExecutionPlan(
prompt_id="metadata",
node_id="1",
node_type="metadata-test",
prompt={"1": {"class_type": "metadata-test"}},
extra_pnginfo={"workflow": {"nodes": [{"id": 1}]}},
)
context = InProcessCtxProvider().build(plan)
image = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 2, 3, 3), dtype=torch.float32)))
with bind_runtime(refs, context, InProcessOps()):
normal = await context.output.save_images(
image, filename_prefix="with_metadata",
extra_metadata={"Title": "Crystools"})
private = await context.output.save_images(
image, filename_prefix="without_workflow",
save_metadata=False,
extra_metadata={"Title": "Crystools"})
preview = await context.ui.preview_images(image)
return normal, private, preview
try:
normal, private, preview = asyncio.run(run())
normal_info = Image.open(pathlib.Path(
output_dir, normal["images"][0]["filename"])).info
assert json.loads(normal_info["prompt"])["1"]["class_type"] == (
"metadata-test")
assert json.loads(normal_info["workflow"])["nodes"][0]["id"] == 1
assert json.loads(normal_info["Title"]) == "Crystools"
private_info = Image.open(pathlib.Path(
output_dir, private["images"][0]["filename"])).info
assert "prompt" not in private_info
assert "workflow" not in private_info
assert json.loads(private_info["Title"]) == "Crystools"
preview_info = Image.open(pathlib.Path(
temp_dir, preview["images"][0]["filename"])).info
assert "prompt" in preview_info
assert "workflow" in preview_info
finally:
folder_paths.set_output_directory(old_output)
folder_paths.set_temp_directory(old_temp)
def test_system_stats_are_bounded_resource_totals(monkeypatch):
import comfy.model_management as model_management
device = torch.device("cpu")
monkeypatch.setattr(model_management, "get_torch_device", lambda: device)
monkeypatch.setattr(
model_management, "get_all_torch_devices", lambda: [device])
monkeypatch.setattr(
model_management, "get_torch_device_name", lambda value: "Test CPU")
def total(value, torch_total_too=False):
return (1000, 800) if torch_total_too else 4096
def free(value, torch_free_too=False):
return (400, 300) if torch_free_too else 1024
monkeypatch.setattr(model_management, "get_total_memory", total)
monkeypatch.setattr(model_management, "get_free_memory", free)
context = InProcessCtxProvider().build(ExecutionPlan(
prompt_id="stats", node_id="1", node_type="stats"))
stats = asyncio.run(context.system.stats())
assert stats == {
"system": {"ram_total": 4096, "ram_free": 1024},
"devices": [{
"name": "Test CPU",
"type": "cpu",
"index": None,
"vram_total": 1000,
"vram_free": 400,
"torch_vram_total": 800,
"torch_vram_free": 300,
}],
}
def test_conditioning_spatial_crop_keeps_tile_orchestration_pack_side():
class FakeControl:
def __init__(self, hint, extra, previous=None):
self.cond_hint_original = hint
self.cond_hint = object()
self.control_input = object()
self.extra_concat_orig = [extra]
self.previous_controlnet = previous
def copy(self):
clone = object.__new__(type(self))
clone.__dict__ = self.__dict__.copy()
return clone
def set_previous_controlnet(self, previous):
self.previous_controlnet = previous
async def run_crop():
refs = InProcessRefResolver()
ops = InProcessOps()
embedding = torch.tensor([[[1.0]]])
pooled = torch.tensor([[2.0]])
mask = torch.zeros((1, 8, 10))
mask[:, 2:5, 3:7] = 1.0
hint = torch.arange(3 * 64 * 80).reshape(1, 3, 64, 80)
extra = torch.arange(8 * 10).reshape(1, 1, 8, 10)
previous = FakeControl(hint + 1, extra + 1)
control = FakeControl(hint, extra, previous)
conditioning = [
[embedding, {
"area": (4, 5, 1, 2),
"mask": mask,
"gligen": (
"position", object(), [
(pooled, 4, 5, 1, 2),
(pooled, 1, 1, 7, 9),
],
),
"control": control,
}],
[embedding + 1, {"area": (1, 1, 7, 9)}],
[embedding + 2, {
"area": ("percentage", 0.5, 0.5, 0.25, 0.2),
}],
]
ref = CondRef._wrap(await refs.create("CONDITIONING", conditioning))
with bind_runtime(refs, None, ops):
cropped_ref = await ref.spatial_crop(
x=3, y=2, width=4, height=3,
source_width=10, source_height=8,
)
cropped = await refs.resolve(cropped_ref)
return cropped, conditioning, control, previous
cropped, original, control, previous = asyncio.run(run_crop())
assert len(cropped) == 2
assert cropped[0][0] is original[0][0]
assert cropped[0][1]["area"] == (3, 4, 0, 0)
assert cropped[0][1]["mask"].shape == (1, 3, 4)
assert torch.all(cropped[0][1]["mask"] == 1)
assert cropped[0][1]["gligen"][2] == [
(original[0][1]["gligen"][2][0][0], 3, 4, 0, 0),
]
assert cropped[1][1]["area"] == (3, 4, 0, 0)
cloned = cropped[0][1]["control"]
assert cloned is not control
assert cloned.previous_controlnet is not previous
assert torch.equal(
cloned.cond_hint_original,
control.cond_hint_original[..., 16:40, 24:56],
)
assert torch.equal(
cloned.extra_concat_orig[0],
control.extra_concat_orig[0][..., 2:5, 3:7],
)
assert cloned.cond_hint is None
assert cloned.control_input is None
assert control.cond_hint is not None
assert control.control_input is not None
def test_scheduled_cfg_guider_accepts_closed_sigma_bounds(monkeypatch):
import comfy.samplers
calls = []
def sampling_function(
inner_model, x, timestep, uncond, cond, cfg,
model_options=None, seed=None,
):
calls.append((uncond, cond, cfg))
return x
monkeypatch.setattr(comfy.samplers, "sampling_function", sampling_function)
class FakeModel:
model_options = {}
@staticmethod
def is_dynamic():
return False
async def run():
refs = InProcessRefResolver()
model = ModelRef._wrap(await refs.create("MODEL", FakeModel()))
positive = CondRef._wrap(await refs.create("CONDITIONING", []))
negative = CondRef._wrap(await refs.create("CONDITIONING", []))
with bind_runtime(refs, None, InProcessOps()):
guider_ref = await model.scheduled_cfg_guider(
positive, negative, 6.5,
bounds={"unit": "sigma", "start": 5.42, "end": 0.28},
)
guider = await refs.resolve(guider_ref)
guider.inner_model = "model"
guider.conds = {"positive": "positive", "negative": "negative"}
sample = torch.zeros((1, 1, 1, 1))
guider.predict_noise(sample, torch.tensor([5.0]))
guider.predict_noise(sample, torch.tensor([0.1]))
with pytest.raises(ValueError, match="at least"):
await model.scheduled_cfg_guider(
positive, negative, 6.5,
bounds={"unit": "sigma", "start": 0.28, "end": 5.42},
)
asyncio.run(run())
assert calls == [
("negative", "positive", 6.5),
(None, "positive", 1.0),
]
def test_sampling_spatial_crop_uses_patch_owned_protocol_for_model_and_guider():
import comfy.model_patcher
class SpatialPatch:
def __init__(self, label):
self.label = label
self.calls = []
def spatial_crop_inputs(self, **kwargs):
self.calls.append(kwargs)
return SpatialPatch(self.label + "-cropped")
class FakeModelPatcher(comfy.model_patcher.ModelPatcher):
def __del__(self):
pass
def __init__(self, patch):
self.model_options = {
"transformer_options": {
"patches": {
"first": [patch],
"second": [patch],
},
},
}
def clone(self):
clone = object.__new__(type(self))
patches = self.model_options["transformer_options"]["patches"]
clone.model_options = {
"transformer_options": {
"patches": {
name: list(values) for name, values in patches.items()
},
},
}
return clone
class Guider:
def __init__(self, model):
self.model_patcher = model
self.model_options = model.model_options
self.cfg = 4.0
async def run_crop():
refs = InProcessRefResolver()
ops = InProcessOps()
patch = SpatialPatch("hint")
model = FakeModelPatcher(patch)
guider = Guider(model)
model_ref = ModelRef._wrap(await refs.create("MODEL", model))
guider_ref = GuiderRef._wrap(await refs.create("GUIDER", guider))
params = {
"regions": [(0, 0, 32, 64), (32, 0, 64, 64)],
"source_width": 64,
"source_height": 64,
"target_width": 32,
"target_height": 64,
}
with bind_runtime(refs, None, ops):
cropped_model_ref = await model_ref.spatial_crop_inputs(**params)
cropped_guider_ref = await guider_ref.spatial_crop_inputs(**params)
cropped_model = await refs.resolve(cropped_model_ref)
cropped_guider = await refs.resolve(cropped_guider_ref)
return patch, model, guider, cropped_model, cropped_guider, params
patch, model, guider, cropped_model, cropped_guider, params = asyncio.run(
run_crop())
assert len(patch.calls) == 2
assert patch.calls == [params, params]
assert cropped_model is not model
first = cropped_model.model_options["transformer_options"]["patches"]
assert first["first"][0] is first["second"][0]
assert first["first"][0].label == "hint-cropped"
original = model.model_options["transformer_options"]["patches"]
assert original["first"][0] is patch
assert cropped_guider is not guider
assert cropped_guider.model_patcher is not model
assert cropped_guider.model_options is cropped_guider.model_patcher.model_options
guider_patches = cropped_guider.model_options[
"transformer_options"]["patches"]
assert guider_patches["first"][0] is guider_patches["second"][0]
def test_qwen_control_patches_crop_their_own_spatial_inputs(monkeypatch):
import comfy.latent_formats
from comfy_extras.nodes_model_patch import (
DiffSynthCnetPatch,
ZImageControlPatch,
)
monkeypatch.setattr(
comfy.latent_formats.Flux, "process_in", lambda _self, value: value)
class Vae:
@staticmethod
def encode(image):
return image.movedim(-1, 1)
@staticmethod
def spacial_compression_encode():
return 1
class ControlModel:
def __init__(self, additional_in_dim):
self.additional_in_dim = additional_in_dim
@staticmethod
def process_input_latent_image(value):
return value
class ControlPatcher:
def __init__(self, additional_in_dim):
self.model = ControlModel(additional_in_dim)
image = torch.arange(1 * 8 * 8 * 3, dtype=torch.float32).reshape(
1, 8, 8, 3)
inpaint = image.flip(2)
mask = torch.zeros((1, 1, 1, 8, 8), dtype=torch.float32)
mask[..., :4] = 1.0
params = {
"regions": [(0, 0, 4, 8), (4, 0, 8, 8)],
"source_width": 8,
"source_height": 8,
"target_width": 4,
"target_height": 8,
}
diffsynth = DiffSynthCnetPatch(
ControlPatcher(0), Vae(), image, 0.75)
diffsynth_crop = diffsynth.spatial_crop_inputs(**params)
assert diffsynth_crop is not diffsynth
assert diffsynth_crop.image.shape == (2, 8, 4, 3)
assert diffsynth_crop.encoded_image.shape == (2, 3, 8, 4)
assert torch.equal(diffsynth_crop.image[0], image[0, :, :4])
assert torch.equal(diffsynth_crop.image[1], image[0, :, 4:])
assert diffsynth.image.shape == (1, 8, 8, 3)
zimage = ZImageControlPatch(
ControlPatcher(1), Vae(), image, 0.5,
inpaint_image=inpaint, mask=mask,
)
zimage_crop = zimage.spatial_crop_inputs(**params)
assert zimage_crop is not zimage
assert zimage_crop.image.shape == (2, 8, 4, 3)
assert zimage_crop.inpaint_image.shape == (2, 8, 4, 3)
assert zimage_crop.mask.shape == (2, 1, 1, 8, 4)
assert zimage_crop.encoded_image.shape[0] == 2
assert zimage.image.shape == (1, 8, 8, 3)
assert zimage.mask.shape == (1, 1, 1, 8, 8)
def test_typed_inpaint_model_runs_host_side_primitive(monkeypatch):
import comfy.model_management
class FakeInpaintModel(torch.nn.Module):
def forward(self, image, mask):
return image * (1.0 - mask) + mask * 0.75
monkeypatch.setattr(
comfy.model_management, "get_torch_device",
lambda: torch.device("cpu"),
)
monkeypatch.setattr(
comfy.model_management, "unet_offload_device",
lambda: torch.device("cpu"),
)
cache_clears = []
monkeypatch.setattr(
comfy.model_management, "soft_empty_cache",
lambda: cache_clears.append(True),
)
async def run_inpaint():
refs = InProcessRefResolver()
ops = InProcessOps()
pixels = torch.full((2, 16, 24, 3), 0.2)
mask = torch.zeros((1, 16, 24))
mask[:, 4:12, 8:16] = 1.0
model_ref = InpaintModelRef._wrap(await refs.create(
"INPAINT_MODEL", {
"secure_kind": "image_inpaint.big-lama",
"model": FakeInpaintModel(),
"architecture": "big-lama",
"lock": threading.Lock(),
}))
image_ref = ImageRef._wrap(await refs.create("IMAGE", pixels))
mask_ref = MaskRef._wrap(await refs.create("MASK", mask))
with bind_runtime(refs, None, ops):
output_ref = await model_ref.inpaint(image_ref, mask_ref)
output = await refs.resolve(output_ref)
return output
output = asyncio.run(run_inpaint())
assert output.shape == (2, 16, 24, 3)
assert output.dtype == torch.float32
assert torch.allclose(output[:, :4], torch.full_like(output[:, :4], 0.2))
assert torch.allclose(
output[:, 4:12, 8:16],
torch.full_like(output[:, 4:12, 8:16], 0.75),
)
assert cache_clears == [True]
def test_background_removal_uses_typed_canonical_model_handle():
class FakeBackgroundRemoval:
@staticmethod
def encode_image(pixels):
return pixels[..., 0].clone()
async def run_mask():
refs = InProcessRefResolver()
ops = InProcessOps()
pixels = torch.zeros((2, 8, 10, 3), dtype=torch.float32)
pixels[..., 0] = torch.linspace(0, 1, 80).reshape(1, 8, 10)
model = BackgroundRemovalModelRef._wrap(await refs.create(
"BACKGROUND_REMOVAL_MODEL", {
"secure_kind": "background_removal.comfy",
"model": FakeBackgroundRemoval(),
"lock": threading.Lock(),
}))
image = ImageRef._wrap(await refs.create("IMAGE", pixels))
with bind_runtime(refs, None, ops):
mask_ref = await model.mask(image)
mask = await refs.resolve(mask_ref)
return pixels, mask
pixels, mask = asyncio.run(run_mask())
assert mask.shape == (2, 8, 10)
assert torch.equal(mask, pixels[..., 0])
def test_deep_shrink_uses_core_patch_with_pack_visible_metadata():
class Sampling:
@staticmethod
def percent_to_sigma(percent):
return 1.0 - float(percent)
class ModelConfig:
unet_config = {"context_dim": 2048}
class InnerModel:
model_config = ModelConfig()
class FakePatcher:
def __init__(self):
self.model = InnerModel()
self.input_patch = None
self.output_patch = None
def get_model_object(self, name):
assert name == "model_sampling"
return Sampling()
def clone(self):
return FakePatcher()
def set_model_input_block_patch_after_skip(self, patch):
self.input_patch = patch
def set_model_output_block_patch(self, patch):
self.output_patch = patch
async def run_patch():
refs = InProcessRefResolver()
ops = InProcessOps()
original = FakePatcher()
model = sdk.ModelRef._wrap(await refs.create("MODEL", original))
latent = sdk.LatentRef._wrap(await refs.create("LATENT", {
"samples": torch.zeros((1, 4, 96, 320)),
}))
with bind_runtime(refs, None, ops):
context_dim = await model.unet_context_dim()
spatial_shape = await latent.spatial_shape()
patched_ref = await model.patch(
"kohya_deep_shrink",
block_number=3,
downscale_factor=2.0,
start_percent=0.0,
end_percent=0.35,
downscale_after_skip=True,
downscale_method="bicubic",
upscale_method="bicubic",
)
patched = await refs.resolve(patched_ref)
return original, patched, context_dim, spatial_shape
original, patched, context_dim, spatial_shape = asyncio.run(run_patch())
assert context_dim == 2048
assert spatial_shape == (96, 320)
assert patched is not original
assert original.input_patch is None
assert callable(patched.input_patch)
assert callable(patched.output_patch)
def test_spatial_tiled_evaluation_is_one_synchronized_model_wrapper():
tile_contexts = []
def existing_wrapper(apply_model, args):
tile_contexts.append(
args["c"]["transformer_options"]["spatial_tile"])
return apply_model(
args["input"], args["timestep"], **args["c"]) + 1
class FakePatcher:
def __init__(self, parent=None):
self.parent = parent
self.model_options = {
"model_function_wrapper": existing_wrapper,
}
self.wrapper = existing_wrapper
def clone(self):
result = FakePatcher(self)
result.model_options = dict(self.model_options)
result.wrapper = self.wrapper
return result
def set_model_unet_function_wrapper(self, wrapper):
self.wrapper = wrapper
self.model_options["model_function_wrapper"] = wrapper
async def run_patch():
refs = InProcessRefResolver()
ops = InProcessOps()
original = FakePatcher()
model = sdk.ModelRef._wrap(await refs.create("MODEL", original))
with bind_runtime(refs, None, ops):
patched_ref = await model.patch(
"spatial_tiled_evaluation",
rows=2,
columns=3,
overlap=0.25,
overlap_x=1,
overlap_y=1,
blend="linear",
preserve_existing=True,
)
patched = await refs.resolve(patched_ref)
return original, patched
original, patched = asyncio.run(run_patch())
sample = torch.arange(96, dtype=torch.float32).reshape(1, 1, 8, 12)
def apply_model(value, _timestep, **_conditioning):
return value * 2
output = patched.wrapper(apply_model, {
"input": sample,
"timestep": torch.ones((1,)),
"c": {"transformer_options": {"kept": True}},
})
assert torch.allclose(output, sample * 2 + 1)
assert len(tile_contexts) == 6
assert all(context["source_height"] == 8 for context in tile_contexts)
assert all(context["source_width"] == 12 for context in tile_contexts)
assert original.wrapper is existing_wrapper
assert patched is not original
def test_diffusion_delta_and_concat_latent_are_separate_core_primitives(
tmp_path, monkeypatch,
):
import folder_paths
from safetensors.torch import save_file
patch_path = tmp_path / "ic-light.safetensors"
patch_state = {
"input_blocks.0.0.weight": torch.ones((2, 8, 1, 1)),
"input_blocks.0.0.bias": torch.full((2,), 0.25),
}
save_file(patch_state, str(patch_path))
monkeypatch.setattr(
folder_paths,
"get_full_path_or_raise",
lambda folder, name: str(patch_path)
if (folder, name) == ("model_patches", "ic-light.safetensors")
else (_ for _ in ()).throw(FileNotFoundError((folder, name))),
)
class Diffusion:
@staticmethod
def state_dict():
return {
"input_blocks.0.0.weight": torch.zeros((2, 4, 1, 1)),
"input_blocks.0.0.bias": torch.zeros((2,)),
}
class LatentFormat:
scale_factor = 0.5
class ModelConfig:
latent_format = LatentFormat()
class InnerModel:
diffusion_model = Diffusion()
model_config = ModelConfig()
class FakePatcher:
def __init__(self, parent=None):
self.parent = parent
self.model = InnerModel()
self.model_options = {}
self.patches = {}
self.wrapper = None
def clone(self):
result = FakePatcher(self)
result.patches = dict(self.patches)
result.model_options = dict(self.model_options)
result.wrapper = self.wrapper
return result
def add_patches(self, patches, strength):
self.patches.update({
key: (value, strength) for key, value in patches.items()
})
return list(patches)
def set_model_unet_function_wrapper(self, wrapper):
self.wrapper = wrapper
self.model_options["model_function_wrapper"] = wrapper
async def run_patch():
refs = InProcessRefResolver()
ops = InProcessOps()
original = FakePatcher()
model = sdk.ModelRef._wrap(await refs.create("MODEL", original))
latent_value = {
"samples": torch.arange(2 * 4 * 2 * 3, dtype=torch.float32)
.reshape(2, 4, 2, 3),
}
latent = sdk.LatentRef._wrap(await refs.create(
"LATENT", latent_value))
with bind_runtime(refs, None, ops):
weighted_ref = await model.patch(
"diffusion_weight_delta",
model_patch="ic-light.safetensors",
strength=1.0,
pad_input_channels=True,
)
combined_ref = await weighted_ref.patch(
"concat_latent_input", latent=latent)
weighted = await refs.resolve(weighted_ref)
combined = await refs.resolve(combined_ref)
return original, weighted, combined, latent_value
original, weighted, combined, latent_value = asyncio.run(run_patch())
assert original.patches == {}
assert set(weighted.patches) == {
"diffusion_model.input_blocks.0.0.weight",
"diffusion_model.input_blocks.0.0.bias",
}
weight_patch = weighted.patches[
"diffusion_model.input_blocks.0.0.weight"][0]
assert weight_patch[0] == "diff"
assert weight_patch[1][1] == {"pad_weight": True}
assert combined.wrapper is not None
sample = torch.zeros((2, 4, 2, 3))
def apply_model(**kwargs):
return kwargs
invoked = combined.wrapper(apply_model, {
"input": sample,
"timestep": torch.ones((2,)),
"c": {"tag": "kept"},
})
expected = torch.cat([
item.unsqueeze(0) for item in latent_value["samples"]
], dim=1).repeat(2, 1, 1, 1) * 0.5
assert invoked["tag"] == "kept"
assert torch.equal(invoked["c_concat"], expected)
def test_conditioning_masks_and_latent_composite_are_typed_primitives():
async def run_operations():
refs = InProcessRefResolver()
ops = InProcessOps()
conditioning_value = [[torch.ones((1, 2, 3)), {"tag": "source"}]]
conditioning = CondRef._wrap(await refs.create(
"CONDITIONING", conditioning_value))
mask_value = torch.ones((1, 16, 24))
mask = MaskRef._wrap(await refs.create("MASK", mask_value))
destination_value = {"samples": torch.zeros((1, 4, 4, 5))}
source_value = {"samples": torch.ones((1, 4, 2, 3))}
destination = sdk.LatentRef._wrap(await refs.create(
"LATENT", destination_value))
source = sdk.LatentRef._wrap(await refs.create(
"LATENT", source_value))
with bind_runtime(refs, None, ops):
masked_ref = await conditioning.with_mask(mask, strength=0.75)
composite_ref = await destination.composite(source)
masked = await refs.resolve(masked_ref)
composite = await refs.resolve(composite_ref)
return masked, composite, mask_value
masked, composite, mask_value = asyncio.run(run_operations())
assert masked[0][1]["tag"] == "source"
assert masked[0][1]["mask_strength"] == 0.75
assert masked[0][1]["set_area_to_bounds"] is False
assert torch.equal(masked[0][1]["mask"], mask_value)
assert torch.all(composite["samples"][..., :2, :3] == 1)
assert torch.all(composite["samples"][..., 2:, :] == 0)
assert torch.all(composite["samples"][..., :2, 3:] == 0)
def test_rgb_selection_and_latent_repeat_are_typed_primitives():
async def run_operations():
refs = InProcessRefResolver()
ops = InProcessOps()
pixels = torch.arange(2 * 3 * 4 * 4, dtype=torch.float32).reshape(
2, 3, 4, 4)
image = ImageRef._wrap(await refs.create("IMAGE", pixels))
latent_value = {
"samples": torch.arange(2 * 4 * 2 * 3).reshape(2, 4, 2, 3),
"noise_mask": torch.arange(2 * 2 * 3).reshape(2, 2, 3),
"batch_index": [4, 5],
}
latent = sdk.LatentRef._wrap(await refs.create(
"LATENT", latent_value))
with bind_runtime(refs, None, ops):
rgb_ref = await image.rgb()
repeated_ref = await latent.repeat_batch(3)
rgb = await refs.resolve(rgb_ref)
repeated = await refs.resolve(repeated_ref)
return pixels, latent_value, rgb, repeated
pixels, latent_value, rgb, repeated = asyncio.run(run_operations())
assert torch.equal(rgb, pixels[..., :3])
assert torch.equal(
repeated["samples"], latent_value["samples"].repeat(3, 1, 1, 1))
assert torch.equal(
repeated["noise_mask"], latent_value["noise_mask"].repeat(3, 1, 1))
assert repeated["batch_index"] == [4, 5, 6, 7, 8, 9]
def test_inpaint_primitives_delegate_to_canonical_core_nodes():
class FakeVae:
@staticmethod
def spacial_compression_encode():
return 8
@staticmethod
def encode(pixels):
return pixels.movedim(-1, 1).clone()
class FakePatcher:
def __init__(self, parent=None):
self.parent = parent
self.denoise_mask = None
def clone(self):
return FakePatcher(self)
def set_model_denoise_mask_function(self, function):
self.denoise_mask = function
async def run_operations():
refs = InProcessRefResolver()
ops = InProcessOps()
pixels = torch.full((1, 16, 16, 3), 0.25)
mask_value = torch.zeros((1, 16, 16))
mask_value[:, 7:9, 7:9] = 1.0
positive_value = [[torch.ones((1, 2, 3)), {"side": "positive"}]]
negative_value = [[torch.zeros((1, 2, 3)), {"side": "negative"}]]
image = ImageRef._wrap(await refs.create("IMAGE", pixels))
mask = MaskRef._wrap(await refs.create("MASK", mask_value))
vae = sdk.VaeRef._wrap(await refs.create("VAE", FakeVae()))
positive = CondRef._wrap(await refs.create(
"CONDITIONING", positive_value))
negative = CondRef._wrap(await refs.create(
"CONDITIONING", negative_value))
latent_with_mask = sdk.LatentRef._wrap(await refs.create("LATENT", {
"samples": torch.zeros((1, 4, 2, 2)),
"noise_mask": mask_value,
}))
original_model = FakePatcher()
model = sdk.ModelRef._wrap(await refs.create("MODEL", original_model))
with bind_runtime(refs, None, ops):
grown_ref = await mask.grow(1, tapered_corners=False)
latent_mask_ref = await latent_with_mask.noise_mask()
encoded_ref = await vae.encode_for_inpaint(
image, mask, grow_mask_by=2)
conditioned = await vae.encode_inpaint_conditioning(
image, grown_ref, positive, negative, noise_mask=True)
patched_ref = await model.patch(
"differential_diffusion", strength=0.75)
grown = await refs.resolve(grown_ref)
latent_mask = await refs.resolve(latent_mask_ref)
encoded = await refs.resolve(encoded_ref)
conditioned_values = [
await refs.resolve(item) for item in conditioned]
patched = await refs.resolve(patched_ref)
return (
grown, latent_mask, encoded, conditioned_values,
original_model, patched,
)
(
grown, latent_mask, encoded, conditioned,
original_model, patched,
) = asyncio.run(run_operations())
assert torch.count_nonzero(grown) > 4
assert torch.equal(latent_mask, torch.where(
latent_mask > 0, torch.ones_like(latent_mask), latent_mask))
assert encoded["samples"].shape == (1, 3, 16, 16)
assert encoded["noise_mask"].shape == (1, 1, 16, 16)
positive, negative, latent = conditioned
assert positive[0][1]["side"] == "positive"
assert negative[0][1]["side"] == "negative"
assert "concat_latent_image" in positive[0][1]
assert latent["samples"].shape == (1, 3, 16, 16)
assert patched is not original_model
assert patched.parent is original_model
assert callable(patched.denoise_mask)
def _output_of(node_cls, image):
return asyncio.run(_run(node_cls, image))
@@ -0,0 +1,113 @@
import asyncio
import pytest
import torch
from comfy_api.latest._sdk import (
ImageRef,
InProcessOps,
InProcessRefResolver,
LatentRef,
TensorRef,
VaeRef,
bind_runtime,
)
class _LegacyPackedVae:
"""Small stand-in for an old external channel-packed VAE loader."""
def __init__(self):
self.tiled_kwargs = None
def _assert_current_defaults(self):
assert self.handles_tiling is False
assert self.format_encoded is None
def encode(self, pixels):
self._assert_current_defaults()
return torch.zeros(
(pixels.shape[0], 4, pixels.shape[1], pixels.shape[2]),
dtype=pixels.dtype,
)
def decode(self, samples):
self._assert_current_defaults()
batch, _, height, width = samples.shape
return torch.arange(
batch * height * width * 12, dtype=torch.float32,
).reshape(batch, height, width, 12)
def decode_tiled(self, samples, **kwargs):
self._assert_current_defaults()
self.tiled_kwargs = kwargs
return self.decode(samples) + 1
def temporal_compression_decode(self):
return None
def spacial_compression_decode(self):
return 8
def test_vae_tensor_decode_keeps_channel_postprocessing_pack_side():
async def run():
refs = InProcessRefResolver()
ops = InProcessOps()
value = _LegacyPackedVae()
vae = VaeRef._wrap(await refs.create("VAE", value))
latent = LatentRef._wrap(await refs.create(
"LATENT", {"samples": torch.zeros((1, 4, 2, 3))},
))
image = ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 2, 3, 3)),
))
with bind_runtime(refs, None, ops):
encoded = await vae.encode(image)
decoded = await vae.decode_tensor(latent)
tiled = await vae.decode_tensor_tiled(
latent,
tile_size=64,
overlap=16,
temporal_size=64,
temporal_overlap=8,
)
return (
value,
await refs.resolve(encoded),
decoded,
await refs.resolve(decoded),
tiled,
await refs.resolve(tiled),
)
value, encoded, decoded_ref, decoded, tiled_ref, tiled = asyncio.run(run())
assert isinstance(decoded_ref, TensorRef) and decoded_ref.kind == "TENSOR"
assert isinstance(tiled_ref, TensorRef) and tiled_ref.kind == "TENSOR"
assert encoded["samples"].shape == (1, 4, 2, 3)
assert decoded.shape == (1, 2, 3, 12)
assert torch.equal(tiled, decoded + 1)
assert value.handles_tiling is False
assert value.format_encoded is None
assert value.tiled_kwargs == {
"tile_x": 8,
"tile_y": 8,
"overlap": 2,
"tile_t": None,
"overlap_t": None,
}
def test_vae_tensor_decode_uses_canonical_tile_bounds():
async def run():
refs = InProcessRefResolver()
vae = VaeRef._wrap(await refs.create("VAE", _LegacyPackedVae()))
latent = LatentRef._wrap(await refs.create(
"LATENT", {"samples": torch.zeros((1, 4, 2, 3))},
))
with bind_runtime(refs, None, InProcessOps()):
await vae.decode_tensor_tiled(latent, tile_size=32)
with pytest.raises(ValueError, match="tile_size"):
asyncio.run(run())
@@ -0,0 +1,114 @@
import asyncio
import pytest
import torch
import comfy.clip_vision
from comfy_api.latest._sdk import (
ClipVisionOutputRef,
CondRef,
ImageRef,
InProcessOps,
InProcessRefResolver,
VaeRef,
bind_runtime,
)
class _LayoutVae:
latent_channels = 16
@staticmethod
def spacial_compression_encode():
return 8
@staticmethod
def temporal_compression_encode():
return 4
def test_wan_layout_vision_conditioning_and_batch_selection_stay_opaque():
async def run():
refs = InProcessRefResolver()
vae = VaeRef._wrap(await refs.create("VAE", _LayoutVae()))
images_value = torch.arange(5 * 2 * 3 * 3).reshape(5, 2, 3, 3)
images = ImageRef._wrap(await refs.create("IMAGE", images_value))
first = comfy.clip_vision.Output()
first.penultimate_hidden_states = torch.arange(
8, dtype=torch.float32).reshape(1, 2, 4)
second = comfy.clip_vision.Output()
second.penultimate_hidden_states = torch.arange(
12, dtype=torch.float32).reshape(1, 3, 4) + 100
first_ref = ClipVisionOutputRef._wrap(
await refs.create("CLIP_VISION_OUTPUT", first))
second_ref = ClipVisionOutputRef._wrap(
await refs.create("CLIP_VISION_OUTPUT", second))
conditioning_value = [[
torch.zeros((1, 1, 4)), {"stable": "metadata"},
]]
conditioning = CondRef._wrap(await refs.create(
"CONDITIONING", conditioning_value))
with bind_runtime(refs, None, InProcessOps()):
layout = await vae.latent_layout()
selected = await images.select_batch([4, 1])
combined = await first_ref.concat(second_ref)
attached = await conditioning.with_clip_vision_output(combined)
with pytest.raises(ValueError, match="unique integers"):
await images.select_batch([1, 1])
with pytest.raises(IndexError, match="out of range"):
await images.select_batch([5])
return (
refs,
layout,
await refs.resolve(selected),
await refs.resolve(combined),
await refs.resolve(attached),
conditioning_value,
)
refs, layout, selected, combined, attached, source = asyncio.run(run())
assert refs is not None
assert layout == {
"channels": 16,
"spatial_compression": 8,
"temporal_compression": 4,
}
assert torch.equal(selected, torch.stack((
torch.arange(5 * 2 * 3 * 3).reshape(5, 2, 3, 3)[4],
torch.arange(5 * 2 * 3 * 3).reshape(5, 2, 3, 3)[1],
)))
assert combined.penultimate_hidden_states.shape == (1, 5, 4)
assert attached[0][1]["stable"] == "metadata"
assert attached[0][1]["clip_vision_output"] is combined
assert "clip_vision_output" not in source[0][1]
def test_wan_layout_and_vision_concat_fail_closed():
class _BadLayout:
latent_channels = 0
@staticmethod
def spacial_compression_encode():
return 8
async def run():
refs = InProcessRefResolver()
vae = VaeRef._wrap(await refs.create("VAE", _BadLayout()))
first = comfy.clip_vision.Output()
first.penultimate_hidden_states = torch.zeros((1, 2, 4))
second = comfy.clip_vision.Output()
second.penultimate_hidden_states = torch.zeros((2, 2, 4))
first_ref = ClipVisionOutputRef._wrap(
await refs.create("CLIP_VISION_OUTPUT", first))
second_ref = ClipVisionOutputRef._wrap(
await refs.create("CLIP_VISION_OUTPUT", second))
with bind_runtime(refs, None, InProcessOps()):
with pytest.raises(ValueError, match="latent layout"):
await vae.latent_layout()
with pytest.raises(ValueError, match="compatible hidden states"):
await first_ref.concat(second_ref)
asyncio.run(run())
@@ -0,0 +1,52 @@
import asyncio
from types import SimpleNamespace
import pytest
from comfy_api.latest._sdk import (
ExecutionPlan,
InProcessCtxProvider,
InProcessOps,
InProcessRefResolver,
OpaqueRef,
bind_runtime,
)
def _plan():
return ExecutionPlan(
prompt_id="wanvideo",
node_id="1",
node_type="wanvideo-test",
prompt={"1": {"class_type": "wanvideo-test"}},
extra_pnginfo={},
)
def test_wanvideo_projects_only_a_bounded_transformer_dimension():
async def run():
refs = InProcessRefResolver()
context = InProcessCtxProvider().build(_plan())
with bind_runtime(refs, context, InProcessOps()):
model = OpaqueRef._wrap(await refs.create(
"OPAQUE",
SimpleNamespace(model=SimpleNamespace(
diffusion_model=SimpleNamespace(dim=5120))),
))
assert await context.integrations.wanvideo.transformer_dim(
model) == 5120
missing = OpaqueRef._wrap(await refs.create(
"OPAQUE", SimpleNamespace(model=SimpleNamespace())))
with pytest.raises(ValueError, match="does not publish"):
await context.integrations.wanvideo.transformer_dim(missing)
invalid = OpaqueRef._wrap(await refs.create(
"OPAQUE",
SimpleNamespace(model=SimpleNamespace(
diffusion_model=SimpleNamespace(dim=0))),
))
with pytest.raises(ValueError, match="invalid"):
await context.integrations.wanvideo.transformer_dim(invalid)
asyncio.run(run())
@@ -0,0 +1,177 @@
from __future__ import annotations
import asyncio
import types
import pytest
import torch
from comfy_api.latest import _sdk
def _state(indices, is_skip_list=True):
cls = type("InterpolationStateList", (), {})
value = cls()
value.frame_indices = indices
value.is_skip_list = is_skip_list
return value
def test_interpolation_states_projects_skip_and_keep_lists_as_data():
async def run(value):
refs = _sdk.InProcessRefResolver()
token = await refs.create("INTERPOLATION_STATES", value)
state = _sdk.InterpolationStatesRef._wrap(token)
with _sdk.bind_runtime(
refs, types.SimpleNamespace(), _sdk.InProcessOps(),
):
return await state.skip_mask(5)
assert asyncio.run(run(_state([1, 3]))) == [False, True, False, True, False]
assert asyncio.run(run(_state([1, 3], False))) == [True, False, True, False, True]
def test_interpolation_states_never_invokes_foreign_behavior():
touched = []
class InterpolationStateList:
def __init__(self):
self.frame_indices = [0]
self.is_skip_list = True
def is_frame_skipped(self, _index):
touched.append("method")
raise AssertionError
def __iter__(self):
touched.append("iter")
raise AssertionError
def __repr__(self):
touched.append("repr")
raise AssertionError
value = InterpolationStateList()
assert _sdk._ref_type_for(value) == (
_sdk.InterpolationStatesRef, "INTERPOLATION_STATES")
async def run():
refs = _sdk.InProcessRefResolver()
state = _sdk.InterpolationStatesRef._wrap(
await refs.create("INTERPOLATION_STATES", value))
with _sdk.bind_runtime(refs, None, _sdk.InProcessOps()):
return await state.skip_mask(2)
assert asyncio.run(run()) == [True, False]
assert touched == []
def test_interpolation_states_rejects_malformed_or_unbounded_data():
async def run(value, pair_count=2):
refs = _sdk.InProcessRefResolver()
state = _sdk.InterpolationStatesRef._wrap(
await refs.create("INTERPOLATION_STATES", value))
with _sdk.bind_runtime(refs, None, _sdk.InProcessOps()):
return await state.skip_mask(pair_count)
with pytest.raises(TypeError, match="frame indices must be integers"):
asyncio.run(run(_state([True])))
with pytest.raises(ValueError, match="non-negative"):
asyncio.run(run(_state([-1])))
with pytest.raises(TypeError, match="pair_count must be an integer"):
asyncio.run(run(_state([]), True))
with pytest.raises(ValueError, match=r"\[1, 100000\]"):
asyncio.run(run(_state([]), 100_001))
class _ScaleModel(torch.nn.Module):
def __init__(self):
super().__init__()
self.anchor = torch.nn.Parameter(torch.ones(()))
def forward(self, value):
return torch.nn.functional.interpolate(
value, scale_factor=2.0, mode="nearest")
class _Upscaler:
def __init__(self):
self.model = _ScaleModel()
self.scale = 2
def to(self, *args, **kwargs):
self.model.to(*args, **kwargs)
return self
def __call__(self, value):
return self.model(value)
def test_upscale_model_uses_requested_initial_tile_and_oom_fallback(monkeypatch):
import comfy.model_management
import comfy.utils
calls = []
def tiled_scale(value, fn, *, tile_x, tile_y, **_kwargs):
calls.append((tile_x, tile_y))
if tile_x == 512:
raise RuntimeError("synthetic oom")
return fn(value)
monkeypatch.setattr(comfy.model_management, "get_torch_device",
lambda: torch.device("cpu"))
monkeypatch.setattr(comfy.model_management, "intermediate_device",
lambda: torch.device("cpu"))
monkeypatch.setattr(comfy.model_management, "raise_non_oom",
lambda _error: None)
monkeypatch.setattr(comfy.model_management, "module_size",
lambda _model: 1)
monkeypatch.setattr(comfy.model_management, "free_memory",
lambda *_args: None)
monkeypatch.setattr(comfy.utils, "get_tiled_scale_steps",
lambda *_args, **_kwargs: 1)
monkeypatch.setattr(comfy.utils, "ProgressBar",
lambda _steps: object())
monkeypatch.setattr(comfy.utils, "tiled_scale", tiled_scale)
async def run():
refs = _sdk.InProcessRefResolver()
upscaler = _sdk.UpscaleModelRef._wrap(
await refs.create("UPSCALE_MODEL", _Upscaler()))
image = _sdk.ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 2, 3, 3), dtype=torch.float32)))
with _sdk.bind_runtime(refs, None, _sdk.InProcessOps()):
result = await upscaler.upscale(
image, tile_size=0, channels_last=True)
return await refs.resolve(result)
result = asyncio.run(run())
assert calls == [(512, 512), (256, 256)]
assert result.shape == (1, 4, 6, 3)
assert result.dtype == torch.float32
def test_upscale_model_omitted_tile_keeps_the_existing_direct_path(monkeypatch):
import comfy.model_management
import comfy.utils
monkeypatch.setattr(comfy.model_management, "get_torch_device",
lambda: torch.device("cpu"))
monkeypatch.setattr(comfy.utils, "ProgressBar",
lambda _steps: types.SimpleNamespace(update=lambda _n: None))
monkeypatch.setattr(
comfy.utils, "tiled_scale",
lambda *_args, **_kwargs: pytest.fail("tiled path must remain opt-in"))
async def run():
refs = _sdk.InProcessRefResolver()
upscaler = _sdk.UpscaleModelRef._wrap(
await refs.create("UPSCALE_MODEL", _Upscaler()))
image = _sdk.ImageRef._wrap(await refs.create(
"IMAGE", torch.zeros((1, 2, 3, 3), dtype=torch.float32)))
with _sdk.bind_runtime(refs, None, _sdk.InProcessOps()):
result = await upscaler.upscale(image)
return await refs.resolve(result)
assert asyncio.run(run()).shape == (1, 4, 6, 3)
+15
View File
@@ -0,0 +1,15 @@
import torch
from torch import nn
from comfy.ldm.lama import FourierUnit
def test_fourier_unit_preserves_non_square_spatial_shape():
unit = FourierUnit(4, nn).eval()
source = torch.randn((2, 4, 6, 10), dtype=torch.float32)
result = unit(source)
assert result.shape == source.shape
assert result.dtype == source.dtype
assert torch.isfinite(result).all()
@@ -0,0 +1,136 @@
from types import SimpleNamespace
from comfy import system_monitor
class _Psutil:
cpu_calls = 0
@classmethod
def cpu_percent(cls):
cls.cpu_calls += 1
return 37.5
@staticmethod
def virtual_memory():
return SimpleNamespace(total=1000, available=400)
@staticmethod
def disk_partitions(all=False):
assert all is False
return [
SimpleNamespace(mountpoint="/"),
SimpleNamespace(mountpoint="/Volumes/Private\x00 Data"),
]
@staticmethod
def disk_usage(mountpoint):
return SimpleNamespace(total=2000, free=500)
class _Device:
type = "cuda"
index = 0
class _Models:
device = _Device()
@classmethod
def get_torch_device(cls):
return cls.device
@classmethod
def get_all_torch_devices(cls):
return [cls.device]
@staticmethod
def get_total_memory(device, torch_total_too=False):
assert torch_total_too
return 3000, 2500
@staticmethod
def get_free_memory(device, torch_free_too=False):
assert torch_free_too
return 1000, 900
@staticmethod
def get_torch_device_name(device):
return "Fallback GPU"
class _Nvml:
NVML_TEMPERATURE_GPU = 0
@staticmethod
def nvmlDeviceGetHandleByIndex(index):
return index
@staticmethod
def nvmlDeviceGetMemoryInfo(handle):
return SimpleNamespace(total=4000, free=1500)
@staticmethod
def nvmlDeviceGetUtilizationRates(handle):
return SimpleNamespace(gpu=61)
@staticmethod
def nvmlDeviceGetTemperature(handle, sensor):
return 72
@staticmethod
def nvmlDeviceGetName(handle):
return b"Example GPU"
def test_projection_contains_bounded_metrics_without_mount_paths():
snapshot = system_monitor._collect_snapshot(_Psutil, _Models, _Nvml)
assert snapshot == {
"cpu": {"utilization_percent": 37.5},
"memory": {"total": 1000, "available": 400},
"volumes": [
{"id": "volume-0", "label": "Root", "total": 2000, "available": 500},
{"id": "volume-1", "label": "Private Data", "total": 2000, "available": 500},
],
"accelerators": [{
"id": "accelerator-0",
"name": "Example GPU",
"memory_total": 4000,
"memory_available": 1500,
"utilization_percent": 61.0,
"temperature_c": 72.0,
}],
}
assert "/Volumes" not in repr(snapshot)
def test_snapshot_is_cached_and_returned_as_an_independent_value(monkeypatch):
times = iter([1.0, 1.1, 1.3])
monkeypatch.setattr(system_monitor.time, "monotonic", lambda: next(times))
monkeypatch.setattr(
system_monitor,
"_collect_snapshot",
lambda: {"sample": _Psutil.cpu_percent()},
)
monkeypatch.setattr(system_monitor, "_cached_at", -1.0)
monkeypatch.setattr(system_monitor, "_cached_snapshot", None)
_Psutil.cpu_calls = 0
first = system_monitor.get_system_monitor_snapshot()
first["sample"] = -1
second = system_monitor.get_system_monitor_snapshot()
third = system_monitor.get_system_monitor_snapshot()
assert second == {"sample": 37.5}
assert third == {"sample": 37.5}
assert _Psutil.cpu_calls == 2
def test_missing_optional_sensors_are_null():
snapshot = system_monitor._collect_snapshot(_Psutil, _Models, None)
accelerator = snapshot["accelerators"][0]
assert accelerator["name"] == "Fallback GPU"
assert accelerator["utilization_percent"] is None
assert accelerator["temperature_c"] is None
@@ -0,0 +1,176 @@
from __future__ import annotations
import asyncio
import threading
import pytest
import execution
from comfy_api.latest import _sdk
class _Server:
client_id = None
last_node_id = None
def send_sync(self, *_args):
pass
class _Backend:
maintenance_interval_seconds = 0.25
def __init__(self) -> None:
self.events = []
self.loops = []
self.maintenance_loops = []
async def on_prompt_start(self, prompt_id, extra_data):
self.events.append(("start", prompt_id, extra_data))
self.loops.append(asyncio.get_running_loop())
async def on_prompt_end(self, prompt_id, extra_data):
self.events.append(("end", prompt_id, extra_data))
async def on_prompt_abort(self, prompt_id, extra_data):
self.events.append(("abort", prompt_id, extra_data))
async def maintenance(self):
self.maintenance_loops.append(asyncio.get_running_loop())
async def dispatch(self, _plan, local_call, _runtime=None):
return await local_call()
def _executor():
return execution.PromptExecutor(
_Server(),
cache_args={"ram": 0, "ram_inactive": 0},
)
def test_prompt_awaits_execution_backend_start_and_end_hooks():
backend = _Backend()
original = _sdk.providers.execution_backend
_sdk.providers.execution_backend = backend
extra_data = {"comfy_secure_tenant_id": "tenant-alice"}
try:
asyncio.run(_executor().execute_async({}, "job-1", extra_data, []))
finally:
_sdk.providers.execution_backend = original
assert backend.events == [
("start", "job-1", extra_data),
("end", "job-1", extra_data),
]
def test_prompt_ends_backend_lifecycle_when_execution_setup_raises(monkeypatch):
backend = _Backend()
original = _sdk.providers.execution_backend
_sdk.providers.execution_backend = backend
monkeypatch.setattr(
execution,
"DynamicPrompt",
lambda _prompt: (_ for _ in ()).throw(RuntimeError("setup failed")),
)
try:
with pytest.raises(RuntimeError, match="setup failed"):
asyncio.run(_executor().execute_async({}, "job-failed", {}, []))
finally:
_sdk.providers.execution_backend = original
assert [event[0] for event in backend.events] == ["start", "abort"]
def test_synchronous_prompt_worker_keeps_one_async_loop_for_warm_realms():
backend = _Backend()
original = _sdk.providers.execution_backend
_sdk.providers.execution_backend = backend
executor = _executor()
try:
executor.execute({}, "job-1", {}, [])
executor.execute({}, "job-2", {}, [])
assert executor.execution_backend_maintenance_interval() == 0.25
executor.maintain_execution_backend()
finally:
executor.close()
_sdk.providers.execution_backend = original
assert backend.loops[0] is backend.loops[1]
assert backend.maintenance_loops == [backend.loops[0]]
def test_cleanup_failure_does_not_mask_prompt_failure_or_stop_maintenance(
monkeypatch,
):
class FailingCleanupBackend(_Backend):
async def on_prompt_abort(self, prompt_id, extra_data):
raise RuntimeError("cleanup failed")
async def maintenance(self):
raise RuntimeError("maintenance failed")
backend = FailingCleanupBackend()
original = _sdk.providers.execution_backend
_sdk.providers.execution_backend = backend
monkeypatch.setattr(
execution,
"DynamicPrompt",
lambda _prompt: (_ for _ in ()).throw(RuntimeError("setup failed")),
)
executor = _executor()
try:
with pytest.raises(RuntimeError, match="setup failed"):
executor.execute({}, "job-failed", {}, [])
executor.maintain_execution_backend()
finally:
executor.close()
_sdk.providers.execution_backend = original
def test_prompt_executor_shutdown_cancels_active_prompt_and_scrubs_backend():
class BlockingBackend(_Backend):
def __init__(self) -> None:
super().__init__()
self.started = threading.Event()
self.release = threading.Event()
self.shutdown_called = False
async def on_prompt_start(self, prompt_id, extra_data):
await super().on_prompt_start(prompt_id, extra_data)
self.started.set()
while not self.release.is_set():
await asyncio.sleep(0.001)
async def shutdown(self):
self.shutdown_called = True
backend = BlockingBackend()
original = _sdk.providers.execution_backend
_sdk.providers.execution_backend = backend
executor = _executor()
outcome = []
def execute_prompt():
try:
executor.execute({}, "job-active", {}, [])
except BaseException as exc:
outcome.append(exc)
worker = threading.Thread(target=execute_prompt)
worker.start()
assert backend.started.wait(timeout=1)
try:
executor.request_shutdown()
finally:
backend.release.set()
worker.join(timeout=2)
executor.close()
_sdk.providers.execution_backend = original
assert not worker.is_alive()
assert len(outcome) == 1
assert isinstance(outcome[0], asyncio.CancelledError)
assert [event[0] for event in backend.events] == ["start", "abort"]
assert backend.shutdown_called
@@ -65,6 +65,23 @@ def test_non_string():
assert not validate_node_input(obj1, obj2)
def test_combo_option_lists_use_overlap_semantics():
received = ["normal", "karras", "ays"]
target = ["normal", "karras", "beta57"]
assert validate_node_input(received, target)
assert not validate_node_input(received, ["sgm_uniform", "simple"])
assert not validate_node_input(received, target, strict=True)
assert validate_node_input(["normal", "karras"], target, strict=True)
def test_empty_combo_option_lists_remain_closed():
assert validate_node_input([], [])
assert not validate_node_input(["normal"], [])
assert not validate_node_input([], ["normal"])
assert validate_node_input([], ["normal"], strict=True)
class NotEqualsOverrideTest(str):
"""Test class for ``__ne__`` override."""