mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-09-21 13:38:08 -05:00
Expand secure V2 SDK surface
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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__()
|
||||
|
||||
@@ -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={}):
|
||||
|
||||
@@ -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_
|
||||
@@ -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_
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
@@ -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
@@ -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())}
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in New Issue
Block a user